forked from upstream/filebrowser
Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4907225a1 | ||
|
|
cc136043f9 | ||
|
|
611dd8ea71 | ||
|
|
41e9ff27fa | ||
|
|
2eb352c2d3 | ||
|
|
f11d8df7e8 | ||
|
|
43bb4a36b4 | ||
|
|
3fd20bbee1 | ||
|
|
86a031be1d | ||
|
|
d1b5a18c83 | ||
|
|
d9fc8c0275 | ||
|
|
c67abefc7b | ||
|
|
6cad10afb5 | ||
|
|
3a53b13b3d | ||
|
|
3fca886136 | ||
|
|
16994cae79 | ||
|
|
2ecb9036aa | ||
|
|
7eb9f1b0fa | ||
|
|
d81be14d12 | ||
|
|
884a5fa221 | ||
|
|
bbcf472fe9 | ||
|
|
49075d85df | ||
|
|
b95311dff8 | ||
|
|
7d3aa0201f | ||
|
|
1048b528eb | ||
|
|
6d4c74b883 | ||
|
|
dbcfba993b | ||
|
|
e4d6f4fa51 | ||
|
|
2aa766000b | ||
|
|
315af7aa6e | ||
|
|
9399392956 | ||
|
|
2a17ccb2a9 | ||
|
|
c56b8cd6dc | ||
|
|
28ce27397f | ||
|
|
312e434bb5 | ||
|
|
ed22ce9421 | ||
|
|
cfa1a8c120 | ||
|
|
57487f7523 | ||
|
|
7a80433b2c | ||
|
|
30a00d59ef | ||
|
|
1b08b968db | ||
|
|
fcce47203f | ||
|
|
4de432e8ce | ||
|
|
8caa6d6a60 | ||
|
|
fc42b6fbc0 | ||
|
|
5c2e2f6af5 | ||
|
|
5121a6fa82 | ||
|
|
dc8d71ddb0 | ||
|
|
961bdd475f | ||
|
|
34b957be0f | ||
|
|
86c41db9c5 | ||
|
|
89d99090ec | ||
|
|
0dde7cbe59 | ||
|
|
dfe217d020 | ||
|
|
1d2ac2ab06 | ||
|
|
87923add8a | ||
|
|
be7df9000a | ||
|
|
1126fe7d95 | ||
|
|
4476d97f6e | ||
|
|
888cad356e | ||
|
|
18bd22ee95 | ||
|
|
ae2fabfae6 | ||
|
|
1e891452bc | ||
|
|
dc177b634d | ||
|
|
1b0f53dead | ||
|
|
19c19f70d7 | ||
|
|
bf11fc43f3 | ||
|
|
7b39fcc9ac | ||
|
|
fb1af87048 | ||
|
|
683c837d0c | ||
|
|
b95ce79d50 | ||
|
|
a0c6b123de | ||
|
|
5832a2ebfd | ||
|
|
a9d738d419 | ||
|
|
952484cd3d | ||
|
+4 |
f73ca51f94 | ||
|
+4 |
ad839f92ac | ||
|
|
eb47df7172 | ||
|
+4 |
f2f5d776ca | ||
|
|
4f73b4cc6b | ||
|
|
5d7d94d86a | ||
|
|
9723799e4f | ||
|
|
3e5f0312f3 | ||
|
|
131c3a3686 | ||
|
|
7e5a579dde | ||
|
|
cb2d4dc518 | ||
|
|
8587ffc092 | ||
|
|
de6fefad4e | ||
|
|
9d18a369c7 | ||
|
|
9b599347f7 | ||
|
|
37596b1e23 | ||
|
|
d35fb56587 | ||
|
|
5dab4c7b30 |
No files matched your search
@@ -1,4 +1,6 @@
|
||||
backend/**.db
|
||||
**.crt
|
||||
**.key
|
||||
backend/tmp
|
||||
backend/dist
|
||||
backend/backend*
|
||||
|
||||
@@ -80,6 +80,12 @@ jobs:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-no-config
|
||||
- name: settings
|
||||
dockerfile: ./_docker/Dockerfile.playwright-settings
|
||||
- name: screenshots
|
||||
dockerfile: ./_docker/Dockerfile.playwright-screenshots
|
||||
- name: previews
|
||||
dockerfile: ./_docker/Dockerfile.playwright-previews
|
||||
- name: jwt
|
||||
dockerfile: ./_docker/Dockerfile.playwright-jwt
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
images: |
|
||||
gtstef/filebrowser
|
||||
ghcr.io/gtsteffaniak/filebrowser
|
||||
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
|
||||
- name: Modify tags (strip 'v' and add version tags)
|
||||
id: modify-json
|
||||
run: |
|
||||
JSON='${{ steps.meta.outputs.json }}'
|
||||
@@ -68,15 +68,31 @@ jobs:
|
||||
sub(":v"; ":") # Strip the 'v'
|
||||
| select(test(":latest") | not)
|
||||
) |
|
||||
if any(.tags[]; test("beta")) then
|
||||
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor and major tags
|
||||
.tags += [
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
| ($version | split("-")) as $version_split
|
||||
| ($version_split[0] | split(".")) as $version_parts
|
||||
| ($version_split[1:] | join("-")) as $suffix
|
||||
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
|
||||
| ($version_parts[0]) as $major
|
||||
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
] |
|
||||
# Add suffix-only tags for beta (only if tag ends with -beta)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-beta")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:beta",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta",
|
||||
"gtstef/filebrowser:latest",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:latest"
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta"
|
||||
]
|
||||
else . end |
|
||||
if any(.tags[]; test("stable")) then
|
||||
# Add suffix-only tags for stable (and latest) (only if tag ends with -stable)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-stable")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable",
|
||||
@@ -138,13 +154,31 @@ jobs:
|
||||
| select(test(":latest") | not) # Remove the "latest" tag
|
||||
| . + "-slim" # Append "-slim" to the versioned tag
|
||||
) |
|
||||
if any(.tags[]; test("beta")) then
|
||||
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor and major tags
|
||||
.tags += [
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
| ($version | split("-")) as $version_split
|
||||
| ($version_split[0] | split(".")) as $version_parts
|
||||
| ($version_split[1:] | join("-")) as $suffix
|
||||
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
|
||||
| ($version_parts[0]) as $major
|
||||
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
] |
|
||||
# Add suffix-only tags for beta-slim (only if tag ends with -beta-slim)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:beta-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
|
||||
]
|
||||
else . end |
|
||||
if any(.tags[]; test("stable")) then
|
||||
# Add suffix-only tags for stable-slim (only if tag ends with -stable-slim)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/gtsteffaniak/playwright-base
|
||||
tags: latest
|
||||
tags: chromium
|
||||
- name: Build and push playwright-base
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
|
||||
@@ -18,6 +18,7 @@ frontend/tsconfig.tsbuildinfo
|
||||
/frontend/test-results
|
||||
/frontend/playwright-files
|
||||
/frontend/screenshots
|
||||
/frontend/generated
|
||||
/frontend/tests/playwright-files/demo*
|
||||
/frontend/loginAuth.json
|
||||
/backend/vendor
|
||||
@@ -27,6 +28,7 @@ frontend/tsconfig.tsbuildinfo
|
||||
/backend/srv
|
||||
/backend/http/dist
|
||||
/backend/http/embed
|
||||
/backend/debug
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
|
||||
+319
@@ -2,6 +2,325 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v1.3.0-beta
|
||||
|
||||
Note: a potentially breaking change for docker users: \the default user is now "filebrowser" 1000:1000 instead of root.
|
||||
|
||||
**New Features**:
|
||||
- New Sidebar Features
|
||||
- Sidebar tree navigation (#2006) (#350)
|
||||
- Source usage to be customized to show os-reported values rather than calculated. This can be changed per source by editing the source link in the sidebar. (#1266) (#982)
|
||||
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
|
||||
- new api to archive/unarchive files on the server
|
||||
- requires `create` user permissions
|
||||
- archiving actions respect `server.maxArchiveSize`
|
||||
- Added share icon to items that are shared (#1420)
|
||||
- Authentication enhancements
|
||||
- LDAP login support with OIDC feature parity. (#591)
|
||||
- userGroup for OIDC and LDAP, only users in a group will get access. (#1964)
|
||||
- Add JWT token authentication support (#1364)
|
||||
- Enhanced thumbnail and item previews
|
||||
- Added ability to show "motion preview" for folders with multiple child items that have previews. cycles through the first 4 images.
|
||||
- support for reading embedded images from raw image or heic/heif files (#215)
|
||||
- reorganized and simplified thumbnail settings in profile settings (#1968)
|
||||
- removed `highQuality` thumbnail option which only affected gallery view. Now its always enabled.
|
||||
- improved caching for unsupported images, the same file won't be attempted again with the same modtime.
|
||||
- supports 3d model previews.
|
||||
- FileWatcher also supports watching directories
|
||||
- Support previews for 3D model files (STL, OBJ, 3MF, etc.) (#1273)
|
||||
- supported formats via threejs: GLTF, GLB, OBJ, STL, PLY, DAE (Collada), 3MF, 3DS, USDZ, USD, USDA, USDC, AMF, VRML, WRL, VTK, VTP, PCD, XYZ, VOX, KMZ
|
||||
- supports animations (for formats that contain them)
|
||||
- supports embedded textures, external neighboring file textures, or textures in `/textures` subdirectory
|
||||
- Enhanced prompts
|
||||
- All prompts have a taskbar with a close button
|
||||
- Prompts can be freely moved by dragging taskbar
|
||||
- Prompt styling has been updated
|
||||
- Clicking outside of prompts no longer automatically closes them.
|
||||
- add webdav support (#209) -- thanks to @reddac for (#1764)
|
||||
- see docs on how to use
|
||||
- requires api an un-customized api token as password
|
||||
- respects access rules
|
||||
- requires download permission to view and modify/create/delete permission to modify.
|
||||
|
||||
**Notes**:
|
||||
- Docker images default to `filebrowser` user instead of root
|
||||
- reorganized api routes
|
||||
- consolidated tags for swagger to be more accurately grouped
|
||||
- tools are all behind `/api/tools` routes
|
||||
- `/api/raw` is deprecated (but functional). The `/api/resources/download` route will be used instead.
|
||||
- `/api/preview` has been removed and replaced with `/api/resources/preview`
|
||||
- `/api/onlyoffice` have been replaced with `/api/office`
|
||||
- `/api/shares` has been moved to `/api/share/list`
|
||||
- `/api/auth/tokens` has been moved to `/api/auth/token/list` and `/api/auth/token` added to get specific token info
|
||||
- `PUT /api/token` has been moved to `POST /api/token`
|
||||
- `/public/api/shareinfo` has been moved to `/public/api/share/info`
|
||||
- `POST /resources/bulk/delete` api has been moved to `DELETE /resources/bulk` (#1984)
|
||||
|
||||
**BugFixes**:
|
||||
- Long folder names get cut off at top navigation bar (#1934)
|
||||
|
||||
## v1.2.1-stable
|
||||
|
||||
**BugFixes**:
|
||||
- startup icon generation order issue (#2019)
|
||||
- login button on shares not showing (#2028)
|
||||
- removed file watcher link in context menu for shares (#2025)
|
||||
- Direct download link for single-file shares generates incorrect URL (#2026)
|
||||
- Deleting files causes error V1.2.0 #2039
|
||||
|
||||
## v1.2.7-beta
|
||||
|
||||
**BugFixes**:
|
||||
- head > title > infinitely duplicating titles (#2016)
|
||||
- Proxy auth not working with Nginx, stuck at logo (#2013)
|
||||
|
||||
## v1.2.6-beta
|
||||
|
||||
**Security**:
|
||||
- resolves https://github.com/gtsteffaniak/filebrowser/security/advisories/GHSA-8vrh-3pm2-v4v6
|
||||
|
||||
## v1.2.5-beta
|
||||
|
||||
**Notes**:
|
||||
- Dependency Updates for frontend and backend packages
|
||||
- Upgrade to go 1.26
|
||||
- file list prompt interaction behavior, single-click always selects, double-click navigates. (#1911)
|
||||
|
||||
**BugFixes**:
|
||||
- .ssa and .ass subtitle files get their styling removed during conversion (#1933)
|
||||
- custom icons issue
|
||||
- new database message for noauth (#1935)
|
||||
|
||||
## v1.2.4-beta
|
||||
|
||||
**Notes**:
|
||||
- Changed `/api/media/subtitles` api endpoint to better support subtitles.
|
||||
- Correcting some errors in French language (#1947)
|
||||
- add Dutch `nl.json` for frontend i18n -- thanks @Stephan-P (#1957)
|
||||
- share banner and icon images automatically serve a scaled down 1024x1024
|
||||
|
||||
**BugFixes**:
|
||||
- Can't access directories with space (and possibly other special characters) when browsing a public link share (#1956)
|
||||
- External ASS subtitles are not usable in web preview (#1933)
|
||||
- Filename blocking rename/move (#1950)
|
||||
- enforcedOtp: true blocks all user settings updates with HTTP 400 (#1962)
|
||||
- Share favicon URL not showing up and Share description causing endless loading (#1911)
|
||||
- folder sizes not updated for changes to filesystem outside of filebrowser (#1974)
|
||||
|
||||
## v1.2.3-beta
|
||||
|
||||
**Notes**:
|
||||
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
|
||||
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
|
||||
|
||||
**BugFixes**:
|
||||
- fix stuck motion preview popup window issue
|
||||
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
|
||||
- share delete with user scope issue
|
||||
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
|
||||
- delete prompt icons show image previews
|
||||
|
||||
## v1.2.2-beta
|
||||
|
||||
**New Features**:
|
||||
- Resizable sidebar (#1896)
|
||||
- OIDC Authentication: Change Button Text via `frontend.oidcLoginButtonText` (#1708)
|
||||
- improved favicon processing (#1899)
|
||||
- supports more formats
|
||||
- supports larger images
|
||||
- automatcially generates multiple favicon sizes on startup for non-svg images. Custom svg favicons need a companion *.png to exist broad compatibilty
|
||||
- Enhanced media playback: Ability to control the queue from your device's lock screen and notification panel - Will also show
|
||||
metadata of the current playing media if available (#1917)
|
||||
|
||||
**Notes**:
|
||||
- Better text file content detection (#1726)
|
||||
- More url encoding changes for API which should make things more consistent. Open issues if you see path/source not found errors.
|
||||
- adjustments to the startup behavior for sqlite index for reusing the previous database on startup
|
||||
- CTRL + B disables sticky sidebar forever (#1869)
|
||||
- added context menu back to duplicate finder
|
||||
- Sharing a link for uploads - folder/file access and UX polishing (#1902)
|
||||
- improved listing view and scrolling performance
|
||||
- improved image viewer which will utilize recent thumbnails as a placeholder when loading the full image.
|
||||
- Small reorganization of "Share" settings to make the popup clear (#1826)
|
||||
|
||||
**BugFixes**:
|
||||
- Added docker default `FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"` back -- mistakenly removed. see https://filebrowserquantum.com/en/docs/getting-started/config/#3-default-locations. (#1891)
|
||||
- Fix brand text in login screen (#1898)
|
||||
- Sidebar links cointains urls with 2x repeated source name (#1847)
|
||||
- 2 factor auth getting overwritten if you edit the auth from admin (#1819)
|
||||
- New created user's files are listing error, Probably related with language setting. (#1565)
|
||||
- thumbnail generation cpu/memory and concurrency bug.
|
||||
- added 75MB filesize limit for image previews
|
||||
- optimized concurrency for large vs small images
|
||||
|
||||
|
||||
## v1.2.1-beta
|
||||
|
||||
**New Features**:
|
||||
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
|
||||
|
||||
**Notes**:
|
||||
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850)
|
||||
- updated download api to use repeated `file` query param for path instead of `files` with comma. See swagger for details (#1881)
|
||||
|
||||
**BugFixes**:
|
||||
- OnlyOffice stopped working after 1.2.0 upgrade due to download api changes
|
||||
- Search result link to wrong path when search context other than / (#1863)
|
||||
- File actions doesn't work if using user scopes (#1879)
|
||||
- Drop items into folders doesn't work (#1860)
|
||||
- duplicate finder context menu (#1862)
|
||||
- File list in previews doesn't show the parent items (#1861)
|
||||
|
||||
## v1.2.0-beta
|
||||
|
||||
This is a major version update with many code changes which could cause unexpected behavior. Upgrades should proceed with caution and report any undesriable behavior by opening an issue on github. The index is now fully in sqlite database - see [Indexing Overview](https://filebrowserquantum.com/en/docs/features/indexing/) for more information.
|
||||
|
||||
Sorry for the delay -- a lot of effort went into this release, specifically about fine-tuning sqlite indexing. It took me a long time to iron out the behavior, but it should be mostly there now. Enjoy and report any issues as always :)
|
||||
|
||||
**New Features**:
|
||||
- SQLite-based indexing
|
||||
- reduced memory usage, higher CPU and IO usage.
|
||||
- index persistence between restarts (requires [persistent cacheDir](https://filebrowserquantum.com/en/docs/configuration/server/#cachedir))
|
||||
- new tool - "realtime file watcher" low latency if user has realtime permissions (#917)
|
||||
- Access control works with individual files too
|
||||
- conditionally hide symbolic links as indexing rule config (#1540)
|
||||
- External applications can connect to filebrowser over webDAV (#209) (#1764)
|
||||
- make breadcrumbs act as a drop area (#1785)
|
||||
- search multiple sources at once (#848)
|
||||
- file uploads resume from bad internet connection #1599
|
||||
- chunked downloads - fix for 524 errors on cloudflare #1502
|
||||
- made size calculation consistent: defaults to "size on disk" style to mimic "du -sh", and allows config `source.config.useLogicalSize: true` for 0 size folders and actual size file sizes. (#1266)
|
||||
- Feature: allow deleting selected duplicates from duplicate finder (#1659)
|
||||
- share changes:
|
||||
- opengraph support for shared links
|
||||
- Allow adding "share settings" option to customizable sidebar links (#1825)
|
||||
- file picker for share favicon and banner icon
|
||||
- Allow disabling "Login button" for shares (#1673)
|
||||
|
||||
**Notes**:
|
||||
- `server.cacheDirCleanup` defaults to `false` instead of `true`. For docker, you would still need to mount a cacheDir volume to persist cache between restarts.
|
||||
- indexing rules have been streamlined, see [wiki]. The previous style is deprecated but still functional.
|
||||
- improved listing load times for directories with metadata -- a two-pass approach. First a fast load to get the listing items, then a second api request to include metadata.
|
||||
- Enhanced delete prompt
|
||||
- bulkDelete API added and replaces the delete API for all UI actions. See swagger docs for usage.
|
||||
- raw download and patch resource api simplified to a single source per request. See swagger docs for usage.
|
||||
- UserScope function re-organization, if you notice any user scope issues, please open a GitHub issue.
|
||||
- file upload resume from bad internet connection #1599
|
||||
|
||||
**BugFixes**:
|
||||
- Source info not properly read from external storage NAS (#1727)
|
||||
- ensure user requests scopes are strongly enforced (#1789)
|
||||
|
||||
## New Contributors
|
||||
* @reddec made their first contribution in https://github.com/gtsteffaniak/filebrowser/pull/1764
|
||||
* @maximeborges made their first contribution in https://github.com/gtsteffaniak/filebrowser/pull/1849
|
||||
|
||||
**Full Changelog**: https://github.com/gtsteffaniak/filebrowser/compare/v1.1.6-beta...v1.2.0-beta
|
||||
|
||||
## v1.1.6-beta
|
||||
|
||||
**Notes**:
|
||||
- "showHidden" is now a backend attribute. Shares will NOT show hidden files by default unless configured to.
|
||||
- improved listing view UI performance, especially for folders with many items. (#1773)
|
||||
|
||||
**BugFixes**:
|
||||
- SortBy config is not respected when navigating next/previous (#1786)
|
||||
- unauthenticated users unable to preview files (#1792)
|
||||
- File permission not correctly applied( #1790)
|
||||
- API Key loses "download" permission after creation despite being enabled (#1799)
|
||||
- never exclude root folder (#1749)
|
||||
- sources with "files" name not showing after 1.1.x update (#1772)
|
||||
- Symbolic links can't be deleted from webui. (#1566)
|
||||
- panic: close of closed channel during indexing (#1771)
|
||||
- Public sharing returns 404 errors for album art (#1792)
|
||||
- Directories with some symbols in their name "fail to open" (#1808)
|
||||
|
||||
## v1.1.5-beta
|
||||
|
||||
**Notes**:
|
||||
- Major git container tag request #1756
|
||||
|
||||
**BugFixes**:
|
||||
- Sharing broken if source `disableIndexing: true` (#1742)
|
||||
- Password protected share permission issues (#1729) (#1606) (#1593)
|
||||
- Sidebar Sliding left (#1737)
|
||||
- file permissions not working (#1762)
|
||||
- handle Close() errors in archive creation (#1745)
|
||||
- Move custom CSS and user-selected theme styles to the end of the body (#1744)
|
||||
|
||||
## v1.1.4-beta
|
||||
|
||||
**New Features**:
|
||||
- toggle to show searching results with thumbnails #1545
|
||||
|
||||
**Notes**:
|
||||
- file list has loading spinner to help prevent double click issues and give better feedback
|
||||
- improved cacheDir startup checks
|
||||
- warning for slow read/write speeds below 50MB/s
|
||||
- warning for low free space below 20GB
|
||||
- warning if read/write latency is slow
|
||||
- fatal for any errors reading or writing to the directory
|
||||
- links to official docs for more info
|
||||
- access rule changes:
|
||||
- denied folders won't show up in parent directory listing view (#1684)
|
||||
- tools will respect access rules
|
||||
- shares will check access rules based on user that created the share
|
||||
|
||||
**BugFixes**:
|
||||
- passwords with special characters not working properly (#1648)
|
||||
- encoded filename navigation issue (#1711)
|
||||
- Fix/Improve some behaviors in nextPrevious (#1707)
|
||||
- Files recognized as folder if they have the same name as previously deleted folders #1697
|
||||
- multi logger support issue #1701
|
||||
|
||||
## v1.1.3-beta
|
||||
|
||||
**Notes**:
|
||||
- continue create user dir scope even if filesystem path creation fails #1509
|
||||
- access control cache is cleared more aggresively to ensure no delay
|
||||
- removed default .ico favicon in favor of .svg
|
||||
|
||||
**BugFixes**:
|
||||
- indexing used size increases over time #1685
|
||||
- download progress is not shown #1687
|
||||
|
||||
## v1.1.2-beta
|
||||
|
||||
**Notes**:
|
||||
- changes to duplicate detector
|
||||
- temp SQLite database each query to reduce memory pressure
|
||||
- max limit is 500 groups total
|
||||
- Downloads from UI requests utf-8 formatted names to support chinese and other characters. Updated swagger docs.
|
||||
- beta releases no longer publish to `latest` docker tag #1675
|
||||
|
||||
**BugFixes**:
|
||||
- better index status updates, fixing delays #1649
|
||||
- fixed long load times for listings with media info due sequential processing of files.
|
||||
- downloaded files always included `utf-8` in filename #1671
|
||||
- custom sidebar links allow external links like `https://google.com`
|
||||
- html title not populated correctly for links #1676
|
||||
|
||||
## v1.1.1-beta
|
||||
|
||||
**Notes**:
|
||||
- [docker] upgraded ffmpeg 8.0 to 8.0.1
|
||||
- stricter access control checks on file downloads.
|
||||
- Wrong translation on Chinese "save" button #1650
|
||||
- Incorrect Spanish Translations in Folder Creation and Rename Actions #1626
|
||||
- duplicate file detector has stricter partial checksum match #1617
|
||||
|
||||
**BugFixes**:
|
||||
- added missing exiftool to docker image for heic conversion orientation support
|
||||
- v1.1.0-beta - Incorrect naming of 1 file in directory-info #1621
|
||||
- disable only office viewing settings not applying
|
||||
- OnlyOffice integration does not work behind proxy authentication #1422
|
||||
- Newly created users "add on" to defined scope of previous user #1628 #1518
|
||||
- disable chown on upload / file saving #1469 #1546
|
||||
- Uploading a file will silently overwrite any existing file with the same name #1564
|
||||
- share file url issue
|
||||
- Fix drag and drop and rows in normal view #1651
|
||||
- Some text based files are not able to edit. #1567
|
||||
|
||||
## v1.1.0-beta
|
||||
|
||||
**New Features**:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
|
||||
[](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://hub.docker.com/r/gtstef/filebrowser)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
## Pinned
|
||||
|
||||
:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
|
||||
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
## About
|
||||
@@ -102,12 +104,12 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Min. Memory Requirements | 256 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
|
||||
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
|
||||
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
price | free | free | free | free tier | free tier | $99+ |
|
||||
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -144,3 +146,5 @@ Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
*Accessing files from FileBrowser via WebDav is a newly introduced and experimental feature.
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
FROM gtstef/ffmpeg:8.0-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
@@ -6,7 +6,6 @@ WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev upx
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
RUN go build -tags mupdf,musl -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
@@ -27,8 +26,9 @@ FROM alpine:latest
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
|
||||
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
@@ -42,7 +42,7 @@ COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
RUN [ "filebrowser", "version" ]
|
||||
RUN [ "ffmpeg", "-version" ]
|
||||
RUN [ "ffprobe", "-version" ]
|
||||
USER root
|
||||
RUN [ "exiftool", "-ver" ]
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM node:22-slim
|
||||
WORKDIR /app/frontend
|
||||
|
||||
RUN npm i @playwright/test oauth2-mock-server @types/node
|
||||
RUN npx playwright install --with-deps firefox
|
||||
RUN npx playwright install --with-deps chromium
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
COPY [ "./_docker/src/jwt/", "./" ]
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/", "./" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/previews/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/", "./" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
|
||||
@@ -7,6 +7,7 @@ COPY [ "./_docker/src/settings/", "./" ]
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/settings/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
@@ -17,4 +18,5 @@ COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser set -u testuser1,testuser1
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
RUN ./filebrowser set rule -f /tests/playwright-files -p / -r user -v admin -allow -c config.yaml
|
||||
RUN ./filebrowser set rule -f /tests/playwright-files -p /excluded -r user -v admin -c config.yaml
|
||||
@@ -4,7 +4,6 @@ ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add upx
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
RUN go build -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
@@ -17,12 +16,13 @@ COPY ./frontend/package.json ./
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN npm i --maxsockets 1
|
||||
RUN npm i
|
||||
COPY ./frontend/ ./
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
@@ -35,7 +35,6 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
USER root
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
services:
|
||||
local-playwright:
|
||||
container_name: local-playwright
|
||||
ports:
|
||||
- "8080:8080"
|
||||
build:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-local
|
||||
context: ../
|
||||
local-playwright-screenshots:
|
||||
container_name: local-playwright
|
||||
volumes:
|
||||
- ../frontend/generated:/app/frontend/generated
|
||||
entrypoint: ["/bin/sh", "-c", "./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots && npx playwright test --project light-screenshots"]
|
||||
build:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-local
|
||||
context: ../
|
||||
nginx-proxy-auth:
|
||||
image: nginx
|
||||
container_name: nginx-proxy-auth
|
||||
@@ -7,11 +22,30 @@ services:
|
||||
volumes:
|
||||
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
|
||||
nginx-proxy-jwt:
|
||||
image: nginx
|
||||
container_name: nginx-proxy-jwt
|
||||
ports:
|
||||
- "8081:8081" # Admin user
|
||||
- "8082:8082" # Regular user
|
||||
- "8083:8083" # Wrong key
|
||||
- "8084:8084" # No user
|
||||
volumes:
|
||||
- ./src/jwt/backend/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/jwt/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
|
||||
filebrowser-jwt:
|
||||
hostname: filebrowser
|
||||
volumes:
|
||||
- '../frontend:/home/frontend'
|
||||
- "./src/jwt/backend/config.yaml:/home/filebrowser/data/config.yaml"
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./_docker/Dockerfile.slim
|
||||
filebrowser:
|
||||
hostname: filebrowser
|
||||
volumes:
|
||||
- '../frontend:/home/frontend'
|
||||
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
|
||||
- "./src/proxy/backend/config.yaml:/home/filebrowser/data/config.yaml"
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./_docker/Dockerfile.slim
|
||||
@@ -0,0 +1,48 @@
|
||||
server:
|
||||
port: 8080
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: false
|
||||
jwt:
|
||||
enabled: true
|
||||
secret: "test-jwt-secret-key-for-development-only-do-not-use-in-production"
|
||||
algorithm: "HS256"
|
||||
header: "X-JWT-Assertion"
|
||||
userIdentifier: "sub"
|
||||
groupsClaim: "mygroup"
|
||||
adminGroup: "admin"
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: false
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: true
|
||||
api: false
|
||||
download: true
|
||||
create: true
|
||||
@@ -0,0 +1,66 @@
|
||||
# Admin user (port 8081)
|
||||
server {
|
||||
listen 8081;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
set $admin_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NTc2NiwiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNTc2NiwibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDU3NjYsInN1YiI6InRlc3RhZG1pbiJ9.pxuKGyMmEe94m0xQ7y1QzcUZgU-lZSvqjoKUVoVd3F4";
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion $admin_token;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
|
||||
# Regular user (port 8082)
|
||||
server {
|
||||
listen 8082;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
set $regular_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJleHAiOjIwODc1NjU2OTIsImdyb3VwcyI6WyJteWdyb3VwIl0sImlhdCI6MTc3MjIwNTY5MiwibmFtZSI6IlRlc3QgVXNlciIsIm5iZiI6MTc3MjIwNTY5Miwic3ViIjoidGVzdHVzZXIifQ.X8fUYbhnZpgvP5CnGdms9eGo8yRLyNhRbj7xUSxEgl8";
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion $regular_token;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
|
||||
# Valid token but signed with different secret (port 8083)
|
||||
server {
|
||||
listen 8083;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
# Valid JWT token structure, but signed with wrong secret - should fail signature verification
|
||||
set $wrong_key "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NjAyMywiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNjAyMywibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDYwMjMsInN1YiI6InRlc3RhZG1pbiJ9.afBobbJ3VQ-fKnUebGr-gxFIqegyICi6E3EgkUSeFNw";
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion $wrong_key;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
|
||||
# No authentication (port 8084)
|
||||
server {
|
||||
listen 8084;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Simple JWT generator without external dependencies
|
||||
func main() {
|
||||
// Secret key - must match the one in your config file
|
||||
secret := "wrong-key"
|
||||
|
||||
// Check if custom secret provided via command line
|
||||
if len(os.Args) > 1 {
|
||||
secret = os.Args[1]
|
||||
}
|
||||
|
||||
// Create token claims with 10 year expiration
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(10 * 365 * 24 * time.Hour) // 10 years
|
||||
|
||||
header := map[string]interface{}{
|
||||
"alg": "HS256",
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "testadmin", // username (subject)
|
||||
"email": "testadmin@example.com", // email claim
|
||||
"name": "Test Admin", // full name
|
||||
"groups": []string{"admin"}, // groups - includes admin for testing
|
||||
"iat": now.Unix(), // issued at
|
||||
"exp": expiresAt.Unix(), // expires at
|
||||
"nbf": now.Unix(), // not before
|
||||
}
|
||||
|
||||
// Encode header
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error encoding header: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
|
||||
// Encode claims
|
||||
claimsJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error encoding claims: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
|
||||
// Create signature
|
||||
message := headerB64 + "." + claimsB64
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write([]byte(message))
|
||||
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Combine to form complete JWT
|
||||
tokenString := message + "." + signature
|
||||
|
||||
// Output the token
|
||||
fmt.Println("=== JWT Token Generated ===")
|
||||
fmt.Println()
|
||||
fmt.Println("Token:")
|
||||
fmt.Println(tokenString)
|
||||
fmt.Println()
|
||||
// Verify token can be decoded
|
||||
parts := strings.Split(tokenString, ".")
|
||||
if len(parts) == 3 {
|
||||
fmt.Println("✓ Token structure is valid (3 parts)")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "✗ Invalid token structure\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save to file for easy access
|
||||
filename := "test-jwt-token.txt"
|
||||
if err := os.WriteFile(filename, []byte(tokenString), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Could not write token to file: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✓ Token saved to: %s\n", filename)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Also save to shell script for easy sourcing
|
||||
scriptContent := fmt.Sprintf("#!/bin/bash\n# Source this file to set JWT token as environment variable\n# Usage: source jwt-env.sh\nexport TEST_JWT_TOKEN=\"%s\"\necho \"JWT token loaded into TEST_JWT_TOKEN\"\n", tokenString)
|
||||
if err := os.WriteFile("jwt-env.sh", []byte(scriptContent), 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Could not write shell script: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Shell script saved to: jwt-env.sh")
|
||||
fmt.Println(" Usage: source jwt-env.sh")
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module test
|
||||
|
||||
go 1.25.6
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/jwt-setup.ts",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/jwt",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for different JWT test scenarios */
|
||||
projects: [
|
||||
{
|
||||
name: "admin-user",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8081", // Admin user JWT
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "regular-user",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8082", // Regular user JWT
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong-key",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8083", // Wrong secret key
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no-user",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8084", // No JWT token
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -8,22 +8,27 @@ server:
|
||||
name: "exclude"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
conditionals:
|
||||
rules:
|
||||
- folderPath: "excluded"
|
||||
- folderPath: "excludedButVisible"
|
||||
viewable: true
|
||||
rules:
|
||||
- folderPath: "excluded"
|
||||
- folderPath: "excludedButVisible"
|
||||
viewable: true
|
||||
- fileStartsWith: "startsWith"
|
||||
- fileName: "dontshow.txt"
|
||||
- path: "../frontend/tests/playwright-files2"
|
||||
name: "include"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
useLogicalSize: true
|
||||
rules:
|
||||
- includeRootItem: "myfolder"
|
||||
- includeRootItem: "subfolderExclusions"
|
||||
- includeRootItem: "folder#hash"
|
||||
- includeRootItem: "files"
|
||||
- filePath: "/subfolderExclusions/exclusionlist.sh"
|
||||
- folderPath: "/subfolderExclusions/folderItem"
|
||||
viewable: true
|
||||
conditionals:
|
||||
rules:
|
||||
- includeRootItem: "myfolder"
|
||||
- includeRootItem: "subfolderExclusions"
|
||||
- filePath: "/subfolderExclusions/exclusionlist.sh"
|
||||
- folderPath: "/subfolderExclusions/folderItem"
|
||||
viewable: true
|
||||
- fileStartsWith: "startsWith"
|
||||
- folderEndsWith: "Folder"
|
||||
- folderNames: 'folderNames'
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
|
||||
auth:
|
||||
methods:
|
||||
noauth: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
|
||||
userDefaults:
|
||||
showHidden: true
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/previews-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/previews",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
workers: 10,
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
|
||||
'--enable-webgl',
|
||||
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
|
||||
'--no-sandbox', // Required for Docker
|
||||
],
|
||||
},
|
||||
storageState: "previews.json",
|
||||
actionTimeout: 5000,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/screenshots-setup.ts",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/screenshots",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
|
||||
'--enable-webgl',
|
||||
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
|
||||
'--no-sandbox', // Required for Docker
|
||||
],
|
||||
},
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:8080",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "dark-screenshots",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
name: "light-screenshots",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -13,6 +13,11 @@ server:
|
||||
config:
|
||||
disableIndexing: true
|
||||
defaultEnabled: true
|
||||
- path: "/tests/playwright-files"
|
||||
name: "access"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
denyByDefault: true
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ tmp_dir = "tmp"
|
||||
bin = "tmp/filebrowser"
|
||||
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs"]
|
||||
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs", "preview/thumbnails", "preview/heic"]
|
||||
log = "air.log"
|
||||
delay = 1000 # ms
|
||||
|
||||
@@ -14,4 +14,4 @@ tmp_dir = "tmp"
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
clean_on_exit = false
|
||||
@@ -18,4 +18,4 @@ tmp_dir = "tmp"
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
clean_on_exit = false
|
||||
@@ -3,3 +3,4 @@ air.log
|
||||
filebrowser
|
||||
generated.yaml
|
||||
test_config.yaml
|
||||
/data
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Cache interface for caching operations
|
||||
@@ -29,9 +32,61 @@ type FileCache struct {
|
||||
}
|
||||
}
|
||||
|
||||
// NewFileCache creates a new FileCache
|
||||
func NewFileCache(dir string) (*FileCache, error) {
|
||||
return &FileCache{dir: dir}, nil
|
||||
cacheDir := filepath.Join(dir, "diskcache")
|
||||
|
||||
// Migrate existing cache files from old structure (if any)
|
||||
if err := migrateOldCacheStructure(dir, cacheDir); err != nil {
|
||||
logger.Warningf("failed to migrate old cache structure: %v\n", err)
|
||||
}
|
||||
|
||||
// Create the cache directory immediately with proper permissions
|
||||
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
return &FileCache{dir: cacheDir}, nil
|
||||
}
|
||||
|
||||
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
|
||||
func migrateOldCacheStructure(oldDir, newDir string) error {
|
||||
// Read the old cache directory
|
||||
entries, err := os.ReadDir(oldDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // Directory doesn't exist yet, nothing to migrate
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
migrated := 0
|
||||
for _, entry := range entries {
|
||||
// Only migrate single-character directories (cache hash directories like a, b, c, etc.)
|
||||
if !entry.IsDir() || len(entry.Name()) != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if it's already the diskcache directory
|
||||
if entry.Name() == "d" && filepath.Join(oldDir, entry.Name()) == newDir {
|
||||
continue
|
||||
}
|
||||
|
||||
oldPath := filepath.Join(oldDir, entry.Name())
|
||||
newPath := filepath.Join(newDir, entry.Name())
|
||||
|
||||
// Create parent directory in new location
|
||||
if err := os.MkdirAll(newDir, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Move the directory
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
// If rename fails (e.g., cross-device), skip this one
|
||||
continue
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
@@ -40,11 +95,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
defer mu.Unlock()
|
||||
|
||||
fileName := f.getFileName(key)
|
||||
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fileName, value, 0600); err != nil {
|
||||
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,9 @@ func TestFileCache(t *testing.T) {
|
||||
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
|
||||
)
|
||||
|
||||
// Set up file permissions before creating cache
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
|
||||
// Create temporary directory for the cache
|
||||
cacheDir, err := os.MkdirTemp("", cacheRoot)
|
||||
require.NoError(t, err)
|
||||
@@ -28,19 +32,20 @@ func TestFileCache(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// store new key
|
||||
// Note: NewFileCache creates a "diskcache" subdirectory, so the actual path includes it
|
||||
err = cache.Store(ctx, key, []byte(value))
|
||||
require.NoError(t, err)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
|
||||
|
||||
// update existing key
|
||||
err = cache.Store(ctx, key, []byte(newValue))
|
||||
require.NoError(t, err)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
|
||||
|
||||
// delete key
|
||||
err = cache.Delete(ctx, key)
|
||||
require.NoError(t, err)
|
||||
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
|
||||
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
|
||||
require.False(t, exists)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetContent_UTF8Truncation(t *testing.T) {
|
||||
// Get the path to the test file
|
||||
// The test file is in frontend/tests/playwright-files/utf8-truncated.txt
|
||||
// We need to find it relative to the test execution directory
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Navigate from backend/adapters/fs/files to the project root
|
||||
// backend/adapters/fs/files -> backend/adapters/fs -> backend/adapters -> backend -> root
|
||||
testFilePath := filepath.Join(cwd, "..", "..", "..", "..", "frontend", "tests", "playwright-files", "utf8-truncated.txt")
|
||||
|
||||
// Check if file exists, if not try alternative path
|
||||
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
|
||||
// Try from project root
|
||||
testFilePath = filepath.Join("frontend", "tests", "playwright-files", "utf8-truncated.txt")
|
||||
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
|
||||
t.Skipf("Test file not found at %s, skipping test", testFilePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get absolute path
|
||||
var absPath string
|
||||
absPath, err = filepath.Abs(testFilePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("file with UTF-8 truncation at 4096 byte boundary", func(t *testing.T) {
|
||||
// The test file is longer than 4096 bytes, with valid UTF-8 throughout.
|
||||
// However, when reading exactly 4096 bytes as a header, it cuts off in the
|
||||
// middle of a multi-byte UTF-8 sequence (e6 9c, missing the last byte 88 of '月').
|
||||
// This should trigger the truncation handling in getContent's header validation.
|
||||
// The fix should trim the incomplete sequence from the header, allowing the
|
||||
// header check to pass, and then the full file (which is valid UTF-8) should
|
||||
// pass the full file validation.
|
||||
content, err := getContent(absPath)
|
||||
|
||||
// Should not return an error - the header truncation is handled, and the full file is valid
|
||||
require.NoError(t, err)
|
||||
|
||||
// The content should be the full valid UTF-8 file
|
||||
require.NotEmpty(t, content, "Content should not be empty - full file is valid UTF-8")
|
||||
|
||||
// Verify it contains the expected text
|
||||
require.Contains(t, content, "文件已备份", "Content should contain Chinese characters")
|
||||
require.Contains(t, content, "2024年", "Content should contain date information")
|
||||
})
|
||||
|
||||
t.Run("regular UTF-8 text file", func(t *testing.T) {
|
||||
// Test with a simple text file to ensure normal files still work
|
||||
tmpFile, err := os.CreateTemp("", "test-utf8-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
testText := "Hello, 世界! This is a test file with UTF-8 characters.\n"
|
||||
_, err = tmpFile.WriteString(testText)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
content, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testText, content)
|
||||
})
|
||||
|
||||
t.Run("file smaller than header size", func(t *testing.T) {
|
||||
// Test with a file smaller than 4096 bytes
|
||||
tmpFile, err := os.CreateTemp("", "test-small-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
testText := "Small file content"
|
||||
_, err = tmpFile.WriteString(testText)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
content, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testText, content)
|
||||
})
|
||||
|
||||
t.Run("file with Chinese characters at boundary", func(t *testing.T) {
|
||||
// Create a file that's exactly 4094 bytes, ending with a complete Chinese character
|
||||
tmpFile, err := os.CreateTemp("", "test-chinese-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
// Create content that's exactly 4094 bytes, ending with complete UTF-8
|
||||
baseText := "2024年 06月 17日 星期一 04:05:58 CST 文件已备份\n"
|
||||
content := ""
|
||||
for len([]byte(content)) < 4094 {
|
||||
content += baseText
|
||||
}
|
||||
// Trim to exactly 4094 bytes
|
||||
encoded := []byte(content)
|
||||
if len(encoded) > 4094 {
|
||||
encoded = encoded[:4094]
|
||||
}
|
||||
// Ensure it ends with a complete character by finding the last complete rune
|
||||
for len(encoded) > 0 {
|
||||
lastRune, _ := decodeLastRune(encoded)
|
||||
if lastRune != 0xFFFD { // RuneError
|
||||
break
|
||||
}
|
||||
encoded = encoded[:len(encoded)-1]
|
||||
}
|
||||
|
||||
_, err = tmpFile.Write(encoded)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
result, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result)
|
||||
require.Equal(t, string(encoded), result)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to check if last rune is valid
|
||||
func decodeLastRune(p []byte) (rune, int) {
|
||||
if len(p) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
r, size := utf8.DecodeLastRune(p)
|
||||
return r, size
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
@@ -190,11 +193,33 @@ func TestSortItems(t *testing.T) {
|
||||
// per design decision - RealPathCache is auxiliary and doesn't need clearing
|
||||
|
||||
func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
// Initialize the index in mock mode (no filesystem operations)
|
||||
// Use a temporary directory for cache to avoid creating directories in the source tree
|
||||
tmpDir := t.TempDir()
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = tmpDir
|
||||
defer func() {
|
||||
settings.Config.Server.CacheDir = originalCacheDir
|
||||
}()
|
||||
|
||||
// Ensure fileutils permissions are set (needed by NewTempDB)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Initialize the database first (use test helper to avoid permission issues)
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
// Initialize the index with scanning disabled to prevent background scanner interference
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: "/mock/path",
|
||||
}, true) // true for mock mode
|
||||
}, true, false) // true for mock mode, false for isNewDb
|
||||
|
||||
// Get the index and set up mock data
|
||||
idx := indexing.GetIndex("test")
|
||||
@@ -202,8 +227,18 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Create mock directory structure
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
|
||||
// Wait for initial scanner to complete (it will fail on /mock/path but needs to finish cleanup)
|
||||
// This ensures batchItems is nil before we start testing
|
||||
for i := 0; i < 50; i++ { // Wait up to 500ms
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
status := idx.GetScannerStatus()
|
||||
if status["status"] == "ready" || status["status"] == "unavailable" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock directory structure using UpdateMetadata
|
||||
rootInfo := &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
@@ -213,27 +248,28 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
{Name: "Test Object", Type: "directory"},
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Delete the old directory item from the database before replacing it with a file
|
||||
idx.DeleteMetadata("/Test Object", true, false)
|
||||
|
||||
// Simulate the directory-to-file override by updating the mock data
|
||||
// Remove the directory from the parent's Folders slice
|
||||
rootInfo := idx.Directories["/"] //nolint:staticcheck // t.Fatal terminates execution
|
||||
for i, folder := range rootInfo.Folders {
|
||||
if folder.Name == "Test Object" {
|
||||
rootInfo.Folders = append(rootInfo.Folders[:i], rootInfo.Folders[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
rootInfo.Folders = []iteminfo.ItemInfo{} // Clear folders
|
||||
|
||||
// Add the file to the parent's Files slice
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Size: 25, // Length of "This is test file content"
|
||||
rootInfo.Files = []iteminfo.ExtendedItemInfo{
|
||||
{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Size: 25, // Length of "This is test file content"
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Verify the directory was replaced with a file in the mock data
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true)
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
@@ -267,11 +303,33 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOverrideFileToDirectory(t *testing.T) {
|
||||
// Use a temporary directory for cache to avoid creating directories in the source tree
|
||||
tmpDir := t.TempDir()
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = tmpDir
|
||||
defer func() {
|
||||
settings.Config.Server.CacheDir = originalCacheDir
|
||||
}()
|
||||
|
||||
// Ensure fileutils permissions are set (needed by NewTempDB)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Initialize the database first (use test helper to avoid permission issues)
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
// Initialize the index in mock mode (no filesystem operations)
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: "/mock/path",
|
||||
}, true) // true for mock mode
|
||||
}, true, false) // true for mock mode, false for isNewDb
|
||||
|
||||
// Get the index and set up mock data
|
||||
idx := indexing.GetIndex("test")
|
||||
@@ -279,8 +337,18 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Create mock directory structure with a file
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
|
||||
// Wait for initial scanner to complete (it will fail on /mock/path but needs to finish cleanup)
|
||||
// This ensures batchItems is nil before we start testing
|
||||
for i := 0; i < 50; i++ { // Wait up to 500ms
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
status := idx.GetScannerStatus()
|
||||
if status["status"] == "ready" || status["status"] == "unavailable" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock directory structure with a file using UpdateMetadata
|
||||
rootInfo := &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
@@ -290,25 +358,26 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Delete the old file item from the database before replacing it with a directory
|
||||
idx.DeleteMetadata("/Test Object", false, false)
|
||||
|
||||
// Simulate the file-to-directory override by updating the mock data
|
||||
// Remove the file from the parent's Files slice
|
||||
rootInfo := idx.Directories["/"] //nolint:staticcheck // t.Fatal terminates execution
|
||||
for i, file := range rootInfo.Files {
|
||||
if file.Name == "Test Object" {
|
||||
rootInfo.Files = append(rootInfo.Files[:i], rootInfo.Files[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
rootInfo.Files = []iteminfo.ExtendedItemInfo{} // Clear files
|
||||
|
||||
// Add the directory to the parent's Folders slice
|
||||
rootInfo.Folders = append(rootInfo.Folders, iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Type: "directory",
|
||||
})
|
||||
rootInfo.Folders = []iteminfo.ItemInfo{
|
||||
{
|
||||
Name: "Test Object",
|
||||
Type: "directory",
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Verify the file was replaced with a directory in the mock data
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true)
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
|
||||
+394
-291
@@ -2,138 +2,268 @@ package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
index := indexing.GetIndex(opts.Source)
|
||||
if index == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
if !strings.HasPrefix(opts.Path, "/") {
|
||||
opts.Path = "/" + opts.Path
|
||||
}
|
||||
realPath, isDir, err := index.GetRealPath(opts.Path)
|
||||
if err != nil {
|
||||
return response, fmt.Errorf("could not get real path for requested path: %v, error: %v", opts.Path, err)
|
||||
}
|
||||
if !strings.HasSuffix(opts.Path, "/") && isDir {
|
||||
opts.Path = opts.Path + "/"
|
||||
}
|
||||
opts.IsDir = isDir
|
||||
var info *iteminfo.FileInfo
|
||||
|
||||
// Check if path is viewable (allows filesystem access without indexing)
|
||||
isViewable := index.IsViewable(isDir, opts.Path)
|
||||
|
||||
// For non-viewable paths, verify they are indexed
|
||||
// Skip this check if indexing is disabled for the entire source
|
||||
if !isViewable && !index.Config.DisableIndexing {
|
||||
err = index.RefreshFileInfo(opts)
|
||||
if err != nil {
|
||||
return response, fmt.Errorf("path not accessible: %v", err)
|
||||
// processDirectoryMetadata extracts metadata for audio/video files in directories
|
||||
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
|
||||
metadataCount := 0
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
if isItemAudio || isItemVideo {
|
||||
metadataCount++
|
||||
}
|
||||
}
|
||||
// Process files concurrently using goroutines
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // Protects processedCount
|
||||
|
||||
if isDir {
|
||||
info, err = index.GetFsDirInfo(opts.Path)
|
||||
// Refresh directory to update folder sizes
|
||||
wg.Go(func() {
|
||||
indexPath := idx.MakeIndexPath(response.Path, true)
|
||||
err := idx.RefreshDirectory(indexPath, false)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
} else {
|
||||
// For files, get info from parent directory to ensure HasPreview is set correctly
|
||||
info, err = index.GetFsDirInfo(opts.Path)
|
||||
if err != nil {
|
||||
return response, err
|
||||
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Set hasMetadata flag if there are files with potential metadata
|
||||
if metadataCount > 0 {
|
||||
response.HasMetadata = true
|
||||
}
|
||||
|
||||
response.FileInfo = *info
|
||||
response.RealPath = realPath
|
||||
response.Source = opts.Source
|
||||
// Only process metadata if explicitly requested
|
||||
if opts.Metadata && metadataCount > 0 {
|
||||
processedCount := 0
|
||||
|
||||
if access != nil && !access.Permitted(index.Path, opts.Path, opts.Username) {
|
||||
// User doesn't have access to the current folder, but check if they have access to any subitems
|
||||
// This allows specific allow rules on subfolders/files to work even when parent is denied
|
||||
err := access.CheckChildItemAccess(response, index, opts.Username)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
// Create a single shared FFmpegService instance for all files to coordinate concurrency
|
||||
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
|
||||
if sharedFFmpegService != nil {
|
||||
|
||||
// For directories, populate metadata for audio/video files ONLY if explicitly requested
|
||||
// This avoids expensive ffprobe calls on every directory listing
|
||||
if isDir && opts.Metadata {
|
||||
startTime := time.Now()
|
||||
metadataCount := 0
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
|
||||
if isItemAudio || isItemVideo {
|
||||
// Get the real path for this file
|
||||
itemRealPath, _, _ := index.GetRealPath(opts.Path, fileItem.Name)
|
||||
|
||||
// Extract metadata for audio files (without album art for performance)
|
||||
if isItemAudio {
|
||||
err := extractAudioMetadata(context.Background(), fileItem, itemRealPath, opts.AlbumArt || opts.Content, opts.Metadata)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract metadata for file: "+fileItem.Name, err)
|
||||
} else {
|
||||
metadataCount++
|
||||
}
|
||||
} else if isItemVideo {
|
||||
// Extract duration for video files
|
||||
err := extractVideoMetadata(context.Background(), fileItem, itemRealPath)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: "+fileItem.Name, err)
|
||||
} else {
|
||||
metadataCount++
|
||||
}
|
||||
if isItemAudio || isItemVideo {
|
||||
wg.Go(func() {
|
||||
// Extract metadata for audio files (without album art for performance)
|
||||
if isItemAudio {
|
||||
err := extractAudioMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, opts.AlbumArt, opts.Metadata, sharedFFmpegService)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
} else {
|
||||
mu.Lock()
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
} else {
|
||||
// Extract duration for video files
|
||||
err := extractVideoMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, sharedFFmpegService)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
} else {
|
||||
mu.Lock()
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if metadataCount > 0 {
|
||||
elapsed := time.Since(startTime)
|
||||
logger.Debugf("Extracted metadata for %d audio/video files in %v (avg: %v per file)",
|
||||
metadataCount, elapsed, elapsed/time.Duration(metadataCount))
|
||||
}
|
||||
}
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Extract content/metadata when explicitly requested OR for single file audio/video requests
|
||||
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
|
||||
if opts.Content || opts.Metadata || (!isDir && isAudioVideo) {
|
||||
processContent(response, index, opts)
|
||||
}
|
||||
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
|
||||
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
|
||||
// Add OnlyOffice ID if applicable
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
response.OnlyOfficeId = generateOfficeId(realPath)
|
||||
}
|
||||
|
||||
// Strip user scope from response path to return path relative to user's context
|
||||
if user != nil && userScope != "" && userScope != "/" {
|
||||
response.Path = strings.TrimPrefix(response.Path, userScope)
|
||||
if response.Path == "" {
|
||||
response.Path = "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CheckPermissions(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
if access == nil {
|
||||
return "", "", fmt.Errorf("access not provided")
|
||||
}
|
||||
if user == nil {
|
||||
return "", "", fmt.Errorf("user not provided")
|
||||
}
|
||||
if opts.Path == "" {
|
||||
return "", "", fmt.Errorf("path not provided")
|
||||
}
|
||||
if opts.Source == "" {
|
||||
return "", "", fmt.Errorf("source not provided")
|
||||
}
|
||||
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return "", "", fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
|
||||
// Resolve user scope
|
||||
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
|
||||
if scopeErr != nil || userScope == "" {
|
||||
return "", "", fmt.Errorf("user has no access to source: %v", opts.Source)
|
||||
}
|
||||
|
||||
safePath, err := utils.SanitizeUserPath(opts.Path)
|
||||
if err != nil {
|
||||
return "", "", errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Combine scope + sanitized path
|
||||
indexPath := utils.JoinPathAsUnix(userScope, safePath)
|
||||
// Layer 1: USER ACCESS CONTROL
|
||||
// Quick check: Does THIS user have permission?
|
||||
if !access.Permitted(idx.Path, indexPath, user.Username) {
|
||||
return "", "", errors.ErrAccessDenied
|
||||
}
|
||||
return indexPath, userScope, nil
|
||||
}
|
||||
|
||||
type Items struct {
|
||||
Files []string `json:"files,omitempty"`
|
||||
Folders []string `json:"folders,omitempty"`
|
||||
}
|
||||
|
||||
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
|
||||
items := Items{}
|
||||
indexPath, _, err := CheckPermissions(opts, access, user)
|
||||
if err != nil {
|
||||
return items, err
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return items, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
|
||||
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
Expand: true,
|
||||
SkipExtendedAttrs: true,
|
||||
})
|
||||
if err != nil {
|
||||
return items, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
if info.Type != "directory" {
|
||||
return items, fmt.Errorf("path is not a directory: %v ", indexPath)
|
||||
}
|
||||
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
|
||||
return items, err
|
||||
}
|
||||
if opts.Only == "files" || opts.Only == "" {
|
||||
for _, file := range info.Files {
|
||||
items.Files = append(items.Files, file.Name)
|
||||
}
|
||||
}
|
||||
if opts.Only == "folders" || opts.Only == "" {
|
||||
for _, folder := range info.Folders {
|
||||
items.Folders = append(items.Folders, folder.Name)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
indexPath, userScope, err := CheckPermissions(opts, access, user)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
// Layer 2: INDEX RULES (global)
|
||||
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
|
||||
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
Expand: opts.Expand,
|
||||
SkipExtendedAttrs: opts.SkipExtendedAttrs,
|
||||
})
|
||||
if err != nil {
|
||||
return response, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
|
||||
// Build response
|
||||
response.FileInfo = *info
|
||||
response.RealPath = filepath.Join(idx.Path, indexPath)
|
||||
response.Source = opts.Source
|
||||
|
||||
// Layer 3: FILTER CHILDREN (user access)
|
||||
// Remove child items THIS user can't access
|
||||
if info.Type == "directory" {
|
||||
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
|
||||
defer finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
if opts.SkipExtendedAttrs {
|
||||
return response, nil
|
||||
}
|
||||
if share != nil && user.Permissions.Share {
|
||||
for i := range response.Files {
|
||||
file := &response.Files[i]
|
||||
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path)
|
||||
}
|
||||
for i := range response.Folders {
|
||||
folder := &response.Folders[i]
|
||||
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path)
|
||||
}
|
||||
response.IsShared = share.IsShared(response.Path, idx.Path)
|
||||
}
|
||||
// Process directory metadata if requested
|
||||
if info.Type == "directory" {
|
||||
processDirectoryMetadata(response, idx, opts)
|
||||
}
|
||||
// Process single file content/metadata
|
||||
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
|
||||
if opts.Content || opts.Metadata || isAudioVideo {
|
||||
processContent(response, idx, opts)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -150,7 +280,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath)
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
@@ -158,17 +288,16 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
}
|
||||
|
||||
// Handle subtitles if requested
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
|
||||
if exists {
|
||||
info.DetectSubtitles(parentInfo)
|
||||
err := info.LoadSubtitleContent()
|
||||
if err != nil {
|
||||
logger.Debug("failed to load subtitle content: " + err.Error())
|
||||
}
|
||||
}
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
|
||||
if exists {
|
||||
info.GetSubtitles(parentInfo)
|
||||
}
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
info.Subtitles = append(info.Subtitles, subtitles...)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -177,13 +306,14 @@ 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)
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
// Copy metadata to ExtendedFileInfo
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
|
||||
info.HasMetadata = true
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -214,7 +344,8 @@ func generateOfficeId(realPath string) string {
|
||||
|
||||
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
|
||||
// and optionally extracts duration using the ffmpeg service with concurrency control
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool) error {
|
||||
// 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 {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -253,15 +384,14 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
|
||||
// This respects concurrency limits and gracefully handles missing ffmpeg
|
||||
if getDuration {
|
||||
ffmpegService := ffmpeg.NewFFmpegService(5, false, "")
|
||||
if ffmpegService != nil {
|
||||
startTime := time.Now()
|
||||
if duration, err := ffmpegService.GetMediaDuration(ctx, realPath); err == nil {
|
||||
// Use provided service or create a new one for backward compatibility
|
||||
service := ffmpegService
|
||||
if service == nil {
|
||||
service = ffmpeg.NewFFmpegService(5, false, "")
|
||||
}
|
||||
if service != nil {
|
||||
if duration, err := service.GetMediaDuration(ctx, realPath); err == nil {
|
||||
item.Metadata.Duration = int(duration)
|
||||
elapsed := time.Since(startTime)
|
||||
if elapsed > 50*time.Millisecond {
|
||||
logger.Debugf("Duration extraction took %v for file: %s", elapsed, item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,11 +400,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract album art and encode as base64 with strict size limits
|
||||
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
|
||||
if picture := m.Picture(); picture != nil && picture.Data != nil {
|
||||
// More aggressive size limit to prevent memory issues (max 5MB)
|
||||
// Size limit to prevent memory issues (max 5MB)
|
||||
if len(picture.Data) <= 5*1024*1024 {
|
||||
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
|
||||
item.Metadata.AlbumArt = picture.Data
|
||||
} else {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
|
||||
}
|
||||
@@ -284,11 +414,15 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
}
|
||||
|
||||
// extractVideoMetadata extracts duration from video files using ffprobe
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string) error {
|
||||
// Extract duration using the ffmpeg VideoService with concurrency control
|
||||
videoService := ffmpeg.NewFFmpegService(10, false, "")
|
||||
if videoService != nil {
|
||||
duration, err := videoService.GetMediaDuration(ctx, realPath)
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
// Use provided service or create a new one for backward compatibility
|
||||
service := ffmpegService
|
||||
if service == nil {
|
||||
service = ffmpeg.NewFFmpegService(10, false, "")
|
||||
}
|
||||
if service != nil {
|
||||
duration, err := service.GetMediaDuration(ctx, realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -302,14 +436,14 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteFiles(source, absPath string, absDirPath string, isDir bool) error {
|
||||
func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
|
||||
if !index.Config.DisableIndexing {
|
||||
indexPath := index.MakeIndexPath(absPath)
|
||||
indexPath := index.MakeIndexPath(absPath, isDir)
|
||||
|
||||
// Perform the physical deletion
|
||||
err := os.RemoveAll(absPath)
|
||||
@@ -317,19 +451,19 @@ func DeleteFiles(source, absPath string, absDirPath string, isDir bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear cache entries
|
||||
indexing.RealPathCache.Delete(absPath)
|
||||
indexing.IsDirCache.Delete(absPath + ":isdir")
|
||||
|
||||
// Remove metadata from index
|
||||
if isDir {
|
||||
// Recursively remove directory and all subdirectories from index
|
||||
index.DeleteMetadata(indexPath, true, true)
|
||||
} else {
|
||||
// Remove file from parent's file list
|
||||
index.DeleteMetadata(indexPath, false, false)
|
||||
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
|
||||
if !deleteSuccess {
|
||||
logger.Errorf("Failed to delete metadata from index for %s, but filesystem deletion succeeded", indexPath)
|
||||
}
|
||||
|
||||
// Refresh the parent directory to recalculate sizes and update counts
|
||||
// This will traverse up the tree and update all parent sizes correctly
|
||||
refreshConfig := utils.FileOptions{
|
||||
Path: index.MakeIndexPath(absDirPath),
|
||||
Path: index.MakeIndexPath(filepath.Dir(absPath), true),
|
||||
IsDir: true,
|
||||
}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
@@ -349,15 +483,20 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
return nil
|
||||
}
|
||||
// Always normalize path using MakeIndexPath
|
||||
path = idx.MakeIndexPath(path)
|
||||
path = idx.MakeIndexPath(path, isDir)
|
||||
|
||||
// MakeIndexPath always adds trailing slash, but for files we need to remove it
|
||||
if !isDir {
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
// Check if path is a symlink
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
isSymlink := false
|
||||
if realPath != "" {
|
||||
if fileInfo, err := os.Lstat(realPath); err == nil {
|
||||
isSymlink = fileInfo.Mode()&os.ModeSymlink != 0
|
||||
}
|
||||
}
|
||||
|
||||
// Skip indexing for viewable paths (viewable: true means don't index, just allow FS access)
|
||||
if idx.IsViewable(isDir, path) {
|
||||
// Skip indexing only for paths that are explicitly excluded (not viewable and should be skipped)
|
||||
hidden := indexing.IsHidden(filepath.Join(idx.Path, path))
|
||||
if idx.ShouldSkip(isDir, path, hidden, isSymlink, false) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -370,6 +509,8 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
if !Exists(realPath) {
|
||||
// Directory no longer exists, remove it from the index
|
||||
// This clears both Directories and DirectoriesLedger maps
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
idx.DeleteMetadata(path, true, false)
|
||||
return nil
|
||||
}
|
||||
@@ -377,6 +518,10 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
}
|
||||
|
||||
err := idx.RefreshFileInfo(utils.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
|
||||
if err != nil {
|
||||
logger.Errorf("RefreshFileInfo failed: %v", err)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -431,7 +576,7 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
}
|
||||
|
||||
// Prepare paths for index operations
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc)
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc, isSrcDir)
|
||||
srcParentPath := filepath.Dir(realsrc)
|
||||
|
||||
// Perform the physical move
|
||||
@@ -441,47 +586,52 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
}
|
||||
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !srcIdx.Config.DisableIndexing {
|
||||
// Remove metadata from source index
|
||||
if isSrcDir {
|
||||
// Recursively remove directory and all subdirectories from source index
|
||||
srcIdx.DeleteMetadata(srcIndexPath, true, true)
|
||||
} else {
|
||||
// Remove file from source parent's file list
|
||||
srcIdx.DeleteMetadata(srcIndexPath, false, false)
|
||||
}
|
||||
|
||||
// Refresh the source parent directory to recalculate sizes and update counts
|
||||
go RefreshIndex(sourceIndex, srcParentPath, true, false) //nolint:errcheck
|
||||
go func() {
|
||||
if isSrcDir {
|
||||
srcIdx.DeleteMetadata(srcIndexPath, true, true)
|
||||
} else {
|
||||
srcIdx.DeleteMetadata(srcIndexPath, false, false)
|
||||
}
|
||||
if err := RefreshIndex(sourceIndex, srcParentPath, true, false); err != nil {
|
||||
logger.Errorf("Failed to refresh source parent directory %s after move: %v", srcParentPath, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Handle DESTINATION indexing
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !dstIdx.Config.DisableIndexing {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// When moving a folder, refresh the folder itself recursively FIRST
|
||||
// Must complete before parent refresh to avoid race condition
|
||||
RefreshIndex(destIndex, realdst, true, true) //nolint:errcheck
|
||||
// Recursively index the moved directory tree
|
||||
if err := RefreshIndex(destIndex, realdst, true, true); err != nil {
|
||||
logger.Errorf("Failed to index moved directory %s: %v", realdst, err)
|
||||
return
|
||||
}
|
||||
|
||||
// THEN refresh the parent directory so it sees the newly indexed child
|
||||
// Refresh parent to update its size
|
||||
parentDir := filepath.Dir(realdst)
|
||||
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
if err := RefreshIndex(destIndex, parentDir, true, false); err != nil {
|
||||
logger.Errorf("Failed to refresh destination parent %s: %v", parentDir, err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// If moving a file, just refresh the parent directory
|
||||
// For files, refresh parent directory
|
||||
parentDir := filepath.Dir(realdst)
|
||||
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// Use backend source paths to match how shares are stored
|
||||
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.Path, dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
|
||||
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc, isSrcDir), dstIdx.Path, dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
|
||||
|
||||
// Update access rules for the moved path
|
||||
if a != nil {
|
||||
// If moving within the same source, update the rules
|
||||
if srcIdx.Path == dstIdx.Path {
|
||||
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
|
||||
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc, isSrcDir), dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
|
||||
}
|
||||
// Cross-source moves don't preserve access rules (they're source-specific)
|
||||
}
|
||||
@@ -497,37 +647,51 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
|
||||
// Validate the copy operation before executing
|
||||
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
|
||||
logger.Errorf("[COPY] Copy validation failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Perform the physical copy
|
||||
err := fileutils.CopyFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
logger.Errorf("[COPY] Physical copy failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// For copy operations:
|
||||
// 1. Shallow refresh of source (just to update access times if needed)
|
||||
// 2. Recursively index the destination to capture the entire copied tree
|
||||
|
||||
// Refresh source (parent directory if it's a file)
|
||||
srcRefreshPath := realsrc
|
||||
srcRefreshIsDir := isSrcDir
|
||||
if !isSrcDir {
|
||||
srcRefreshPath = filepath.Dir(realsrc)
|
||||
srcRefreshIsDir = true
|
||||
// Refresh source (non-recursive, just metadata)
|
||||
srcIdx := indexing.GetIndex(sourceIndex)
|
||||
if srcIdx != nil && !srcIdx.Config.DisableIndexing {
|
||||
srcRefreshPath := realsrc
|
||||
if !isSrcDir {
|
||||
srcRefreshPath = filepath.Dir(realsrc)
|
||||
}
|
||||
go RefreshIndex(sourceIndex, srcRefreshPath, true, false) //nolint:errcheck
|
||||
}
|
||||
|
||||
go RefreshIndex(sourceIndex, srcRefreshPath, srcRefreshIsDir, false) //nolint:errcheck
|
||||
// Refresh destination (RECURSIVE for directories to capture full tree)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
dstIdx := indexing.GetIndex(destIndex)
|
||||
if dstIdx != nil && !dstIdx.Config.DisableIndexing {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// Recursively index the copied directory tree
|
||||
if err := RefreshIndex(destIndex, realdst, true, true); err != nil {
|
||||
logger.Errorf("[COPY] Failed to index copied directory %s: %v", realdst, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh destination (parent directory if it's a file)
|
||||
dstRefreshPath := realdst
|
||||
if !isSrcDir {
|
||||
// If copying a file (regardless of destination), refresh the parent directory
|
||||
dstRefreshPath = filepath.Dir(realdst)
|
||||
// Refresh parent to update its size
|
||||
parentDir := filepath.Dir(realdst)
|
||||
if err := RefreshIndex(destIndex, parentDir, true, false); err != nil {
|
||||
logger.Errorf("[COPY] Failed to refresh parent %s: %v", parentDir, err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// For files, refresh parent directory
|
||||
dstParent := filepath.Dir(realdst)
|
||||
go RefreshIndex(destIndex, dstParent, true, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
go RefreshIndex(destIndex, dstRefreshPath, true, true) //nolint:errcheck
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -550,6 +714,7 @@ func WriteDirectory(opts utils.FileOptions) error {
|
||||
}
|
||||
|
||||
// Ensure the parent directories exist
|
||||
// Permissions are set by MkdirAll (subject to umask, which is usually acceptable)
|
||||
err = os.MkdirAll(realPath, fileutils.PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -557,11 +722,26 @@ func WriteDirectory(opts utils.FileOptions) error {
|
||||
|
||||
// Explicitly set directory permissions to bypass umask
|
||||
err = os.Chmod(realPath, fileutils.PermDir)
|
||||
if err != nil {
|
||||
// Handle chmod error gracefully
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
}
|
||||
|
||||
// Refresh the directory itself recursively
|
||||
err = RefreshIndex(idx.Name, opts.Path, true, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return RefreshIndex(idx.Name, opts.Path, true, true)
|
||||
// Refresh parent directory to update its size
|
||||
parentPath := filepath.Dir(opts.Path)
|
||||
if parentPath != "." && parentPath != "/" && parentPath != opts.Path {
|
||||
if err := RefreshIndex(idx.Name, parentPath, true, false); err != nil {
|
||||
logger.Debugf("Could not refresh parent directory %s: %v", parentPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteFile(source, path string, in io.Reader) error {
|
||||
@@ -580,15 +760,20 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
}
|
||||
var stat os.FileInfo
|
||||
// Check if the destination exists and is a directory
|
||||
if stat, err = os.Stat(realPath); err == nil && stat.IsDir() {
|
||||
// If it's a directory and we're trying to create a file, remove the directory first
|
||||
err = os.RemoveAll(realPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove existing directory to create file: %v", err)
|
||||
if stat, err = os.Stat(realPath); err == nil {
|
||||
if stat.IsDir() {
|
||||
// If it's a directory and we're trying to create a file, remove the directory first
|
||||
err = os.RemoveAll(realPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove existing directory to create file: %v", err)
|
||||
}
|
||||
}
|
||||
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
|
||||
}
|
||||
|
||||
// Open the file for writing (create if it doesn't exist, truncate if it does)
|
||||
// For new files: permissions are set to fileutils.PermFile (subject to umask, which is usually acceptable)
|
||||
// For existing files: permissions are preserved automatically (O_TRUNC doesn't change them)
|
||||
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -603,135 +788,53 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
|
||||
// Explicitly set file permissions to bypass umask
|
||||
err = os.Chmod(realPath, fileutils.PermFile)
|
||||
if err != nil {
|
||||
// Handle chmod error gracefully
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
}
|
||||
|
||||
// Refresh the file itself
|
||||
err = RefreshIndex(source, path, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return RefreshIndex(source, path, false, false)
|
||||
// Refresh parent directory to update its size
|
||||
parentPath := filepath.Dir(path)
|
||||
if parentPath != "." && parentPath != "/" && parentPath != path {
|
||||
if err := RefreshIndex(source, parentPath, true, false); err != nil {
|
||||
logger.Debugf("Could not refresh parent directory %s: %v", parentPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getContent reads and returns the file content if it's considered an editable text file.
|
||||
// This is a wrapper around utils.IsTextFile that also returns the file content.
|
||||
func getContent(realPath string) (string, error) {
|
||||
const headerSize = 4096
|
||||
// Thresholds for detecting binary-like content (these can be tuned)
|
||||
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
|
||||
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
|
||||
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
|
||||
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
|
||||
|
||||
// Open file
|
||||
f, err := os.Open(realPath)
|
||||
// First check if it's a text file using the consolidated validation logic
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Read header
|
||||
headerBytes := make([]byte, headerSize)
|
||||
n, err := f.Read(headerBytes)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
if !isText {
|
||||
// Not a text file, return empty string (no error, just not text)
|
||||
return "", nil
|
||||
}
|
||||
actualHeader := headerBytes[:n]
|
||||
|
||||
// --- Start of new heuristic checks ---
|
||||
|
||||
if n > 0 {
|
||||
// 1. Basic Check: Is the header valid UTF-8?
|
||||
// If not, it's unlikely an editable UTF-8 text file.
|
||||
if !utf8.Valid(actualHeader) {
|
||||
return "", nil // Not an error, just not the text file we want
|
||||
}
|
||||
|
||||
// 2. Check for excessive null bytes in the header
|
||||
nullCountInHeader := 0
|
||||
for _, b := range actualHeader {
|
||||
if b == 0x00 {
|
||||
nullCountInHeader++
|
||||
}
|
||||
}
|
||||
// Reject if too many nulls absolutely or relatively in the header
|
||||
if nullCountInHeader > 0 { // Only perform check if there are any nulls
|
||||
if nullCountInHeader > maxNullBytesInHeaderAbs ||
|
||||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
|
||||
return "", nil // Too many nulls in header
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for other non-text ASCII control characters in the header
|
||||
// (C0 controls excluding \t, \n, \r)
|
||||
for _, b := range actualHeader {
|
||||
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
|
||||
return "", nil // Found problematic control character
|
||||
}
|
||||
// C1 control characters (0x80-0x9F) would be caught by utf8.Valid if part of invalid sequences,
|
||||
// or by the non-printable rune check later if they form valid (but undesirable) codepoints.
|
||||
}
|
||||
|
||||
// Optional: Use http.DetectContentType for an additional check on the header
|
||||
// contentType := http.DetectContentType(actualHeader)
|
||||
// if !strings.HasPrefix(contentType, "text/") && contentType != "application/octet-stream" {
|
||||
// // If it's clearly a non-text MIME type (e.g., "image/jpeg"), reject it.
|
||||
// // "application/octet-stream" is ambiguous, so we rely on other heuristics.
|
||||
// return "", nil
|
||||
// }
|
||||
}
|
||||
// --- End of new heuristic checks for header ---
|
||||
|
||||
// Now read the full file (original logic)
|
||||
// It's a text file, read and return the content
|
||||
content, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Handle empty file (original logic - returns specific string)
|
||||
|
||||
// Handle empty file (return specific marker string)
|
||||
if len(content) == 0 {
|
||||
return "empty-file-x6OlSil", nil
|
||||
}
|
||||
|
||||
stringContent := string(content)
|
||||
|
||||
// 4. Final UTF-8 validation for the entire file
|
||||
// (This is crucial as the header might be fine, but the rest of the file isn't)
|
||||
if !utf8.ValidString(stringContent) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 5. Check for excessive null bytes in the entire file content
|
||||
if len(content) > 0 { // Check only for non-empty files
|
||||
totalNullCount := 0
|
||||
for _, b := range content {
|
||||
if b == 0x00 {
|
||||
totalNullCount++
|
||||
}
|
||||
}
|
||||
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
|
||||
return "", nil // Too many nulls in the entire file
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check for excessive non-printable runes in the entire file content
|
||||
// (Excluding tab, newline, carriage return, which are common in text files)
|
||||
if len(stringContent) > 0 { // Check only for non-empty strings
|
||||
nonPrintableRuneCount := 0
|
||||
totalRuneCount := 0
|
||||
for _, r := range stringContent {
|
||||
totalRuneCount++
|
||||
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
|
||||
// It excludes control characters. We explicitly allow \t, \n, \r.
|
||||
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
|
||||
nonPrintableRuneCount++
|
||||
}
|
||||
}
|
||||
|
||||
if totalRuneCount > 0 { // Avoid division by zero
|
||||
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
|
||||
return "", nil // Too many non-printable runes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The file has passed all checks and is considered editable text.
|
||||
return stringContent, nil
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
func IsNamedPipe(mode os.FileMode) bool {
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// MakeUserDir makes the user directory according to settings.
|
||||
@@ -25,37 +27,46 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
|
||||
for i, scope := range u.Scopes {
|
||||
source, ok := settings.Config.Server.SourceMap[scope.Name]
|
||||
if !ok {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
continue
|
||||
}
|
||||
// create directory and append user name
|
||||
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
|
||||
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
|
||||
parentDir := filepath.Join(source.Path, scope.Scope)
|
||||
// validate that scope path exists
|
||||
// If parent directory doesn't exist and createUserDir is enabled, create it
|
||||
if !Exists(parentDir) {
|
||||
return fmt.Errorf("MakeUserDirs: scope path does not exist: %s", scope.Scope)
|
||||
if err := MakeUserDir(parentDir); err != nil {
|
||||
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
|
||||
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
|
||||
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
// Continue to next scope even if this one failed
|
||||
}
|
||||
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
|
||||
// create directory exactly as specified
|
||||
fullPath := filepath.Join(source.Path, scope.Scope)
|
||||
parentDir := filepath.Dir(fullPath)
|
||||
if !Exists(parentDir) {
|
||||
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
|
||||
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
|
||||
continue
|
||||
}
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user: failed to create user home dir: %s", err)
|
||||
logger.Errorf("create user: failed to create user home dir: %s", err)
|
||||
// Continue to next scope even if this one failed
|
||||
}
|
||||
} else {
|
||||
// just assigning scope to path provided, so just check that it exists
|
||||
path := filepath.Join(source.Path, scope.Scope)
|
||||
if !Exists(path) {
|
||||
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
|
||||
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
|
||||
continue
|
||||
}
|
||||
}
|
||||
u.Scopes[i] = scope
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
assetFs fs.FS
|
||||
)
|
||||
|
||||
// Custom dirFS to handle both embedded and non-embedded file systems
|
||||
type dirFS struct {
|
||||
http.Dir
|
||||
}
|
||||
|
||||
// Implement the Open method for dirFS, which wraps http.Dir
|
||||
func (d dirFS) Open(name string) (fs.File, error) {
|
||||
return d.Dir.Open(name)
|
||||
}
|
||||
|
||||
// InitAssetFS initializes the asset filesystem for the application
|
||||
// This should be called once during startup before http or preview services start
|
||||
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
|
||||
if useEmbedded {
|
||||
assetFs = embeddedAssets
|
||||
} else {
|
||||
// Dev mode: Serve files from http/dist directory
|
||||
assetFs = dirFS{Dir: http.Dir("http/dist")}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAssetFS returns the initialized asset filesystem
|
||||
func GetAssetFS() fs.FS {
|
||||
return assetFs
|
||||
}
|
||||
@@ -62,6 +62,13 @@ func CopyFile(source, dest string) error {
|
||||
|
||||
// copySingleFile handles copying a single file.
|
||||
func copySingleFile(source, dest string) error {
|
||||
// Get source file info to preserve permissions
|
||||
srcInfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourcePerms := srcInfo.Mode().Perm()
|
||||
|
||||
// Open the source file.
|
||||
src, err := os.Open(source)
|
||||
if err != nil {
|
||||
@@ -75,8 +82,8 @@ func copySingleFile(source, dest string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the destination file.
|
||||
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, PermFile)
|
||||
// Create the destination file with source permissions
|
||||
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,11 +95,13 @@ func copySingleFile(source, dest string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the configured file permissions instead of copying from source
|
||||
err = os.Chmod(dest, PermFile)
|
||||
|
||||
// Preserve source file permissions
|
||||
// Handle chmod errors gracefully (e.g., in rootless containers where chmod may be restricted)
|
||||
err = os.Chmod(dest, sourcePerms)
|
||||
if err != nil {
|
||||
return err
|
||||
// Log but don't fail - chmod may be restricted in some environments
|
||||
// The file was copied successfully, so we continue
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build !windows
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// GetPartitionSize returns the filesystem size for Unix systems
|
||||
func GetPartitionSize(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Total size in bytes: Blocks * Block size
|
||||
total := uint64(stat.Blocks) * uint64(stat.Bsize)
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// GetFreeSpace returns the available free space for Unix systems
|
||||
func GetFreeSpace(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Available free space in bytes: Available blocks * Block size
|
||||
free := uint64(stat.Bavail) * uint64(stat.Bsize)
|
||||
return free, nil
|
||||
}
|
||||
|
||||
// GetPartitionUsed returns the used space for Unix systems (total - free)
|
||||
func GetPartitionUsed(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := uint64(stat.Blocks) * uint64(stat.Bsize)
|
||||
free := uint64(stat.Bavail) * uint64(stat.Bsize)
|
||||
used := total - free
|
||||
return used, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build windows
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// GetPartitionSize returns the filesystem size for Windows systems
|
||||
func GetPartitionSize(path string) (uint64, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var freeBytes, totalBytes, totalFreeBytes uint64
|
||||
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalBytes, nil
|
||||
}
|
||||
|
||||
// GetFreeSpace returns the available free space for Windows systems
|
||||
func GetFreeSpace(path string) (uint64, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var freeBytes, totalBytes, totalFreeBytes uint64
|
||||
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return freeBytes, nil
|
||||
}
|
||||
|
||||
// GetPartitionUsed returns the used space for Windows systems (total - free)
|
||||
func GetPartitionUsed(path string) (uint64, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var freeBytes, totalBytes, totalFreeBytes uint64
|
||||
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
used := totalBytes - freeBytes
|
||||
return used, nil
|
||||
}
|
||||
+48
-17
@@ -1,34 +1,65 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
var (
|
||||
revokedApiKeyList map[string]struct{}
|
||||
revokeMu sync.Mutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
revokedApiKeyList = make(map[string]struct{})
|
||||
}
|
||||
|
||||
// Auther is the authentication interface.
|
||||
type Auther interface {
|
||||
// Auth is called to authenticate a request.
|
||||
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
|
||||
}
|
||||
|
||||
func IsRevokedApiKey(key string) bool {
|
||||
_, exists := revokedApiKeyList[key]
|
||||
return exists
|
||||
const FB_ISSUER = "FileBrowser Quantum" // don't change
|
||||
|
||||
// IsRevokedApiToken checks if a token is in the revoked list.
|
||||
func IsRevokedApiToken(accessStore *access.Storage, token string) bool {
|
||||
if accessStore == nil {
|
||||
return false
|
||||
}
|
||||
return accessStore.IsTokenRevoked(token)
|
||||
}
|
||||
|
||||
func RevokeAPIKey(key string) {
|
||||
revokeMu.Lock()
|
||||
defer revokeMu.Unlock()
|
||||
revokedApiKeyList[key] = struct{}{}
|
||||
// RevokeApiToken adds a token to the revoked list.
|
||||
func RevokeApiToken(accessStore *access.Storage, token string) error {
|
||||
if accessStore == nil {
|
||||
return fmt.Errorf("access storage not available")
|
||||
}
|
||||
return accessStore.RevokeToken(token)
|
||||
}
|
||||
|
||||
func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions, minimal bool) (string, users.AuthToken, error) {
|
||||
if _, ok := user.Tokens[name]; ok {
|
||||
return "", users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
|
||||
}
|
||||
now := time.Now()
|
||||
expires := now.Add(duration)
|
||||
// Create minimal token with only JWT standard claims
|
||||
claim := users.AuthToken{
|
||||
MinimalAuthToken: users.MinimalAuthToken{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expires),
|
||||
Issuer: FB_ISSUER,
|
||||
},
|
||||
},
|
||||
}
|
||||
if !minimal {
|
||||
claim.Permissions = perms
|
||||
claim.BelongsTo = user.ID
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
|
||||
tokenString, err := token.SignedString([]byte(settings.Config.Auth.Key))
|
||||
if err != nil {
|
||||
return "", users.AuthToken{}, err
|
||||
}
|
||||
return tokenString, claim, nil
|
||||
|
||||
}
|
||||
@@ -22,6 +22,11 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
|
||||
username := r.URL.Query().Get("username")
|
||||
recaptcha := r.URL.Query().Get("recaptcha")
|
||||
password := r.Header.Get("X-Password")
|
||||
// URL-decode password to support special characters in headers
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid password encoding: %v", err)
|
||||
}
|
||||
totpCode := r.Header.Get("X-Secret")
|
||||
|
||||
// If ReCaptcha is enabled, check the code.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// MethodJwtAuth is used to identify JWT external authentication.
|
||||
const MethodJwtAuth = "jwt"
|
||||
|
||||
// JwtAuth is an external JWT implementation of an auther.
|
||||
// It verifies JWT tokens signed with a shared secret and extracts user identity.
|
||||
type JwtAuth struct {
|
||||
Secret string `json:"secret"` // shared secret for verifying JWT signatures
|
||||
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256, etc.)
|
||||
}
|
||||
|
||||
// ExternalJwtClaims represents the expected claims in an external JWT token
|
||||
type ExternalJwtClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Username string `json:"sub"` // subject claim (username)
|
||||
Email string `json:"email,omitempty"` // email claim
|
||||
Name string `json:"name,omitempty"` // full name claim
|
||||
Groups []string `json:"groups,omitempty"` // groups claim
|
||||
Custom map[string]interface{} `json:"-"` // catch-all for custom claims
|
||||
}
|
||||
|
||||
// Auth authenticates the user via an external JWT token.
|
||||
// The token is verified using the configured secret and algorithm.
|
||||
func (a JwtAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||
// This should not be called directly - JWT auth is handled in middleware
|
||||
// because we need access to the full config (header name, user identifier field, etc.)
|
||||
return nil, fmt.Errorf("JWT auth must be handled in middleware")
|
||||
}
|
||||
|
||||
// VerifyExternalJWT verifies an external JWT token and extracts the username claim
|
||||
func VerifyExternalJWT(tokenString string, secret string, algorithm string, userIdentifierField string) (string, map[string]interface{}, error) {
|
||||
// Parse the token with the secret
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify the signing method matches what we expect
|
||||
expectedMethod := jwt.GetSigningMethod(algorithm)
|
||||
if expectedMethod == nil {
|
||||
return nil, fmt.Errorf("unsupported signing algorithm: %s", algorithm)
|
||||
}
|
||||
|
||||
if token.Method.Alg() != expectedMethod.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// Return the secret key for HMAC algorithms
|
||||
// For RSA/ECDSA, this would need to be a public key
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Debugf("JWT token verification failed: %v", err)
|
||||
return "", nil, fmt.Errorf("invalid JWT token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return "", nil, fmt.Errorf("JWT token is invalid")
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("failed to parse JWT claims")
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if err := claims.Valid(); err != nil {
|
||||
logger.Debugf("JWT claims validation failed: %v", err)
|
||||
return "", nil, fmt.Errorf("JWT token is expired or invalid: %w", err)
|
||||
}
|
||||
|
||||
// Extract username from the configured field (default: "sub")
|
||||
var username string
|
||||
if userVal, ok := claims[userIdentifierField]; ok {
|
||||
if userStr, ok := userVal.(string); ok {
|
||||
username = userStr
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return "", nil, fmt.Errorf("JWT token missing required claim: %s", userIdentifierField)
|
||||
}
|
||||
|
||||
// Convert claims to map for additional processing
|
||||
claimsMap := make(map[string]interface{})
|
||||
for k, v := range claims {
|
||||
claimsMap[k] = v
|
||||
}
|
||||
|
||||
logger.Debugf("Successfully verified JWT token for user: %s", username)
|
||||
return username, claimsMap, nil
|
||||
}
|
||||
|
||||
// ExtractGroupsFromClaims extracts groups from JWT claims based on the configured groups claim field
|
||||
func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField string) []string {
|
||||
var groups []string
|
||||
|
||||
if groupsVal, ok := claims[groupsClaimField]; ok {
|
||||
switch v := groupsVal.(type) {
|
||||
case []interface{}:
|
||||
// Groups as array of interfaces
|
||||
for _, g := range v {
|
||||
if groupStr, ok := g.(string); ok {
|
||||
groups = append(groups, groupStr)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
// Groups as array of strings
|
||||
groups = v
|
||||
case string:
|
||||
// Single group as string
|
||||
groups = []string{v}
|
||||
default:
|
||||
// Try to unmarshal as JSON array
|
||||
if jsonBytes, err := json.Marshal(v); err == nil {
|
||||
var groupsArray []string
|
||||
if err := json.Unmarshal(jsonBytes, &groupsArray); err == nil {
|
||||
groups = groupsArray
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
)
|
||||
|
||||
func TestVerifyExternalJWT_ValidToken(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
username := "testuser"
|
||||
|
||||
// Create a valid JWT token
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"email": "test@example.com",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
extractedUsername, extractedClaims, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT() failed: %v", err)
|
||||
}
|
||||
|
||||
if extractedUsername != username {
|
||||
t.Errorf("Expected username %s, got %s", username, extractedUsername)
|
||||
}
|
||||
|
||||
if extractedClaims["email"] != "test@example.com" {
|
||||
t.Errorf("Expected email test@example.com, got %v", extractedClaims["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ExpiredToken(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
|
||||
// Create an expired JWT token
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "testuser",
|
||||
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
|
||||
"iat": time.Now().Add(-2 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token - should fail
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyExternalJWT() should fail for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_InvalidSignature(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
wrongSecret := "wrong-secret-key"
|
||||
algorithm := "HS256"
|
||||
|
||||
// Create a token with one secret
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "testuser",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Try to verify with a different secret - should fail
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, wrongSecret, algorithm, "sub")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyExternalJWT() should fail for invalid signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_MissingUsernameClaim(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
|
||||
// Create a token without the 'sub' claim
|
||||
claims := jwt.MapClaims{
|
||||
"email": "test@example.com",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token - should fail due to missing 'sub' claim
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyExternalJWT() should fail for missing username claim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
username := "testuser"
|
||||
|
||||
// Create a token with custom username field
|
||||
claims := jwt.MapClaims{
|
||||
"username": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token with custom user identifier field
|
||||
extractedUsername, _, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "username")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT() failed: %v", err)
|
||||
}
|
||||
|
||||
if extractedUsername != username {
|
||||
t.Errorf("Expected username %s, got %s", username, extractedUsername)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_ArrayOfStrings(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []string{"admin", "users"},
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" || groups[1] != "users" {
|
||||
t.Errorf("Expected groups [admin, users], got %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_ArrayOfInterfaces(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []interface{}{"admin", "users"},
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" || groups[1] != "users" {
|
||||
t.Errorf("Expected groups [admin, users], got %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_SingleString(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": "admin",
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("Expected 1 group, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" {
|
||||
t.Errorf("Expected group admin, got %s", groups[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_MissingClaim(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"email": "test@example.com",
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 0 {
|
||||
t.Fatalf("Expected 0 groups, got %d", len(groups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_CustomClaimName(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"roles": []string{"admin", "editor"},
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "roles")
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" || groups[1] != "editor" {
|
||||
t.Errorf("Expected groups [admin, editor], got %v", groups)
|
||||
}
|
||||
}
|
||||
@@ -67,15 +67,17 @@ func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, err
|
||||
|
||||
secretText := key.Secret()
|
||||
nonce := ""
|
||||
secretToStore := secretText
|
||||
if settings.Config.Auth.TotpSecret != "" {
|
||||
// If an encryption key is provided, encrypt the secret.
|
||||
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
|
||||
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
|
||||
}
|
||||
}
|
||||
// set cache so verify can attempt to use it but not require it for user yet.
|
||||
TotpCache.Set(user.Username, secretText+"||"+nonce)
|
||||
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
|
||||
// Use the original base32 secret in the OTP URL, not the encrypted version
|
||||
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
|
||||
return url, nil
|
||||
}
|
||||
@@ -181,12 +183,13 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
Algorithm: TOTPAlgorithm,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("error during TOTP validation: %v", err)
|
||||
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
|
||||
}
|
||||
if !valid {
|
||||
logger.Warningf("Invalid TOTP code for user %s", user.Username)
|
||||
return fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
if totpSecret != "" {
|
||||
if totpSecret != "" || !user.OtpEnabled {
|
||||
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
|
||||
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
|
||||
user.OtpEnabled = true // Enable OTP for the user
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
|
||||
== Running benchmark ==
|
||||
/usr/local/go/bin/go
|
||||
? github.com/gtsteffaniak/filebrowser/backend [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.005s
|
||||
2025/11/07 17:48:07 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:07 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:07 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:07 [ERROR] Root scanner error: open /tmp/filebrowser_test3280924123/: no such file or directory
|
||||
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 0.006s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.002s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/cmd 0.014s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/common/settings
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
10 374600 ns/op 192030 B/op 3237 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.139s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.004s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/access 0.287s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/crud [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt 0.842s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.003s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/docs [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/events [no test files]
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/ffmpeg
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
BenchmarkGetOrientationFilter-8 10 63.00 ns/op 0 B/op 0 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/ffmpeg 0.006s
|
||||
2025/11/07 17:48:08 [DEBUG] Saving user [admin] changepass: true
|
||||
2025/11/07 17:48:08 [DEBUG] Saving user [non-admin] changepass: true
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/http 0.248s
|
||||
2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/indexing
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
BenchmarkFillIndex-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
10 3611835 ns/op 323676 B/op 1655 allocs/op
|
||||
2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
BenchmarkSearchAllIndexes-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
10 784497 ns/op 97700 B/op 1061 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/indexing 0.077s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.006s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.130s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
|
||||
@@ -17,9 +17,6 @@ func validateAccessRules() {
|
||||
}
|
||||
// Get all sources
|
||||
for sourcePath := range settings.Config.Server.SourceMap {
|
||||
// Clear cache before migration to ensure we get fresh data
|
||||
store.Access.ClearCacheForSource(sourcePath)
|
||||
|
||||
// Get all rules for this source
|
||||
rules, err := store.Access.GetAllRules(sourcePath)
|
||||
if err != nil {
|
||||
@@ -65,7 +62,6 @@ func validateAccessRules() {
|
||||
|
||||
// After migration, clear cache
|
||||
if migratedCount > 0 {
|
||||
store.Access.ClearCacheForSource(sourcePath)
|
||||
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
+164
-57
@@ -60,10 +60,17 @@ func runCLI() bool {
|
||||
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
|
||||
var user, dbConfig string
|
||||
var asAdmin bool
|
||||
var fsPath, indexPath, ruleCategory, value string
|
||||
var allow bool
|
||||
|
||||
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
|
||||
setCmd.BoolVar(&asAdmin, "a", false, "Create a user as admin user, used in combination with -u")
|
||||
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
|
||||
setCmd.StringVar(&fsPath, "f", "", "Real filesystem path (e.g. /mnt/storage) for rule command")
|
||||
setCmd.StringVar(&indexPath, "p", "", "Index path (e.g. /secret) for rule command")
|
||||
setCmd.StringVar(&ruleCategory, "r", "", "Rule category: 'user', 'group', or 'all' (for deny only)")
|
||||
setCmd.StringVar(&value, "v", "", "Value: username or groupname (not required if ruleCategory is 'all')")
|
||||
setCmd.BoolVar(&allow, "allow", false, "Allow access (default: false, which means deny)")
|
||||
|
||||
// Parse subcommand flags only if a subcommand is specified
|
||||
if len(os.Args) > 1 {
|
||||
@@ -72,72 +79,48 @@ func runCLI() bool {
|
||||
createConfig(configPath)
|
||||
return false
|
||||
case "set":
|
||||
err := setCmd.Parse(os.Args[2:])
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
userInfo := strings.Split(user, ",")
|
||||
if len(userInfo) < 2 {
|
||||
fmt.Printf("not enough info to create user: \"set -u username,password\", only provided %v\n", userInfo)
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
username := userInfo[0]
|
||||
password := userInfo[1]
|
||||
_ = getStore(dbConfig) // ignore bool check
|
||||
user, err := store.Users.Get(username)
|
||||
if err != nil {
|
||||
newUser := users.User{
|
||||
Username: username,
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
if source.Config.DefaultEnabled {
|
||||
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
|
||||
Name: source.Name,
|
||||
Scope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
}
|
||||
subcommand := os.Args[2]
|
||||
|
||||
// Create the user logic
|
||||
if asAdmin {
|
||||
logger.Infof("Creating user as admin: %s\n", username)
|
||||
} else {
|
||||
logger.Infof("Creating non-admin user: %s\n", username)
|
||||
}
|
||||
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
|
||||
newUser.Permissions.Admin = asAdmin
|
||||
err = storage.CreateUser(newUser, newUser.Permissions)
|
||||
// Handle old syntax: "set -u user,pass -c config.yaml"
|
||||
if subcommand == "-u" {
|
||||
// Parse flags starting from os.Args[2:] to include the -u flag
|
||||
err := setCmd.Parse(os.Args[2:])
|
||||
if err != nil {
|
||||
logger.Errorf("could not create user: %v", err)
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
|
||||
err = setUser(dbConfig, asAdmin)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
logger.Fatalf("user %s is not allowed to login with password authentication, cannot set password", username)
|
||||
}
|
||||
user.Password = password
|
||||
user.TOTPSecret = "" // reset TOTP secret if it exists
|
||||
user.TOTPNonce = "" // reset TOTP nonce if it exists
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
if asAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
// Ensure version is set for existing users being updated
|
||||
if user.Version == 0 {
|
||||
user.Version = 1
|
||||
}
|
||||
err = store.Users.Save(user, true, false)
|
||||
|
||||
// New pattern: subcommand-based (e.g., "set user" or "set rule")
|
||||
// Parse flags starting from os.Args[3:] to skip the subcommand
|
||||
err := setCmd.Parse(os.Args[3:])
|
||||
if err != nil {
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
switch subcommand {
|
||||
case "rule":
|
||||
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
default:
|
||||
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", subcommand)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("successfully updated user: %s\n", username)
|
||||
return false
|
||||
|
||||
case "version":
|
||||
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
|
||||
@@ -151,6 +134,130 @@ func runCLI() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
|
||||
// Validate required parameters
|
||||
if fsPath == "" {
|
||||
return fmt.Errorf("real filesystem path is required: use -f <fsPath>")
|
||||
}
|
||||
if indexPath == "" {
|
||||
return fmt.Errorf("index path is required: use -p <indexPath>")
|
||||
}
|
||||
if ruleCategory == "" {
|
||||
return fmt.Errorf("ruleCategory is required: use -r <user|group|all>")
|
||||
}
|
||||
if ruleCategory != "all" && value == "" {
|
||||
return fmt.Errorf("value is required when ruleCategory is 'user' or 'group': use -v <username|groupname>")
|
||||
}
|
||||
|
||||
// Initialize store and settings
|
||||
_ = getStore(dbConfig) // ignore bool check
|
||||
|
||||
// Apply the rule based on allow flag and ruleCategory
|
||||
var err error
|
||||
if allow {
|
||||
switch ruleCategory {
|
||||
case "user":
|
||||
err = store.Access.AllowUser(fsPath, indexPath, value)
|
||||
case "group":
|
||||
err = store.Access.AllowGroup(fsPath, indexPath, value)
|
||||
default:
|
||||
return fmt.Errorf("invalid ruleCategory for allow: must be 'user' or 'group'")
|
||||
}
|
||||
} else {
|
||||
switch ruleCategory {
|
||||
case "user":
|
||||
err = store.Access.DenyUser(fsPath, indexPath, value)
|
||||
case "group":
|
||||
err = store.Access.DenyGroup(fsPath, indexPath, value)
|
||||
case "all":
|
||||
err = store.Access.DenyAll(fsPath, indexPath)
|
||||
default:
|
||||
return fmt.Errorf("invalid ruleCategory: must be 'user', 'group', or 'all'")
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add or update rule: %w", err)
|
||||
}
|
||||
|
||||
action := "deny"
|
||||
if allow {
|
||||
action = "allow"
|
||||
}
|
||||
if ruleCategory == "all" {
|
||||
fmt.Printf("successfully added %s rule for all users on index path '%s' in filesystem path '%s'\n", action, indexPath, fsPath)
|
||||
} else {
|
||||
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, fsPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setUser(dbConfig string, asAdmin bool) error {
|
||||
if len(os.Args) < 4 {
|
||||
return fmt.Errorf("missing username and password for 'set user'. Use 'set -u username,password'")
|
||||
}
|
||||
userInfo := strings.Split(os.Args[3], ",")
|
||||
if len(userInfo) < 2 {
|
||||
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", userInfo)
|
||||
}
|
||||
username := userInfo[0]
|
||||
password := userInfo[1]
|
||||
_ = getStore(dbConfig) // ignore bool check
|
||||
user, err := store.Users.Get(username)
|
||||
if err != nil {
|
||||
newUser := users.User{
|
||||
Username: username,
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
if source.Config.DefaultEnabled {
|
||||
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
|
||||
Name: source.Name,
|
||||
Scope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create the user logic
|
||||
if asAdmin {
|
||||
logger.Infof("Creating user as admin: %s\n", username)
|
||||
} else {
|
||||
logger.Infof("Creating non-admin user: %s\n", username)
|
||||
}
|
||||
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
|
||||
newUser.Permissions.Admin = asAdmin
|
||||
err = storage.CreateUser(newUser, newUser.Permissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create user: %v", err)
|
||||
}
|
||||
fmt.Printf("successfully created user")
|
||||
return nil
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
|
||||
}
|
||||
user.Password = password
|
||||
user.TOTPSecret = "" // reset TOTP secret if it exists
|
||||
user.TOTPNonce = "" // reset TOTP nonce if it exists
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
if asAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
// Ensure version is set for existing users being updated
|
||||
if user.Version == 0 {
|
||||
user.Version = 1
|
||||
}
|
||||
err = store.Users.Save(user, true, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not update user: %v", err)
|
||||
}
|
||||
fmt.Printf("successfully updated user: %s\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDefaults defines default settings for new users.
|
||||
type UserDefaults struct {
|
||||
Permissions users.Permissions `yaml:"permissions"`
|
||||
|
||||
+70
-7
@@ -3,9 +3,11 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "net/http/pprof"
|
||||
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/icons"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
|
||||
@@ -96,7 +99,15 @@ func StartFilebrowser() {
|
||||
logger.Info(database)
|
||||
logger.Infof("Sources : %v", sourceList)
|
||||
logger.Debugf("Using Embedded FS : %v", settings.Env.EmbeddedFs)
|
||||
|
||||
walModeStr := "OFF"
|
||||
if settings.Config.Server.IndexSqlConfig.WalMode {
|
||||
walModeStr = "WAL"
|
||||
}
|
||||
logger.Infof("SQL Journal Mode : %v", walModeStr)
|
||||
if settings.Config.Server.CacheDirCleanup {
|
||||
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
serverConfig := settings.Config.Server
|
||||
swagInfo := docs.SwaggerInfo
|
||||
swagInfo.BasePath = serverConfig.BaseURL
|
||||
@@ -105,8 +116,25 @@ func StartFilebrowser() {
|
||||
if len(settings.Config.Server.SourceMap) == 0 {
|
||||
logger.Fatal("No sources configured, exiting...")
|
||||
}
|
||||
|
||||
// Initialize shared index database before starting HTTP service
|
||||
isNewDb, err := indexing.InitializeIndexDB()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to initialize index database: %v", err)
|
||||
}
|
||||
|
||||
// Set indexing storage for persistence
|
||||
if store != nil && store.Indexing != nil {
|
||||
indexing.SetIndexingStorage(store.Indexing)
|
||||
if isNewDb {
|
||||
if err := store.Indexing.ResetAllComplexities(); err != nil {
|
||||
logger.Errorf("Failed to reset index complexities: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
go indexing.Initialize(source, false)
|
||||
go indexing.Initialize(source, false, isNewDb)
|
||||
}
|
||||
validateUserInfo(!dbExists)
|
||||
validateOfficeIntegration()
|
||||
@@ -127,12 +155,23 @@ func StartFilebrowser() {
|
||||
case <-done:
|
||||
logger.Info("Server stopped unexpectedly. Shutting down...")
|
||||
}
|
||||
if !*settings.Config.Server.CacheDirCleanup {
|
||||
|
||||
// Stop all indexing scanners before closing the database
|
||||
indexing.StopAllScanners()
|
||||
|
||||
// Give scanners a moment to finish their current scan operations
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// cleanup temp databases
|
||||
indexDB := indexing.GetIndexDB()
|
||||
if indexDB != nil {
|
||||
indexDB.Close()
|
||||
}
|
||||
if settings.Config.Server.CacheDirCleanup {
|
||||
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
|
||||
<-shutdownComplete // Ensure we don't exit prematurely
|
||||
// Wait for the server to stop
|
||||
<-shutdownComplete
|
||||
logger.Info("Shutdown complete.")
|
||||
}
|
||||
|
||||
@@ -144,11 +183,35 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
numWorkers := settings.Config.Server.NumImageProcessors
|
||||
ffmpeg.SetFFmpegPaths()
|
||||
|
||||
// setup disk cache
|
||||
// Initialize asset filesystem before starting services
|
||||
if settings.Env.EmbeddedFs {
|
||||
embeddedAssets := fbhttp.GetEmbeddedAssets()
|
||||
subAssets, err := fs.Sub(embeddedAssets, "embed")
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to create sub filesystem: %v", err)
|
||||
}
|
||||
fileutils.InitAssetFS(subAssets, true)
|
||||
} else {
|
||||
fileutils.InitAssetFS(nil, false)
|
||||
}
|
||||
|
||||
// Start preview service
|
||||
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
|
||||
if err != nil {
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
|
||||
logger.Debugf("Exiftool Enabled : %v", settings.Config.Integrations.Media.ExiftoolPath != "")
|
||||
|
||||
// Generate PWA icons after preview service is initialized
|
||||
if err := icons.GeneratePWAIcons(); err != nil {
|
||||
logger.Warningf("Failed to generate PWA icons: %v", err)
|
||||
}
|
||||
|
||||
// Initialize PWA manifest after icons are generated
|
||||
icons.InitializePWAManifest()
|
||||
|
||||
fbhttp.StartHttp(ctx, store, shutdownComplete)
|
||||
return nil
|
||||
}
|
||||
+81
-2
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
@@ -37,6 +38,12 @@ func validateUserInfo(newDB bool) {
|
||||
if updateShowFirstLogin(user) {
|
||||
updateUser = true
|
||||
}
|
||||
if updateSidebarLinks(user) {
|
||||
updateUser = true
|
||||
}
|
||||
if updateTokens(user) {
|
||||
updateUser = true
|
||||
}
|
||||
adminUser := settings.Config.Auth.AdminUsername
|
||||
adminPass := settings.Config.Auth.AdminPassword
|
||||
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
|
||||
@@ -116,7 +123,7 @@ func updateShowFirstLogin(user *users.User) bool {
|
||||
|
||||
// func to convert legacy user with perm key to permissions
|
||||
func updatePermissions(user *users.User) bool {
|
||||
if user.Version == 1 {
|
||||
if user.Version >= 1 {
|
||||
return false
|
||||
}
|
||||
updateUser := true
|
||||
@@ -162,7 +169,7 @@ func updatePermissions(user *users.User) bool {
|
||||
user.Permissions.Delete = true
|
||||
updateUser = true
|
||||
}
|
||||
user.Version = 1
|
||||
user.Version = 2
|
||||
if updateUser {
|
||||
createBackup = true
|
||||
}
|
||||
@@ -186,3 +193,75 @@ func updatePreviewSettings(user *users.User) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// updateSidebarLinks checks if user has stale source links and rebuilds them if needed
|
||||
func updateSidebarLinks(user *users.User) bool {
|
||||
// Count source links and check if any are still valid
|
||||
sourceLinksCount := 0
|
||||
validSourceLinksCount := 0
|
||||
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
sourceLinksCount++
|
||||
// Check if this source still exists
|
||||
if link.SourceName != "" {
|
||||
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
|
||||
validSourceLinksCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If user has no source links, don't update anything
|
||||
if sourceLinksCount == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// If user has source links but NONE are valid, rebuild from their scopes
|
||||
if validSourceLinksCount == 0 {
|
||||
logger.Infof("User %s has %d stale source links, rebuilding from scopes", user.Username, sourceLinksCount)
|
||||
|
||||
// Remove all existing source links
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if !strings.HasPrefix(link.Category, "source") {
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new source links based on user's scopes
|
||||
for _, scope := range user.Scopes {
|
||||
if source, ok := settings.Config.Server.SourceMap[scope.Name]; ok {
|
||||
// User has access to this source, add it to sidebar
|
||||
newLinks = append(newLinks, users.SidebarLink{
|
||||
Name: source.Name,
|
||||
Category: "source",
|
||||
Target: "/",
|
||||
Icon: "",
|
||||
SourceName: source.Path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
user.SidebarLinks = newLinks
|
||||
return true
|
||||
}
|
||||
|
||||
// User has at least one valid source link, no update needed
|
||||
return false
|
||||
}
|
||||
|
||||
func updateTokens(user *users.User) bool {
|
||||
if user.Version >= 2 {
|
||||
return false
|
||||
}
|
||||
if user.ApiKeys != nil {
|
||||
user.Tokens = make(map[string]users.AuthToken)
|
||||
for name, token := range user.ApiKeys {
|
||||
token.Token = token.Key
|
||||
user.Tokens[name] = token
|
||||
}
|
||||
}
|
||||
user.Version = 2
|
||||
return true
|
||||
}
|
||||
+180
-17
@@ -6,8 +6,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
ldap "github.com/go-ldap/ldap/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -29,6 +32,18 @@ func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
return t.base.RoundTrip(reqCopy)
|
||||
}
|
||||
|
||||
// AuthCommon contains fields shared across multiple authentication methods
|
||||
type AuthCommon struct {
|
||||
Enabled bool `json:"enabled"` // whether to enable this authentication method.
|
||||
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges
|
||||
UserGroups []string `json:"userGroups"` // if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
|
||||
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
|
||||
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
|
||||
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification (insecure, for testing only)
|
||||
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
CreateUser bool `json:"createUser"` // deprecated: always true for supported authentication methods
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
|
||||
Methods LoginMethods `json:"methods"`
|
||||
@@ -44,6 +59,8 @@ type LoginMethods struct {
|
||||
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
|
||||
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
|
||||
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
|
||||
LdapAuth LdapConfig `json:"ldap" validate:"omitempty"`
|
||||
JwtAuth JwtAuthConfig `json:"jwt" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type PasswordAuthConfig struct {
|
||||
@@ -55,10 +72,8 @@ type PasswordAuthConfig struct {
|
||||
}
|
||||
|
||||
type ProxyAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CreateUser bool `json:"createUser"` // create user if not exists
|
||||
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
|
||||
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
AuthCommon `json:",inline"`
|
||||
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
|
||||
}
|
||||
|
||||
type Recaptcha struct {
|
||||
@@ -69,19 +84,65 @@ type Recaptcha struct {
|
||||
|
||||
// OpenID OAuth2.0
|
||||
type OidcConfig struct {
|
||||
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
|
||||
ClientID string `json:"clientId"` // secret: client id of the OIDC application
|
||||
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
|
||||
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
|
||||
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
|
||||
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
|
||||
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
|
||||
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
CreateUser bool `json:"createUser"` // create user if not exists
|
||||
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges.
|
||||
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
|
||||
Provider *oidc.Provider `json:"-"` // OIDC provider
|
||||
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
|
||||
AuthCommon `json:",inline"`
|
||||
ClientID string `json:"clientId"` // secret: client id of the OIDC application
|
||||
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
|
||||
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
|
||||
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
|
||||
Provider *oidc.Provider `json:"-"` // OIDC provider
|
||||
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
|
||||
}
|
||||
|
||||
type LdapConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Server string `json:"server"` // scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
|
||||
BaseDN string `json:"baseDN"` // LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
|
||||
UserDN string `json:"userDN"` // Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
|
||||
UserPassword string `json:"userPassword"` // Bind password for service account
|
||||
UserFilter string `json:"userFilter"` // Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
|
||||
Port int `json:"-"` // derived from server
|
||||
Scheme string `json:"-"` // derived from server
|
||||
Host string `json:"-"` // derived from server
|
||||
}
|
||||
|
||||
// JwtAuthConfig configures external JWT token authentication
|
||||
// Similar to Grafana's JWT auth: accepts external JWT tokens signed with a shared secret
|
||||
// The query parameter is hardcoded to "jwt" (e.g. ?jwt=<token>)
|
||||
type JwtAuthConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
|
||||
Secret string `json:"secret"` // secret: shared secret key for verifying JWT token signatures (required)
|
||||
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
|
||||
}
|
||||
|
||||
// ValidateLdapAuth checks LDAP config and sets defaults. Call when LDAP is enabled.
|
||||
func ValidateLdapAuth() error {
|
||||
ldapCfg := &Config.Auth.Methods.LdapAuth
|
||||
if ldapCfg.Server == "" {
|
||||
return fmt.Errorf("LDAP server is required when LDAP is enabled")
|
||||
}
|
||||
scheme, host, port, err := parseLdapServer(ldapCfg.Server)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP server is invalid: %w", err)
|
||||
}
|
||||
ldapCfg.Server = fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
ldapCfg.Scheme = scheme
|
||||
ldapCfg.Host = host
|
||||
ldapCfg.Port = port
|
||||
|
||||
if ldapCfg.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP baseDN is required when LDAP is enabled")
|
||||
}
|
||||
if ldapCfg.UserDN == "" {
|
||||
return fmt.Errorf("LDAP userDN (bind DN) is required when LDAP is enabled")
|
||||
}
|
||||
if ldapCfg.UserFilter == "" {
|
||||
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
|
||||
}
|
||||
if err := verifyLdapConnection(); err != nil {
|
||||
logger.Fatalf("LDAP connection check failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
|
||||
@@ -133,3 +194,105 @@ func validateOidcAuth() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLdapServer(server string) (scheme, host string, port int, err error) {
|
||||
parts := strings.Split(server, "://")
|
||||
if len(parts) != 2 {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP server: %s", server)
|
||||
}
|
||||
scheme = strings.ToLower(parts[0])
|
||||
if scheme != "ldap" && scheme != "ldaps" {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP scheme: %s", scheme)
|
||||
}
|
||||
hostParts := strings.Split(parts[1], ":")
|
||||
host = strings.TrimSuffix(hostParts[0], "/")
|
||||
if len(hostParts) > 1 && hostParts[1] != "" {
|
||||
port, err = strconv.Atoi(strings.TrimSuffix(hostParts[1], "/"))
|
||||
if err != nil || port == 0 {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP port: %s", hostParts[1])
|
||||
}
|
||||
} else {
|
||||
if scheme == "ldaps" {
|
||||
port = 636
|
||||
} else {
|
||||
port = 389
|
||||
}
|
||||
}
|
||||
return scheme, host, port, nil
|
||||
}
|
||||
|
||||
// VerifyLdapConnection tests that the LDAP server is reachable, can bind (if credentials set), and responds to a search.
|
||||
func verifyLdapConnection() error {
|
||||
if Config.Auth.Methods.LdapAuth.Server == "" {
|
||||
return fmt.Errorf("LDAP server is required when LDAP is enabled")
|
||||
}
|
||||
if Config.Auth.Methods.LdapAuth.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP baseDN is required for connection verification")
|
||||
}
|
||||
var opts []ldap.DialOpt
|
||||
scheme := Config.Auth.Methods.LdapAuth.Scheme
|
||||
host := Config.Auth.Methods.LdapAuth.Host
|
||||
port := Config.Auth.Methods.LdapAuth.Port
|
||||
if Config.Auth.Methods.LdapAuth.DisableVerifyTLS && scheme == "ldaps" {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
|
||||
}
|
||||
fullURL := fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
conn, err := ldap.DialURL(fullURL, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP connection failed (is the LDAP outpost running at %s? use port 389 for LDAP, 636 for LDAPS; this is not the HTTP port): %w", fullURL, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if Config.Auth.Methods.LdapAuth.UserDN != "" && Config.Auth.Methods.LdapAuth.UserPassword != "" {
|
||||
if err = conn.Bind(Config.Auth.Methods.LdapAuth.UserDN, Config.Auth.Methods.LdapAuth.UserPassword); err != nil {
|
||||
return fmt.Errorf("LDAP bind failed (check userDN and userPassword): %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Unbind() }()
|
||||
}
|
||||
// Dummy search to verify the server responds to LDAP search (same path that fails at login if misconfigured).
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
Config.Auth.Methods.LdapAuth.BaseDN,
|
||||
ldap.ScopeBaseObject,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
"(objectClass=*)",
|
||||
[]string{"1.1"}, // no attributes
|
||||
nil,
|
||||
)
|
||||
_, err = conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP search test failed (server may require StartTLS or bind before search): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJwtAuth checks JWT config and sets defaults. Call when JWT auth is enabled.
|
||||
func ValidateJwtAuth() error {
|
||||
jwtCfg := &Config.Auth.Methods.JwtAuth
|
||||
if jwtCfg.Secret == "" {
|
||||
return fmt.Errorf("JWT secret is required when JWT auth is enabled")
|
||||
}
|
||||
if jwtCfg.Header == "" {
|
||||
jwtCfg.Header = "X-JWT-Assertion"
|
||||
}
|
||||
if jwtCfg.Algorithm == "" {
|
||||
jwtCfg.Algorithm = "HS256"
|
||||
}
|
||||
if jwtCfg.GroupsClaim == "" {
|
||||
jwtCfg.GroupsClaim = "groups"
|
||||
}
|
||||
if jwtCfg.UserIdentifier == "" {
|
||||
jwtCfg.UserIdentifier = "sub"
|
||||
}
|
||||
// Validate algorithm
|
||||
validAlgos := map[string]bool{
|
||||
"HS256": true, "HS384": true, "HS512": true,
|
||||
"RS256": true, "RS384": true, "RS512": true,
|
||||
"ES256": true, "ES384": true, "ES512": true,
|
||||
}
|
||||
if !validAlgos[jwtCfg.Algorithm] {
|
||||
return fmt.Errorf("unsupported JWT algorithm: %s. Supported: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512", jwtCfg.Algorithm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLdapServer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
server string
|
||||
wantScheme string
|
||||
wantHost string
|
||||
wantPort int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ldap with host and port",
|
||||
server: "ldap://localhost:389",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "localhost",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldaps with host and port",
|
||||
server: "ldaps://ldap.example.com:636",
|
||||
wantScheme: "ldaps",
|
||||
wantHost: "ldap.example.com",
|
||||
wantPort: 636,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldap with trailing slash",
|
||||
server: "ldap://host:389/",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "host",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldap port not provided defaults to 389",
|
||||
server: "ldap://localhost",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "localhost",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldaps port not provided defaults to 636",
|
||||
server: "ldaps://ldap.example.com",
|
||||
wantScheme: "ldaps",
|
||||
wantHost: "ldap.example.com",
|
||||
wantPort: 636,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "port provided but invalid returns error",
|
||||
server: "ldap://host:bad",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "port provided but invalid (ldaps) returns error",
|
||||
server: "ldaps://host:xyz",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "port zero returns error",
|
||||
server: "ldap://host:0",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty server",
|
||||
server: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no scheme",
|
||||
server: "localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme",
|
||||
server: "http://localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme ftp",
|
||||
server: "ftp://localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotScheme, gotHost, gotPort, err := parseLdapServer(tt.server)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseLdapServer() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr {
|
||||
if gotScheme != tt.wantScheme {
|
||||
t.Errorf("parseLdapServer() scheme = %v, want %v", gotScheme, tt.wantScheme)
|
||||
}
|
||||
if gotHost != tt.wantHost {
|
||||
t.Errorf("parseLdapServer() host = %v, want %v", gotHost, tt.wantHost)
|
||||
}
|
||||
if gotPort != tt.wantPort {
|
||||
t.Errorf("parseLdapServer() port = %v, want %v", gotPort, tt.wantPort)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLdapConnection_ValidationOnly(t *testing.T) {
|
||||
// Save and restore global config so we don't affect other tests
|
||||
saved := Config.Auth.Methods.LdapAuth
|
||||
defer func() { Config.Auth.Methods.LdapAuth = saved }()
|
||||
|
||||
t.Run("empty server returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
}
|
||||
err := verifyLdapConnection()
|
||||
if err == nil {
|
||||
t.Fatal("verifyLdapConnection() expected error for empty server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "LDAP server is required") {
|
||||
t.Errorf("verifyLdapConnection() error = %v, want message containing 'LDAP server is required'", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty baseDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
Scheme: "ldap",
|
||||
Host: "localhost",
|
||||
Port: 389,
|
||||
BaseDN: "",
|
||||
}
|
||||
err := verifyLdapConnection()
|
||||
if err == nil {
|
||||
t.Fatal("verifyLdapConnection() expected error for empty baseDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "baseDN is required") {
|
||||
t.Errorf("verifyLdapConnection() error = %v, want message containing 'baseDN is required'", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateLdapAuth_ConfigValidation(t *testing.T) {
|
||||
saved := Config.Auth.Methods.LdapAuth
|
||||
defer func() { Config.Auth.Methods.LdapAuth = saved }()
|
||||
|
||||
t.Run("empty server returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "server is required") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid server URL returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "not-a-valid-ldap-url",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for invalid server URL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty baseDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
BaseDN: "",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty baseDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "baseDN is required") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty userDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty userDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "userDN") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
+306
-101
@@ -3,8 +3,10 @@ package settings
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
@@ -51,15 +53,21 @@ func Initialize(configFile string) {
|
||||
setupServer()
|
||||
setupAuth(false)
|
||||
setupSources(false)
|
||||
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
|
||||
setupUrls()
|
||||
setupFrontend(false)
|
||||
setupMedia()
|
||||
setupMedia(false)
|
||||
}
|
||||
|
||||
func setupServer() {
|
||||
if Config.Server.ListenAddress == "" {
|
||||
Config.Server.ListenAddress = "0.0.0.0"
|
||||
}
|
||||
// Check environment variable first (overrides config file)
|
||||
if os.Getenv("FILEBROWSER_SQL_WAL") == "true" {
|
||||
Config.Server.IndexSqlConfig.WalMode = true
|
||||
}
|
||||
// WalMode is false by default (OFF journaling)
|
||||
}
|
||||
|
||||
func setupEnv() {
|
||||
@@ -87,17 +95,132 @@ func setupFs() {
|
||||
}
|
||||
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
|
||||
|
||||
// try writing to cache dir
|
||||
absCacheDir, err := filepath.Abs(Config.Server.CacheDir)
|
||||
// Perform mandatory cache directory speed test
|
||||
testCacheDirSpeed()
|
||||
|
||||
logger.Infof("cache directory setup successfully: %v", Config.Server.CacheDir)
|
||||
|
||||
}
|
||||
|
||||
// testCacheDirSpeed performs a mandatory speed test on the cache directory by writing,
|
||||
// reading, and deleting a 10MB test file. Reports write and read performance in MB/s.
|
||||
func testCacheDirSpeed() {
|
||||
msgPrfx := "cacheDir"
|
||||
failSuffix := "Please review documentation to ensure a valid cache directory is configured https://filebrowserquantum.com/en/docs/configuration/server/#cachedir"
|
||||
const testFileSize = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
// Ensure cache directory exists
|
||||
err := os.MkdirAll(Config.Server.CacheDir, fileutils.PermDir)
|
||||
if err != nil {
|
||||
logger.Errorf("error getting absolute path for 'server.cacheDir: %v': %v", Config.Server.CacheDir, err)
|
||||
}
|
||||
cacheDir := filepath.Join(absCacheDir, "init_test")
|
||||
err = os.MkdirAll(cacheDir, fileutils.PermDir)
|
||||
if err != nil {
|
||||
logger.Errorf("Unable to write to 'server.cacheDir: %v', please ensure the cache directory is writable: %v", absCacheDir, err)
|
||||
logger.Fatalf("%s failed to create cache directory: %v\n%s", msgPrfx, err, failSuffix)
|
||||
}
|
||||
|
||||
testFileName := filepath.Join(Config.Server.CacheDir, "speed_test.tmp")
|
||||
|
||||
// Create test data (10MB of zeros)
|
||||
testData := make([]byte, testFileSize)
|
||||
|
||||
// Test write performance
|
||||
writeStart := time.Now()
|
||||
file, err := os.Create(testFileName)
|
||||
if err != nil {
|
||||
logger.Fatalf("%s failed to create test file: %v\n%s", msgPrfx, err, failSuffix)
|
||||
}
|
||||
|
||||
written, err := file.Write(testData)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
os.Remove(testFileName)
|
||||
logger.Fatalf("%s failed to write test file: %v\n%s", msgPrfx, err, failSuffix)
|
||||
}
|
||||
|
||||
err = file.Sync() // Ensure data is written to disk
|
||||
if err != nil {
|
||||
file.Close()
|
||||
os.Remove(testFileName)
|
||||
logger.Fatalf("%s failed to sync test file to disk: %v\n%s", msgPrfx, err, failSuffix)
|
||||
}
|
||||
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
os.Remove(testFileName)
|
||||
logger.Fatalf("%s failed to close test file: %v\n%s", msgPrfx, err, failSuffix)
|
||||
}
|
||||
|
||||
writeDuration := time.Since(writeStart)
|
||||
writeSpeedMBs := float64(written) / (1024 * 1024) / writeDuration.Seconds()
|
||||
|
||||
// Test read performance
|
||||
readStart := time.Now()
|
||||
file, err = os.Open(testFileName)
|
||||
if err != nil {
|
||||
os.Remove(testFileName)
|
||||
logger.Fatalf("%s failed to open test file for reading: %v\n%s", msgPrfx, err, failSuffix)
|
||||
}
|
||||
|
||||
readData := make([]byte, testFileSize)
|
||||
readBytes, err := io.ReadFull(file, readData)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
os.Remove(testFileName)
|
||||
logger.Fatalf("%s failed to read test file: %v\n%s", msgPrfx, err, failSuffix)
|
||||
}
|
||||
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
os.Remove(testFileName)
|
||||
logger.Fatalf("%s failed to close test file after reading: %v\n%s", msgPrfx, err, failSuffix)
|
||||
}
|
||||
|
||||
readDuration := time.Since(readStart)
|
||||
readSpeedMBs := float64(readBytes) / (1024 * 1024) / readDuration.Seconds()
|
||||
|
||||
// Verify data integrity
|
||||
if readBytes != written {
|
||||
os.Remove(testFileName)
|
||||
logger.Fatalf("%s data integrity check failed: wrote %d bytes but read %d bytes\n%s", msgPrfx, written, readBytes, failSuffix)
|
||||
}
|
||||
|
||||
// Clean up test file
|
||||
err = os.Remove(testFileName)
|
||||
if err != nil {
|
||||
logger.Fatalf("%s failed to remove test file: %v\n%s", msgPrfx, err, failSuffix)
|
||||
}
|
||||
|
||||
// Log performance results
|
||||
writeDurationMs := writeDuration.Seconds() * 1000
|
||||
readDurationMs := readDuration.Seconds() * 1000
|
||||
const highLatencyThresholdMs = 1000.0 // 1 second for 10MB file indicates high latency
|
||||
|
||||
if writeSpeedMBs < 50 {
|
||||
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
|
||||
logger.Warningf("%s slow write speed detected: %.2f MB/s (%.2f ms)\n%s\n%s", msgPrfx, writeSpeedMBs, writeDurationMs, failSuffix, slowSuffix)
|
||||
} else if writeDurationMs > highLatencyThresholdMs {
|
||||
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
|
||||
logger.Warningf("%s high write latency detected: %.2f ms for 10MB file (speed: %.2f MB/s). This may indicate network storage or I/O issues.\n%s\n%s", msgPrfx, writeDurationMs, writeSpeedMBs, failSuffix, slowSuffix)
|
||||
} else {
|
||||
logger.Debugf("%s write speed: %.2f MB/s (%.2f ms)", msgPrfx, writeSpeedMBs, writeDurationMs)
|
||||
}
|
||||
if readSpeedMBs < 50 {
|
||||
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
|
||||
logger.Warningf("%s slow read speed detected: %.2f MB/s (%.2f ms)\n%s\n%s", msgPrfx, readSpeedMBs, readDurationMs, failSuffix, slowSuffix)
|
||||
} else if readDurationMs > highLatencyThresholdMs {
|
||||
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
|
||||
logger.Warningf("%s high read latency detected: %.2f ms for 10MB file (speed: %.2f MB/s). This may indicate network storage or I/O issues.\n%s\n%s", msgPrfx, readDurationMs, readSpeedMBs, failSuffix, slowSuffix)
|
||||
} else {
|
||||
logger.Debugf("%s read speed : %.2f MB/s (%.2f ms)", msgPrfx, readSpeedMBs, readDurationMs)
|
||||
}
|
||||
// check cache directory disk free space
|
||||
freeSpace, err := fileutils.GetFreeSpace(Config.Server.CacheDir)
|
||||
if err != nil {
|
||||
logger.Fatalf("%s failed to get free space for cache directory: %v\n%s", msgPrfx, err, failSuffix)
|
||||
}
|
||||
freeSpaceGB := float64(freeSpace) / (1024 * 1024 * 1024)
|
||||
logger.Debugf("%s cache directory has %.2f GB of free space", msgPrfx, freeSpaceGB)
|
||||
const minRecommendedGB = 20.0
|
||||
if freeSpaceGB < minRecommendedGB {
|
||||
logger.Warningf("%s only has %.2f GB of free space, this is less than the %.0f GB minimum recommended free space.\n%s", msgPrfx, freeSpaceGB, minRecommendedGB, failSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
func setupFrontend(generate bool) {
|
||||
@@ -158,42 +281,53 @@ func setupFrontend(generate bool) {
|
||||
loadCustomFavicon()
|
||||
}
|
||||
|
||||
func setupMedia() {
|
||||
// If VideoPreview is not initialized, initialize with all types enabled
|
||||
if Config.Integrations.Media.Convert.VideoPreview == nil {
|
||||
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
Config.Integrations.Media.Convert.VideoPreview[t] = true
|
||||
}
|
||||
return
|
||||
func setupMedia(generate bool) {
|
||||
// Save user's explicit config before applying defaults
|
||||
userImageConfig := make(map[ImagePreviewType]*bool)
|
||||
for k, v := range Config.Integrations.Media.Convert.ImagePreview {
|
||||
userImageConfig[k] = v
|
||||
}
|
||||
|
||||
// If VideoPreview map is empty, it means user didn't configure any video preview settings
|
||||
// In this case, enable all by default
|
||||
if len(Config.Integrations.Media.Convert.VideoPreview) == 0 {
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
Config.Integrations.Media.Convert.VideoPreview[t] = true
|
||||
}
|
||||
return
|
||||
// Re-initialize with defaults (gets wiped out during YAML unmarshal)
|
||||
Config.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
|
||||
Config.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
|
||||
Config.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
|
||||
|
||||
// Apply user overrides (only for keys explicitly set in YAML)
|
||||
for k, v := range userImageConfig {
|
||||
Config.Integrations.Media.Convert.ImagePreview[k] = v
|
||||
}
|
||||
|
||||
// User has explicitly configured some video preview settings
|
||||
// Start with all enabled, then apply user overrides
|
||||
userConfig := make(map[VideoPreviewType]bool)
|
||||
// VideoPreview: Merge user config with defaults
|
||||
// Save user's explicit config from YAML
|
||||
userVideoConfig := make(map[VideoPreviewType]*bool)
|
||||
for k, v := range Config.Integrations.Media.Convert.VideoPreview {
|
||||
userConfig[k] = v
|
||||
userVideoConfig[k] = v
|
||||
}
|
||||
|
||||
// Reset to defaults (all enabled)
|
||||
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
|
||||
// Re-initialize with defaults (all enabled)
|
||||
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
Config.Integrations.Media.Convert.VideoPreview[t] = true
|
||||
Config.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
|
||||
}
|
||||
|
||||
// Apply user overrides (only for explicitly set values)
|
||||
for k, v := range userConfig {
|
||||
// Apply user overrides (only for keys explicitly set in YAML)
|
||||
for k, v := range userVideoConfig {
|
||||
Config.Integrations.Media.Convert.VideoPreview[k] = v
|
||||
}
|
||||
|
||||
// Resolve exiftool path once at startup: validate user path or discover via PATH
|
||||
if Config.Integrations.Media.ExiftoolPath != "" && !generate {
|
||||
if err := exec.Command(Config.Integrations.Media.ExiftoolPath, "-ver").Run(); err != nil {
|
||||
logger.Warningf("exiftool path is invalid or not executable: %q (%v); disabling exiftool", Config.Integrations.Media.ExiftoolPath, err)
|
||||
Config.Integrations.Media.ExiftoolPath = ""
|
||||
}
|
||||
}
|
||||
if Config.Integrations.Media.ExiftoolPath == "" && !generate {
|
||||
if path, err := exec.LookPath("exiftool"); err == nil && path != "" {
|
||||
Config.Integrations.Media.ExiftoolPath = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupSources(generate bool) {
|
||||
@@ -216,11 +350,7 @@ func setupSources(generate bool) {
|
||||
if name == "\\" {
|
||||
name = strings.Split(realPath, ":")[0]
|
||||
}
|
||||
if generate {
|
||||
source.Path = generatorPath // use placeholder path
|
||||
} else {
|
||||
source.Path = realPath // use absolute path
|
||||
}
|
||||
source.Path = realPath // use absolute path
|
||||
if source.Name == "" {
|
||||
_, ok := Config.Server.SourceMap[source.Path]
|
||||
if ok {
|
||||
@@ -229,6 +359,10 @@ func setupSources(generate bool) {
|
||||
source.Name = name
|
||||
}
|
||||
}
|
||||
if generate {
|
||||
source.Path = generatorPath // use placeholder path
|
||||
source.Name = "Source Name"
|
||||
}
|
||||
modifyExcludeInclude(source)
|
||||
setConditionals(source)
|
||||
if source.Config.DefaultUserScope == "" {
|
||||
@@ -300,20 +434,34 @@ func setupAuth(generate bool) {
|
||||
if Config.Auth.Methods.ProxyAuth.Enabled {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
|
||||
}
|
||||
if Config.Auth.Methods.OidcAuth.Enabled {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
|
||||
}
|
||||
if Config.Auth.Methods.NoAuth {
|
||||
logger.Warning("Configured with no authentication, this is not recommended.")
|
||||
Config.Auth.AuthMethods = []string{"disabled"}
|
||||
}
|
||||
if Config.Auth.Methods.OidcAuth.Enabled || generate {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
|
||||
err := validateOidcAuth()
|
||||
if err != nil && !generate {
|
||||
logger.Fatalf("Error validating OIDC auth: %v", err)
|
||||
}
|
||||
logger.Info("OIDC Auth configured successfully")
|
||||
}
|
||||
if Config.Auth.Methods.LdapAuth.Enabled || generate {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "ldap")
|
||||
err := ValidateLdapAuth()
|
||||
if err != nil && !generate {
|
||||
logger.Fatalf("Error validating LDAP auth: %v", err)
|
||||
}
|
||||
logger.Info("LDAP Auth configured successfully")
|
||||
}
|
||||
if Config.Auth.Methods.JwtAuth.Enabled || generate {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "jwt")
|
||||
err := ValidateJwtAuth()
|
||||
if err != nil && !generate {
|
||||
logger.Fatalf("Error validating JWT auth: %v", err)
|
||||
}
|
||||
logger.Info("JWT Auth configured successfully")
|
||||
}
|
||||
|
||||
// use password auth as default if no auth methods are set
|
||||
if len(Config.Auth.AuthMethods) == 0 {
|
||||
@@ -502,6 +650,11 @@ func loadEnvConfig() {
|
||||
logger.Info("Using ReCaptcha Secret from FILEBROWSER_RECAPTCHA_SECRET environment variable")
|
||||
}
|
||||
|
||||
ldapUserPassword := os.Getenv("FILEBROWSER_LDAP_USER_PASSWORD")
|
||||
if ldapUserPassword != "" {
|
||||
Config.Auth.Methods.LdapAuth.UserPassword = ldapUserPassword
|
||||
logger.Info("Using LDAP bind password from FILEBROWSER_LDAP_USER_PASSWORD environment variable")
|
||||
}
|
||||
}
|
||||
|
||||
func setDefaults(generate bool) Settings {
|
||||
@@ -533,7 +686,12 @@ func setDefaults(generate bool) Settings {
|
||||
NameToSource: map[string]*Source{},
|
||||
MaxArchiveSizeGB: 50,
|
||||
CacheDir: "tmp",
|
||||
CacheDirCleanup: boolPtr(true),
|
||||
IndexSqlConfig: IndexSqlConfig{
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
},
|
||||
Filesystem: Filesystem{
|
||||
CreateFilePermission: "644",
|
||||
CreateDirectoryPermission: "755",
|
||||
@@ -553,7 +711,6 @@ func setDefaults(generate bool) Settings {
|
||||
Frontend: Frontend{
|
||||
Name: "FileBrowser Quantum",
|
||||
},
|
||||
|
||||
UserDefaults: UserDefaults{
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
@@ -573,7 +730,6 @@ func setDefaults(generate bool) Settings {
|
||||
Download: boolPtr(true), // defaults to true
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
HighQuality: boolPtr(true),
|
||||
Image: boolPtr(true),
|
||||
Video: boolPtr(true),
|
||||
MotionVideoPreview: boolPtr(true),
|
||||
@@ -583,27 +739,26 @@ func setDefaults(generate bool) Settings {
|
||||
Folder: boolPtr(true),
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
ChunkSize: 10, // 10MB
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
DownloadChunkSize: 0, // 0MB, default to no chunking
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initialize ImagePreview map with all supported types set to false by default
|
||||
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]bool)
|
||||
for _, t := range AllImagePreviewTypes {
|
||||
s.Integrations.Media.Convert.ImagePreview[t] = false
|
||||
}
|
||||
|
||||
// Initialize VideoPreview map with all supported types set to true by default
|
||||
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
|
||||
// Initialize ImagePreview map with defaults
|
||||
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
|
||||
s.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
|
||||
s.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
|
||||
// Initialize VideoPreview map with defaults (all enabled)
|
||||
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
s.Integrations.Media.Convert.VideoPreview[t] = true
|
||||
s.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// validateCustomImage validates a custom image file path and returns the absolute path or error
|
||||
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
|
||||
func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) {
|
||||
// Get absolute path
|
||||
absolutePath, err = filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
@@ -611,16 +766,11 @@ func validateCustomImage(configPath, imageName string, maxSize int64, allowedFor
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
stat, err := os.Stat(absolutePath)
|
||||
_, err = os.Stat(absolutePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not access file: %w", err)
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if stat.Size() > maxSize {
|
||||
return "", fmt.Errorf("file too large (%d bytes), maximum allowed is %d bytes", stat.Size(), maxSize)
|
||||
}
|
||||
|
||||
// Validate file format
|
||||
ext := strings.ToLower(filepath.Ext(absolutePath))
|
||||
validFormat := false
|
||||
@@ -639,11 +789,10 @@ func validateCustomImage(configPath, imageName string, maxSize int64, allowedFor
|
||||
|
||||
func loadCustomFavicon() {
|
||||
const imageName = "favicon"
|
||||
const maxSize = 1024 * 1024 // 1MB
|
||||
allowedFormats := []string{".ico", ".png", ".svg"}
|
||||
allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||
|
||||
// Set default embedded favicon path
|
||||
Env.FaviconEmbeddedPath = "img/icons/favicon.ico"
|
||||
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
|
||||
|
||||
// Check if a custom favicon path is configured
|
||||
if Config.Frontend.Favicon == "" {
|
||||
@@ -653,7 +802,7 @@ func loadCustomFavicon() {
|
||||
}
|
||||
|
||||
// Validate custom favicon
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats)
|
||||
if err != nil {
|
||||
logger.Warningf("Custom favicon validation failed: %v, using default", err)
|
||||
Config.Frontend.Favicon = ""
|
||||
@@ -671,16 +820,10 @@ func loadCustomFavicon() {
|
||||
|
||||
func loadLoginIcon() {
|
||||
const imageName = "login icon"
|
||||
const maxSize = 1024 * 1024 // 1MB
|
||||
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
|
||||
|
||||
// Set default embedded icon path based on dark mode preference
|
||||
isDarkMode := Config.UserDefaults.DarkMode != nil && *Config.UserDefaults.DarkMode
|
||||
if isDarkMode {
|
||||
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg" // Dark mode: dark background
|
||||
} else {
|
||||
Env.LoginIconEmbeddedPath = "img/icons/favicon-light.svg" // Light mode: light background
|
||||
}
|
||||
// Set default embedded icon path - just use favicon.svg (light/dark handled by CSS)
|
||||
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg"
|
||||
|
||||
// Check if a custom login icon path is configured
|
||||
if Config.Frontend.LoginIcon == "" {
|
||||
@@ -690,7 +833,7 @@ func loadLoginIcon() {
|
||||
}
|
||||
|
||||
// Validate custom login icon
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats)
|
||||
if err != nil {
|
||||
logger.Warningf("Custom login icon validation failed: %v, using default", err)
|
||||
Env.LoginIconPath = Env.LoginIconEmbeddedPath
|
||||
@@ -707,28 +850,70 @@ func loadLoginIcon() {
|
||||
// setConditionalsMap builds optimized map structures from conditional rules for O(1) lookups
|
||||
func setConditionals(config *Source) {
|
||||
|
||||
// Merge rules from both old format (Conditionals.ItemRules) and new format (Rules)
|
||||
rules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...)
|
||||
|
||||
// Initialize the maps structure (only exact match maps for Names)
|
||||
resolved := ResolvedRulesConfig{
|
||||
FileNames: make(map[string]ConditionalRule),
|
||||
FolderNames: make(map[string]ConditionalRule),
|
||||
FilePaths: make(map[string]ConditionalRule),
|
||||
FolderPaths: make(map[string]ConditionalRule),
|
||||
FileEndsWith: make([]ConditionalRule, 0),
|
||||
FolderEndsWith: make([]ConditionalRule, 0),
|
||||
FileStartsWith: make([]ConditionalRule, 0),
|
||||
FolderStartsWith: make([]ConditionalRule, 0),
|
||||
NeverWatchPaths: make(map[string]struct{}),
|
||||
IncludeRootItems: make(map[string]struct{}),
|
||||
IgnoreAllHidden: false,
|
||||
IgnoreAllZeroSizeFolders: false,
|
||||
IgnoreAllSymlinks: false,
|
||||
IndexingDisabled: false,
|
||||
}
|
||||
|
||||
// backwards compatibility
|
||||
if config.Config.Conditionals.Hidden {
|
||||
logger.Warning("conditionals.hidden is deprecated, use conditionals.ignoreHidden instead")
|
||||
config.Config.Conditionals.IgnoreHidden = true
|
||||
logger.Warning("source.conditionals.hidden is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllHidden = true
|
||||
}
|
||||
// Backwards compatibility: if old format fields are set, treat as global rules
|
||||
if config.Config.Conditionals.IgnoreHidden {
|
||||
logger.Warning("source.conditionals.ignoreHidden is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllHidden = true
|
||||
}
|
||||
if config.Config.DisableIndexing {
|
||||
logger.Warning("source.disableIndexing is deprecated, use source.rules instead")
|
||||
resolved.IndexingDisabled = true
|
||||
}
|
||||
|
||||
rules := config.Config.Conditionals.ItemRules
|
||||
// Initialize the maps structure (only exact match maps for Names)
|
||||
resolved := &ResolvedConditionalsConfig{
|
||||
FileNames: make(map[string]ConditionalIndexConfig),
|
||||
FolderNames: make(map[string]ConditionalIndexConfig),
|
||||
FilePaths: make(map[string]ConditionalIndexConfig),
|
||||
FolderPaths: make(map[string]ConditionalIndexConfig),
|
||||
FileEndsWith: make([]ConditionalIndexConfig, 0),
|
||||
FolderEndsWith: make([]ConditionalIndexConfig, 0),
|
||||
FileStartsWith: make([]ConditionalIndexConfig, 0),
|
||||
FolderStartsWith: make([]ConditionalIndexConfig, 0),
|
||||
NeverWatchPaths: make(map[string]struct{}),
|
||||
IncludeRootItems: make(map[string]struct{}),
|
||||
if config.Config.Conditionals.ZeroSizeFolders {
|
||||
logger.Warning("source.conditionals.zeroSizeFolders is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllZeroSizeFolders = true
|
||||
}
|
||||
|
||||
// Process all rules and infer global flags from root-level rules
|
||||
for _, rule := range rules {
|
||||
// Check if this is a root-level rule (folderPath == "/")
|
||||
// Root-level rules with ignoreHidden/ignoreZeroSizeFolders/viewable set global flags
|
||||
isRootLevelRule := rule.FolderPath == "/"
|
||||
|
||||
// Infer global flags from root-level rules
|
||||
if isRootLevelRule {
|
||||
if rule.IgnoreHidden {
|
||||
resolved.IgnoreAllHidden = true
|
||||
}
|
||||
if rule.IgnoreSymlinks {
|
||||
resolved.IgnoreAllSymlinks = true
|
||||
}
|
||||
if rule.IgnoreZeroSizeFolders {
|
||||
resolved.IgnoreAllZeroSizeFolders = true
|
||||
}
|
||||
if rule.Viewable {
|
||||
resolved.IndexingDisabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build optimized lookup structures
|
||||
if rule.FileEndsWith != "" {
|
||||
resolved.FileEndsWith = append(resolved.FileEndsWith, rule)
|
||||
}
|
||||
@@ -766,7 +951,7 @@ func setConditionals(config *Source) {
|
||||
resolved.FolderNames[rule.FolderName] = rule
|
||||
}
|
||||
}
|
||||
config.Config.ResolvedConditionals = resolved
|
||||
config.Config.ResolvedRules = resolved
|
||||
}
|
||||
|
||||
func modifyExcludeInclude(config *Source) {
|
||||
@@ -798,20 +983,40 @@ func modifyExcludeInclude(config *Source) {
|
||||
return strings.Trim(value, "/")
|
||||
}
|
||||
|
||||
// Normalize []ConditionalIndexConfig slices for full paths
|
||||
for i, rule := range config.Config.Conditionals.ItemRules {
|
||||
// Normalize []ConditionalRule slices for full paths
|
||||
// Handle both old format (Conditionals.ItemRules) and new format (Rules)
|
||||
allRules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...)
|
||||
|
||||
for i, rule := range allRules {
|
||||
// normalize full paths
|
||||
config.Config.Conditionals.ItemRules[i].FilePath = normalizeFullPath(rule.FilePath, true)
|
||||
config.Config.Conditionals.ItemRules[i].FolderPath = normalizeFullPath(rule.FolderPath, true)
|
||||
config.Config.Conditionals.ItemRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true)
|
||||
config.Config.Conditionals.ItemRules[i].IncludeRootItem = normalizeFullPath(rule.IncludeRootItem, true)
|
||||
allRules[i].FilePath = normalizeFullPath(rule.FilePath, true)
|
||||
// FolderPath gets trailing slash for proper prefix matching
|
||||
if rule.FolderPath != "" {
|
||||
normalized := normalizeFullPath(rule.FolderPath, true)
|
||||
if normalized != "/" && !strings.HasSuffix(normalized, "/") {
|
||||
normalized = normalized + "/"
|
||||
}
|
||||
allRules[i].FolderPath = normalized
|
||||
}
|
||||
allRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true)
|
||||
if rule.IncludeRootItem != "" {
|
||||
normalized := normalizeFullPath(rule.IncludeRootItem, true)
|
||||
if normalized != "/" && !strings.HasSuffix(normalized, "/") {
|
||||
normalized = normalized + "/"
|
||||
}
|
||||
allRules[i].IncludeRootItem = normalized
|
||||
}
|
||||
|
||||
// normalize names
|
||||
config.Config.Conditionals.ItemRules[i].FileNames = normalizeName(rule.FileNames)
|
||||
config.Config.Conditionals.ItemRules[i].FolderNames = normalizeName(rule.FolderNames)
|
||||
config.Config.Conditionals.ItemRules[i].FileName = normalizeName(rule.FileName)
|
||||
config.Config.Conditionals.ItemRules[i].FolderName = normalizeName(rule.FolderName)
|
||||
allRules[i].FileNames = normalizeName(rule.FileNames)
|
||||
allRules[i].FolderNames = normalizeName(rule.FolderNames)
|
||||
allRules[i].FileName = normalizeName(rule.FileName)
|
||||
allRules[i].FolderName = normalizeName(rule.FolderName)
|
||||
}
|
||||
|
||||
// Update the original slices with normalized values
|
||||
itemRulesLen := len(config.Config.Conditionals.ItemRules)
|
||||
config.Config.Conditionals.ItemRules = allRules[:itemRulesLen]
|
||||
config.Config.Rules = allRules[itemRulesLen:]
|
||||
|
||||
}
|
||||
@@ -1,11 +1,25 @@
|
||||
package settings
|
||||
|
||||
func CanConvertImage(ext string) bool {
|
||||
return MediaEnabled() && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func CanConvertVideo(ext string) bool {
|
||||
return MediaEnabled() && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func MediaEnabled() bool {
|
||||
|
||||
@@ -1,166 +1,60 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
|
||||
if len(scopes) == 0 {
|
||||
return Config.UserDefaults.DefaultScopes, nil
|
||||
}
|
||||
newScopes := []users.SourceScope{}
|
||||
for _, scope := range scopes {
|
||||
|
||||
// first check if its already a path name and keep it
|
||||
source, ok := Config.Server.SourceMap[scope.Name]
|
||||
if ok {
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.Config.DefaultUserScope
|
||||
}
|
||||
if !strings.HasPrefix(scope.Scope, "/") {
|
||||
scope.Scope = "/" + scope.Scope
|
||||
}
|
||||
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
|
||||
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
|
||||
}
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// check if its the name of a source and convert it to a path
|
||||
source, ok = Config.Server.NameToSource[scope.Name]
|
||||
// InitializeUserResolvers sets up the global resolvers and config providers in the users package
|
||||
// This should be called once during application initialization
|
||||
func InitializeUserResolvers() {
|
||||
// Set up the source name resolver
|
||||
users.SetSourceNameResolver(func(sourceName string) (string, error) {
|
||||
source, ok := Config.Server.NameToSource[sourceName]
|
||||
if !ok {
|
||||
// source might no longer be configured
|
||||
continue
|
||||
logger.Debug("Could not get scope from source name: ", sourceName)
|
||||
return "", nil
|
||||
}
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.Config.DefaultUserScope
|
||||
}
|
||||
if !strings.HasPrefix(scope.Scope, "/") {
|
||||
scope.Scope = "/" + scope.Scope
|
||||
}
|
||||
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
|
||||
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
|
||||
}
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
return newScopes, nil
|
||||
}
|
||||
return source.Path, nil
|
||||
})
|
||||
|
||||
func ConvertToFrontendScopes(scopes []users.SourceScope) []users.SourceScope {
|
||||
newScopes := []users.SourceScope{}
|
||||
for _, scope := range scopes {
|
||||
if source, ok := Config.Server.SourceMap[scope.Name]; ok {
|
||||
// Replace scope.Name with source.Path while keeping the same Scope value
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Name,
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
return newScopes
|
||||
}
|
||||
|
||||
func ConvertToFrontendSidebarLinks(links []users.SidebarLink) []users.SidebarLink {
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range links {
|
||||
// For source links, validate that the source still exists
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
logger.Warningf("source link missing sourceName: %v", link.Name)
|
||||
continue
|
||||
}
|
||||
// Check if source exists
|
||||
sourceInfo, ok := Config.Server.SourceMap[link.SourceName]
|
||||
// Set up the source config provider
|
||||
users.SetSourceConfig(&users.SourceConfigProvider{
|
||||
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
|
||||
source, ok := Config.Server.SourceMap[path]
|
||||
if !ok {
|
||||
logger.Warningf("source not found: %v (link name: %v)", link.SourceName, link.Name)
|
||||
continue
|
||||
return users.SourceInfo{}, false
|
||||
}
|
||||
link.SourceName = sourceInfo.Name
|
||||
}
|
||||
// For share links, just pass through (shares are validated separately)
|
||||
// For all other links, pass through as-is
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks
|
||||
}
|
||||
|
||||
func ConvertToBackendSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, error) {
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range links {
|
||||
// For source links, validate that the source exists using SourceName
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
|
||||
}
|
||||
// Validate source exists
|
||||
sourceInfo, ok := Config.Server.NameToSource[link.SourceName]
|
||||
return users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
}, true
|
||||
},
|
||||
GetSourceByName: func(name string) (users.SourceInfo, bool) {
|
||||
source, ok := Config.Server.NameToSource[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
|
||||
return users.SourceInfo{}, false
|
||||
}
|
||||
link.SourceName = sourceInfo.Path // if name chages keep link alive
|
||||
}
|
||||
// Store the link as-is with all fields preserved
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks, nil
|
||||
}
|
||||
|
||||
func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
|
||||
source, ok := Config.Server.NameToSource[sourceName]
|
||||
if !ok {
|
||||
logger.Debug("Could not get scope from source name: ", sourceName)
|
||||
return "", fmt.Errorf("source with name not found %v", sourceName)
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == source.Path {
|
||||
return scope.Scope, nil
|
||||
}
|
||||
}
|
||||
logger.Debugf("scope not found for source %v", sourceName)
|
||||
return "", fmt.Errorf("scope not found for source %v", sourceName)
|
||||
}
|
||||
|
||||
func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (string, error) {
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return scope.Scope, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("scope not found for source %v", sourcePath)
|
||||
}
|
||||
|
||||
// assumes backend style scopes
|
||||
func GetSources(u *users.User) []string {
|
||||
sources := []string{}
|
||||
|
||||
// preserves order of sources
|
||||
for _, source := range Config.Server.Sources {
|
||||
_, err := GetScopeFromSourcePath(u.Scopes, source.Path)
|
||||
if err != nil {
|
||||
logger.Warningf("could not get scope for source %v: %v", source.Path, err)
|
||||
continue
|
||||
}
|
||||
sources = append(sources, source.Name)
|
||||
}
|
||||
return sources
|
||||
return users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
}, true
|
||||
},
|
||||
GetAllSources: func() []users.SourceInfo {
|
||||
sources := make([]users.SourceInfo, 0, len(Config.Server.Sources))
|
||||
for _, source := range Config.Server.Sources {
|
||||
sources = append(sources, users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
return sources
|
||||
},
|
||||
GetDefaultScopes: func() []users.SourceScope {
|
||||
return Config.UserDefaults.DefaultScopes
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -111,74 +112,93 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
|
||||
return comments, secrets, deprecated, nil
|
||||
}
|
||||
|
||||
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
|
||||
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
|
||||
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var files []*ast.File
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedSyntax | packages.NeedFiles,
|
||||
Dir: absDir,
|
||||
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
|
||||
return parser.ParseFile(fset, filename, src, parser.ParseComments)
|
||||
},
|
||||
}
|
||||
pkgs, err := packages.Load(cfg, ".")
|
||||
if err == nil && len(pkgs) > 0 {
|
||||
for _, pkg := range pkgs {
|
||||
files = append(files, pkg.Syntax...)
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
// Fallback for directories outside a module (e.g. test temp dirs): parse .go files directly
|
||||
files, err = parseDirGoFiles(absDir)
|
||||
if err != nil || len(files) == 0 {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
comments := make(CommentsMap)
|
||||
secrets := make(SecretFieldsMap)
|
||||
deprecated := make(DeprecatedFieldsMap)
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Files {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
for _, file := range files {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
ts, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
ts, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
st, ok := ts.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeName := ts.Name.Name
|
||||
commentMap := make(map[string]string)
|
||||
secretMap := make(map[string]bool)
|
||||
deprecatedMap := make(map[string]bool)
|
||||
comments[typeName] = commentMap
|
||||
secrets[typeName] = secretMap
|
||||
deprecated[typeName] = deprecatedMap
|
||||
|
||||
for _, field := range st.Fields.List {
|
||||
if len(field.Names) == 0 {
|
||||
continue
|
||||
}
|
||||
st, ok := ts.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
name := field.Names[0].Name
|
||||
var parts []string
|
||||
var fullComment string
|
||||
|
||||
if field.Doc != nil {
|
||||
docText := strings.TrimSpace(field.Doc.Text())
|
||||
parts = append(parts, docText)
|
||||
fullComment += docText + " "
|
||||
}
|
||||
if field.Comment != nil {
|
||||
commentText := strings.TrimSpace(field.Comment.Text())
|
||||
parts = append(parts, commentText)
|
||||
fullComment += commentText
|
||||
}
|
||||
typeName := ts.Name.Name
|
||||
commentMap := make(map[string]string)
|
||||
secretMap := make(map[string]bool)
|
||||
deprecatedMap := make(map[string]bool)
|
||||
comments[typeName] = commentMap
|
||||
secrets[typeName] = secretMap
|
||||
deprecated[typeName] = deprecatedMap
|
||||
|
||||
for _, field := range st.Fields.List {
|
||||
if len(field.Names) == 0 {
|
||||
continue
|
||||
}
|
||||
name := field.Names[0].Name
|
||||
var parts []string
|
||||
var fullComment string
|
||||
if len(parts) > 0 {
|
||||
commentMap[name] = strings.Join(parts, " : ")
|
||||
}
|
||||
|
||||
if field.Doc != nil {
|
||||
docText := strings.TrimSpace(field.Doc.Text())
|
||||
parts = append(parts, docText)
|
||||
fullComment += docText + " "
|
||||
}
|
||||
if field.Comment != nil {
|
||||
commentText := strings.TrimSpace(field.Comment.Text())
|
||||
parts = append(parts, commentText)
|
||||
fullComment += commentText
|
||||
}
|
||||
// Check if field should be treated as secret
|
||||
if strings.Contains(strings.ToLower(fullComment), "secret:") {
|
||||
secretMap[name] = true
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
commentMap[name] = strings.Join(parts, " : ")
|
||||
}
|
||||
|
||||
// Check if field should be treated as secret
|
||||
if strings.Contains(strings.ToLower(fullComment), "secret:") {
|
||||
secretMap[name] = true
|
||||
}
|
||||
|
||||
// Check if field should be treated as deprecated
|
||||
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
|
||||
deprecatedMap[name] = true
|
||||
}
|
||||
// Check if field should be treated as deprecated
|
||||
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
|
||||
deprecatedMap[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +207,29 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
|
||||
return comments, secrets, deprecated, nil
|
||||
}
|
||||
|
||||
// parseDirGoFiles parses all .go files in dir and returns their ASTs. Used when the directory
|
||||
// is not inside a Go module (e.g. test temp dirs) so packages.Load would fail.
|
||||
func parseDirGoFiles(dir string) ([]*ast.File, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
var files []*ast.File
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// PathToTypeField represents a mapping from YAML path to Go type and field info
|
||||
type PathToTypeField struct {
|
||||
TypeName string
|
||||
@@ -518,6 +561,130 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
|
||||
|
||||
currentField := v.Field(i)
|
||||
|
||||
// Check if this is an inline/embedded field
|
||||
yamlTag := sf.Tag.Get("yaml")
|
||||
jsonTag := sf.Tag.Get("json")
|
||||
isInline := strings.Contains(yamlTag, ",inline") || strings.Contains(jsonTag, ",inline") || sf.Anonymous
|
||||
|
||||
// If field is inline, merge its content directly into parent
|
||||
if isInline && sf.Type.Kind() == reflect.Struct {
|
||||
// Get the embedded struct's fields and add them directly to this level
|
||||
embeddedType := sf.Type
|
||||
embeddedValue := currentField
|
||||
embeddedTypeName := embeddedType.Name()
|
||||
|
||||
for j := 0; j < embeddedType.NumField(); j++ {
|
||||
embeddedField := embeddedType.Field(j)
|
||||
|
||||
// Skip unexported fields
|
||||
if embeddedField.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
if embeddedYamlTag := embeddedField.Tag.Get("yaml"); embeddedYamlTag == "-" {
|
||||
continue
|
||||
}
|
||||
if embeddedJsonTag := embeddedField.Tag.Get("json"); embeddedJsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip deprecated fields
|
||||
if len(deprecated) > 0 && deprecated[embeddedTypeName][embeddedField.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
embeddedFieldValue := embeddedValue.Field(j)
|
||||
|
||||
// If we have defaults, compare and skip if values match
|
||||
if defaults.IsValid() && i < defaults.NumField() {
|
||||
defaultInlineField := defaults.Field(i)
|
||||
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
|
||||
defaultEmbeddedField := defaultInlineField.Field(j)
|
||||
embeddedCurrentValue := embeddedFieldValue.Interface()
|
||||
embeddedDefaultValue := defaultEmbeddedField.Interface()
|
||||
|
||||
if reflect.DeepEqual(embeddedCurrentValue, embeddedDefaultValue) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine key name for embedded field
|
||||
embeddedYamlTag := embeddedField.Tag.Get("yaml")
|
||||
embeddedJsonTag := embeddedField.Tag.Get("json")
|
||||
var embeddedKeyName string
|
||||
if embeddedYamlTag != "" && embeddedYamlTag != "-" {
|
||||
embeddedKeyName = strings.Split(embeddedYamlTag, ",")[0]
|
||||
} else if embeddedJsonTag != "" && embeddedJsonTag != "-" {
|
||||
embeddedKeyName = strings.Split(embeddedJsonTag, ",")[0]
|
||||
} else {
|
||||
embeddedKeyName = embeddedField.Name
|
||||
}
|
||||
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: embeddedKeyName}
|
||||
|
||||
// Attach comments for embedded field
|
||||
var parts []string
|
||||
var comment string
|
||||
if cm := comm[embeddedTypeName][embeddedField.Name]; cm != "" {
|
||||
comment = cm
|
||||
}
|
||||
|
||||
if comment != "" {
|
||||
parts = append(parts, comment)
|
||||
}
|
||||
if len(comm) > 0 {
|
||||
if vt := embeddedField.Tag.Get("validate"); vt != "" {
|
||||
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
keyNode.LineComment = strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// Build value node for embedded field
|
||||
var valNode *yaml.Node
|
||||
var err error
|
||||
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
|
||||
if secrets[embeddedTypeName][embeddedField.Name] && !generateConfig {
|
||||
fieldValue := embeddedFieldValue.Interface()
|
||||
if str, ok := fieldValue.(string); ok && str == "" {
|
||||
valNode = &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Tag: "!!str",
|
||||
Value: "",
|
||||
Style: yaml.DoubleQuotedStyle,
|
||||
}
|
||||
} else {
|
||||
valNode = &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Tag: "!!str",
|
||||
Value: "**hidden**",
|
||||
Style: yaml.DoubleQuotedStyle,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var defaultEmbeddedField reflect.Value
|
||||
if defaults.IsValid() && i < defaults.NumField() {
|
||||
defaultInlineField := defaults.Field(i)
|
||||
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
|
||||
defaultEmbeddedField = defaultInlineField.Field(j)
|
||||
}
|
||||
}
|
||||
|
||||
valNode, err = buildNodeWithDefaults(embeddedFieldValue, comm, defaultEmbeddedField, secrets, deprecated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
mapNode.Content = append(mapNode.Content, keyNode, valNode)
|
||||
}
|
||||
|
||||
// Skip adding the embedded struct itself as a field
|
||||
continue
|
||||
}
|
||||
|
||||
// If we have defaults, compare and skip if values match
|
||||
if defaults.IsValid() && i < defaults.NumField() {
|
||||
defaultField := defaults.Field(i)
|
||||
@@ -532,8 +699,6 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
|
||||
}
|
||||
|
||||
// determine key: yaml tag > json tag > field name
|
||||
yamlTag := sf.Tag.Get("yaml")
|
||||
jsonTag := sf.Tag.Get("json")
|
||||
var keyName string
|
||||
if yamlTag != "" && yamlTag != "-" {
|
||||
keyName = strings.Split(yamlTag, ",")[0]
|
||||
@@ -649,11 +814,6 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
|
||||
}
|
||||
mapNode := &yaml.Node{Kind: yaml.MappingNode}
|
||||
|
||||
// Special handling for ImagePreview maps - show all supported types
|
||||
if isImagePreviewMap(v) {
|
||||
return buildImagePreviewMap(v, comm, secrets, deprecated)
|
||||
}
|
||||
|
||||
// Get and sort map keys alphabetically for consistent output
|
||||
mapKeys := v.MapKeys()
|
||||
keyStrings := make([]string, len(mapKeys))
|
||||
@@ -707,36 +867,21 @@ func GenerateYaml() {
|
||||
{
|
||||
Path: ".",
|
||||
Config: SourceConfig{
|
||||
Conditionals: ConditionalFilter{
|
||||
ItemRules: []ConditionalIndexConfig{
|
||||
{}, // Add empty sample rule to show all fields in generated YAML
|
||||
},
|
||||
Rules: []ConditionalRule{
|
||||
{}, // Add empty sample rule to show all fields in generated YAML
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
setupLogging()
|
||||
setupFs()
|
||||
setupAuth(true)
|
||||
|
||||
// Save original paths before setupSources modifies them (for YAML generation)
|
||||
originalPaths := make(map[*Source]string)
|
||||
for _, source := range Config.Server.Sources {
|
||||
originalPaths[source] = source.Path
|
||||
}
|
||||
|
||||
setupSources(true)
|
||||
setupUrls()
|
||||
setupMedia()
|
||||
setupMedia(true)
|
||||
setupFrontend(true)
|
||||
|
||||
// Restore original paths so the YAML output has the correct paths, not the placeholder
|
||||
for _, source := range Config.Server.Sources {
|
||||
if originalPath, ok := originalPaths[source]; ok {
|
||||
source.Path = originalPath
|
||||
}
|
||||
}
|
||||
|
||||
output := "../frontend/public/config.generated.yaml" // "output YAML file"
|
||||
|
||||
// Generate YAML with comments enabled, full config, and deprecated fields filtered
|
||||
@@ -1136,53 +1281,3 @@ func AlignComments(input string) string {
|
||||
|
||||
return strings.Join(outputLines, "\n")
|
||||
}
|
||||
|
||||
// isImagePreviewMap checks if the given map is an ImagePreview map
|
||||
func isImagePreviewMap(v reflect.Value) bool {
|
||||
if v.Kind() != reflect.Map {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the map key type is ImagePreviewType
|
||||
keyType := v.Type().Key()
|
||||
return keyType.Name() == "ImagePreviewType"
|
||||
}
|
||||
|
||||
// buildImagePreviewMap builds a YAML node for ImagePreview maps, showing all supported types
|
||||
func buildImagePreviewMap(v reflect.Value, comm CommentsMap, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
|
||||
mapNode := &yaml.Node{Kind: yaml.MappingNode}
|
||||
|
||||
// Get all supported image preview types from the global slice
|
||||
for _, imageType := range AllImagePreviewTypes {
|
||||
typeName := string(imageType)
|
||||
|
||||
// Create key node
|
||||
keyNode := &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: typeName,
|
||||
}
|
||||
|
||||
// Get the value from the map, defaulting to false if not present
|
||||
var value bool
|
||||
if v.Len() > 0 {
|
||||
// Look for the key in the map
|
||||
for _, mapKey := range v.MapKeys() {
|
||||
if mapKey.String() == typeName {
|
||||
value = v.MapIndex(mapKey).Bool()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create value node
|
||||
valNode := &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Tag: "!!bool",
|
||||
Value: fmt.Sprintf("%t", value),
|
||||
}
|
||||
|
||||
mapNode.Content = append(mapNode.Content, keyNode, valNode)
|
||||
}
|
||||
|
||||
return mapNode, nil
|
||||
}
|
||||
@@ -88,7 +88,6 @@ func ApplyUserDefaults(u *users.User) {
|
||||
|
||||
// Handle Preview - convert from pointer-based defaults to regular bools
|
||||
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
|
||||
u.Preview.HighQuality = boolValueOrDefault(Config.UserDefaults.Preview.HighQuality, true)
|
||||
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
|
||||
|
||||
@@ -36,30 +36,36 @@ type Environment struct {
|
||||
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
|
||||
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
|
||||
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
|
||||
PWAIconsDir string `json:"-"` // directory where generated PWA icons are stored
|
||||
PWAIcon192 string `json:"-"` // path to 192x192 PWA icon
|
||||
PWAIcon256 string `json:"-"` // path to 256x256 PWA icon
|
||||
PWAIcon512 string `json:"-"` // path to 512x512 PWA icon
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
|
||||
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
|
||||
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
|
||||
Socket string `json:"socket"` // socket to listen on
|
||||
TLSKey string `json:"tlsKey"` // path to TLS key
|
||||
TLSCert string `json:"tlsCert"` // path to TLS cert
|
||||
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
|
||||
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
|
||||
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
|
||||
Port int `json:"port"` // port to listen on
|
||||
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
|
||||
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
|
||||
Logging []LogConfig `json:"logging" yaml:"logging"`
|
||||
Database string `json:"database"` // path to the database file
|
||||
Sources []*Source `json:"sources" validate:"required,dive"`
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup *bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory (default: true)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
|
||||
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
|
||||
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
|
||||
Socket string `json:"socket"` // socket to listen on
|
||||
TLSKey string `json:"tlsKey"` // path to TLS key
|
||||
TLSCert string `json:"tlsCert"` // path to TLS cert
|
||||
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
|
||||
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
|
||||
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
|
||||
Port int `json:"port"` // port to listen on
|
||||
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
|
||||
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
|
||||
Logging []LogConfig `json:"logging" yaml:"logging"`
|
||||
Database string `json:"database"` // path to the database file
|
||||
Sources []*Source `json:"sources" validate:"required,dive"`
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
|
||||
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
|
||||
// not exposed to config
|
||||
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
|
||||
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
|
||||
@@ -70,6 +76,13 @@ type Filesystem struct {
|
||||
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
|
||||
}
|
||||
|
||||
type IndexSqlConfig struct {
|
||||
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
|
||||
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
|
||||
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
|
||||
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
|
||||
}
|
||||
|
||||
type Integrations struct {
|
||||
OnlyOffice OnlyOffice `json:"office" validate:"omitempty"`
|
||||
Media Media `json:"media" validate:"omitempty"`
|
||||
@@ -89,17 +102,19 @@ type Media struct {
|
||||
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
|
||||
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // extract embedded subtitles from media files
|
||||
ExiftoolPath string `json:"exiftoolPath"` // path to exiftool executable
|
||||
}
|
||||
|
||||
type FfmpegConvert struct {
|
||||
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // supported image preview formats. defaults to false for all types unless explicitly enabled.
|
||||
VideoPreview map[VideoPreviewType]bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
|
||||
ImagePreview map[ImagePreviewType]*bool `json:"imagePreview"` // supported image preview formats. defaults vary by type (see individual type docs)
|
||||
VideoPreview map[VideoPreviewType]*bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
|
||||
}
|
||||
|
||||
type ImagePreviewType string
|
||||
|
||||
const (
|
||||
HEICImagePreview ImagePreviewType = "heic"
|
||||
JPEGImagePreview ImagePreviewType = "jpeg" // only used as fallback for JPEG formats that can't otherwise be decoded
|
||||
//RAWImagePreview ImagePreviewType = "raw"
|
||||
)
|
||||
|
||||
@@ -110,6 +125,7 @@ func (i ImagePreviewType) String() string {
|
||||
// AllImagePreviewTypes contains all supported image preview types.
|
||||
var AllImagePreviewTypes = []ImagePreviewType{
|
||||
HEICImagePreview,
|
||||
JPEGImagePreview,
|
||||
//RAWImagePreview,
|
||||
}
|
||||
|
||||
@@ -181,24 +197,26 @@ type SourceConfig struct {
|
||||
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
|
||||
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
|
||||
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
|
||||
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // (optional) not recommended: manual overide interval in minutes to re-index the source
|
||||
DisableIndexing bool `json:"disableIndexing,omitempty"` // (optional) not recommended: disable the indexing of this source
|
||||
Conditionals ConditionalFilter `json:"conditionals"` // conditional rules to apply when indexing to include/exclude certain items
|
||||
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // deprecated: create a rule with indexingIntervalMinutes to set the indexing interval for this source
|
||||
DisableIndexing bool `json:"disableIndexing,omitempty"` // deprecated: use indexingDisabled instead to disable the indexing of this source
|
||||
Conditionals ConditionalFilter `json:"conditionals"` // deprecated: use source.rules instead
|
||||
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
|
||||
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
|
||||
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
|
||||
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
// hidden but used internally - optimized map lookups for conditional rules
|
||||
ResolvedConditionals *ResolvedConditionalsConfig `json:"-"`
|
||||
ResolvedRules ResolvedRulesConfig `json:"-"`
|
||||
}
|
||||
|
||||
type ConditionalFilter struct {
|
||||
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead to exclude hidden files and folders.
|
||||
IgnoreHidden bool `json:"ignoreHidden"` // exclude hidden files and folders.
|
||||
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
|
||||
ItemRules []ConditionalIndexConfig `json:"rules"` // list of item rules to apply to specific paths
|
||||
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
|
||||
IgnoreHidden bool `json:"ignoreHidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
|
||||
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // deprecated: use ignoreZeroSizeFolders instead. eg, FolderPath: "/" and ignoreZeroSizeFolders: true will ignore folders with 0 size under the root folder.
|
||||
ItemRules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
|
||||
}
|
||||
|
||||
type ConditionalIndexConfig struct {
|
||||
type ConditionalRule struct {
|
||||
NeverWatchPath string `json:"neverWatchPath"` // index the folder in the first pass to get included in search, but never re-indexed.
|
||||
IncludeRootItem string `json:"includeRootItem"` // include only these items at root folder level
|
||||
FileStartsWith string `json:"fileStartsWith"` // (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
|
||||
@@ -212,29 +230,37 @@ type ConditionalIndexConfig struct {
|
||||
FileName string `json:"fileName"` // (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
|
||||
FolderName string `json:"folderName"` // (global) exclude folders that match these names. Eg. "folder" or "subfolder"
|
||||
|
||||
Viewable bool `json:"viewable"` // Enable viewing in UI but exclude from indexing
|
||||
Viewable bool `json:"viewable"` // Enable viewing in UI but exclude from indexing
|
||||
IgnoreHidden bool `json:"ignoreHidden"` // Excludes only hidden files and folders
|
||||
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // Excludes only folders with 0 size
|
||||
IgnoreSymlinks bool `json:"ignoreSymlinks"` // Excludes symbolic links
|
||||
}
|
||||
|
||||
// ConditionalMaps provides O(1) lookup performance for conditional rules
|
||||
// Maps are built from ConditionalFilter during initialization
|
||||
type ResolvedConditionalsConfig struct {
|
||||
type ResolvedRulesConfig struct {
|
||||
// Exact match maps - O(1) lookup (only for names, not StartsWith/EndsWith)
|
||||
FileNames map[string]ConditionalIndexConfig // key: file name
|
||||
FolderNames map[string]ConditionalIndexConfig // key: folder name
|
||||
FileNames map[string]ConditionalRule // key: file name
|
||||
FolderNames map[string]ConditionalRule // key: folder name
|
||||
|
||||
// exact match for paths
|
||||
FilePaths map[string]ConditionalIndexConfig // key: file path
|
||||
FolderPaths map[string]ConditionalIndexConfig // key: folder path
|
||||
FilePaths map[string]ConditionalRule // key: file path
|
||||
FolderPaths map[string]ConditionalRule // key: folder path
|
||||
|
||||
FileEndsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
|
||||
FolderEndsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
|
||||
FileStartsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
|
||||
FolderStartsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
|
||||
FileEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
FolderEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
FileStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
FolderStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
|
||||
// NeverWatch paths map - O(1) lookup for all paths with neverWatch: true
|
||||
// This replaces the old NeverWatchPaths slice
|
||||
NeverWatchPaths map[string]struct{} // key: full folder path
|
||||
IncludeRootItems map[string]struct{} // key: inclusive root item
|
||||
|
||||
IgnoreAllHidden bool // Excludes all hidden files and folders
|
||||
IgnoreAllZeroSizeFolders bool // Excludes all folders with 0 size
|
||||
IgnoreAllSymlinks bool // Excludes all symbolic links
|
||||
IndexingDisabled bool // Excludes all files and folders from indexing
|
||||
}
|
||||
|
||||
type Frontend struct {
|
||||
@@ -244,9 +270,11 @@ type Frontend struct {
|
||||
ExternalLinks []ExternalLink `json:"externalLinks"`
|
||||
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
|
||||
Styling StylingConfig `json:"styling"`
|
||||
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
|
||||
Description string `json:"description"` // description that shows up in html head meta description
|
||||
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
|
||||
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
|
||||
Description string `json:"description"` // description that shows up in html head meta description
|
||||
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
|
||||
LoginButtonText string `json:"loginButtonText"` // text to display on the login button
|
||||
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
|
||||
}
|
||||
|
||||
type StylingConfig struct {
|
||||
@@ -275,7 +303,7 @@ type ExternalLink struct {
|
||||
// UserDefaultsPreview holds preview settings with pointer types for defaults
|
||||
type UserDefaultsPreview struct {
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
|
||||
HighQuality *bool `json:"highQuality"` // use high quality thumbnails
|
||||
HighQuality *bool `json:"highQuality"` // deprecated: always true in v1.3.0+
|
||||
Image *bool `json:"image"` // show thumbnails for image files
|
||||
Video *bool `json:"video"` // show thumbnails for video files
|
||||
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
@@ -331,5 +359,4 @@ type UserDefaults 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
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order since 1.1.0
|
||||
}
|
||||
@@ -26,7 +26,7 @@ integrations:
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// All video formats should be enabled by default
|
||||
for _, format := range AllVideoPreviewTypes {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ integrations:
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// All video formats should be enabled by default
|
||||
for _, format := range AllVideoPreviewTypes {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
@@ -81,16 +81,16 @@ integrations:
|
||||
`
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// Check explicitly set formats
|
||||
if !config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
|
||||
t.Error("Expected mp4 to be enabled")
|
||||
}
|
||||
if config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
|
||||
if *config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
|
||||
t.Error("Expected webm to be disabled")
|
||||
}
|
||||
if config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
|
||||
if *config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
|
||||
t.Error("Expected mkv to be disabled")
|
||||
}
|
||||
if config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
|
||||
if *config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
|
||||
t.Error("Expected wmv to be disabled")
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ integrations:
|
||||
M4VVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
|
||||
}
|
||||
for _, format := range unmentionedFormats {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled (not mentioned in config), but it was disabled", format)
|
||||
}
|
||||
}
|
||||
@@ -155,13 +155,13 @@ integrations:
|
||||
}
|
||||
|
||||
for _, format := range expectedEnabled {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range expectedDisabled {
|
||||
if config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if *config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be disabled, but it was enabled", format)
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,7 @@ integrations:
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// All video formats should be enabled by default
|
||||
for _, format := range AllVideoPreviewTypes {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ integrations:
|
||||
for category, formats := range categories {
|
||||
t.Run(category, func(t *testing.T) {
|
||||
for _, format := range formats {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s (%s) to be enabled by default, but it was disabled", format, category)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
)
|
||||
@@ -44,15 +47,37 @@ func GetChecksum(fullPath, algo string) (string, error) {
|
||||
|
||||
// FileOptions are the options when getting a file info.
|
||||
type FileOptions struct {
|
||||
Username string // username for access control
|
||||
Path string // realpath
|
||||
Source string
|
||||
IsDir bool
|
||||
Expand bool
|
||||
ReadHeader bool
|
||||
Content bool
|
||||
Recursive bool // whether to recursively index directories
|
||||
Metadata bool // whether to get metadata
|
||||
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
|
||||
AlbumArt bool // whether to get album art from media files
|
||||
Recursive bool // whether to recursively index directories
|
||||
Metadata bool // whether to get metadata
|
||||
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
|
||||
AlbumArt bool // whether to get album art from media files
|
||||
ShowHidden bool // whether to show hidden files (true = show, false = hide)
|
||||
FollowSymlinks bool // whether to follow symlinks
|
||||
Only string // whether to only get files or folders
|
||||
SkipExtendedAttrs bool // whether to skip extended attributes
|
||||
}
|
||||
|
||||
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
|
||||
// Rule 1: Do Not Use User Input in File Paths (without validation)
|
||||
func SanitizeUserPath(userPath string) (string, error) {
|
||||
clean := filepath.Clean(userPath)
|
||||
|
||||
// Split the path into segments to check for path traversal attempts
|
||||
// We check if ".." appears as a complete path segment (not just in a filename)
|
||||
segments := strings.Split(clean, string(filepath.Separator))
|
||||
|
||||
for _, segment := range segments {
|
||||
// Check if any segment is exactly ".." (path traversal attempt)
|
||||
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
|
||||
if segment == ".." {
|
||||
return "", fmt.Errorf("invalid path: path traversal detected")
|
||||
}
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -130,6 +131,20 @@ func Ternary[T any](cond bool, vtrue, vfalse T) T {
|
||||
return vfalse
|
||||
}
|
||||
|
||||
// Clamp returns value clamped between min and max.
|
||||
// If value < min, returns min.
|
||||
// If value > max, returns max.
|
||||
// Otherwise, returns value.
|
||||
func Clamp[T cmp.Ordered](value, min, max T) T {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// NormalizeRulePath ensures directory paths have trailing slashes for consistent rule storage
|
||||
func AddTrailingSlashIfNotExists(indexPath string) string {
|
||||
// Root path stays as "/"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
type SubtitleTrack struct {
|
||||
Name string `json:"name"` // filename for external, or descriptive name for embedded
|
||||
Language string `json:"language"` // language code
|
||||
Title string `json:"title,omitempty"` // title/description
|
||||
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
|
||||
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
|
||||
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
|
||||
}
|
||||
|
||||
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
|
||||
// validate the size is not too large
|
||||
info, err := os.Stat(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Size() > 1024*1024*50 { // 50MB
|
||||
return "", errors.New("subtitle file is too large")
|
||||
}
|
||||
|
||||
// First check if it's a text file using the consolidated validation logic
|
||||
isText, err := IsTextFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isText {
|
||||
// Not a text file, return empty string (no error, just not text)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// read the file content
|
||||
content, err := os.ReadFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// IsTextFile checks if a file is viewable/editable as text (like with cat or a text editor).
|
||||
// It returns true if the file appears to be text, false if it's likely binary.
|
||||
// This function uses simple heuristics:
|
||||
// - Valid UTF-8 encoding
|
||||
// - Not full of null bytes (which indicate binary data)
|
||||
func IsTextFile(realPath string) (bool, error) {
|
||||
const sampleSize = 8192 // Read first 8KB to check
|
||||
const maxNullByteRatio = 0.05 // Reject if more than 5% null bytes
|
||||
// Read sample from file
|
||||
content, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Empty files are considered text
|
||||
if len(content) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
// Use sample for large files to avoid reading entire file into memory
|
||||
sample := content
|
||||
if len(content) > sampleSize {
|
||||
sample = content[:sampleSize]
|
||||
}
|
||||
// Check 1: Count null bytes - binary files typically have many nulls
|
||||
nullCount := 0
|
||||
for _, b := range sample {
|
||||
if b == 0x00 {
|
||||
nullCount++
|
||||
}
|
||||
}
|
||||
|
||||
if nullCount > 0 {
|
||||
nullRatio := float64(nullCount) / float64(len(sample))
|
||||
if nullRatio > maxNullByteRatio {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2: Validate UTF-8 encoding
|
||||
// Trim sample to last complete UTF-8 rune to avoid false negatives
|
||||
trimmedSample := sample
|
||||
for len(trimmedSample) > 0 {
|
||||
lastRune, size := utf8.DecodeLastRune(trimmedSample)
|
||||
if lastRune != utf8.RuneError {
|
||||
break
|
||||
}
|
||||
if size == 1 {
|
||||
trimmedSample = trimmedSample[:len(trimmedSample)-1]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(trimmedSample) > 0 && !utf8.Valid(trimmedSample) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -8,4 +8,3 @@ userDefaults:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
highQuality: true
|
||||
@@ -25,7 +25,6 @@ userDefaults:
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
preview:
|
||||
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
|
||||
highQuality: true # generate high quality thumbnail preview images (defaults to true if not set)
|
||||
image: true # show thumbnail preview image for image files (defaults to true if not set)
|
||||
video: true # show thumbnail preview image for video files (defaults to true if not set)
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
|
||||
|
||||
+127
-124
@@ -37,8 +37,10 @@ type SourceRuleMap map[string]RuleMap
|
||||
type StringSet map[string]struct{}
|
||||
|
||||
type dbStorage struct {
|
||||
AllRules SourceRuleMap `json:"all_rules"`
|
||||
Groups GroupMap `json:"groups"`
|
||||
AllRules SourceRuleMap `json:"all_rules"`
|
||||
Groups GroupMap `json:"groups"`
|
||||
RevokedTokens map[string]struct{} `json:"revoked_tokens"` // set of revoked token hashes
|
||||
HashedTokens map[string]uint `json:"hashed_tokens"` // maps token hash → user ID
|
||||
}
|
||||
|
||||
// RuleSet groups users and groups for allow/deny lists.
|
||||
@@ -72,21 +74,26 @@ type GroupMap map[string]StringSet
|
||||
|
||||
// Storage manages access rules and group membership.
|
||||
type Storage struct {
|
||||
mux sync.RWMutex
|
||||
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
|
||||
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
|
||||
DB *storm.DB // Optional: DB for persistence
|
||||
Users *users.Storage // Reference to users storage
|
||||
mux sync.RWMutex
|
||||
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
|
||||
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
|
||||
RevokedTokens map[string]struct{} // set of revoked token hashes - in-memory authoritative state
|
||||
HashedTokens map[string]uint // maps token hash → user ID - in-memory authoritative state
|
||||
DB *storm.DB // Optional: DB for persistence
|
||||
Users *users.Storage // Reference to users storage
|
||||
}
|
||||
|
||||
// SaveToDB persists all rules to the DB if DB is set.
|
||||
// IMPORTANT: Caller must hold s.mux lock (either read or write).
|
||||
func (s *Storage) SaveToDB() error {
|
||||
if s.DB == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(&dbStorage{
|
||||
AllRules: s.AllRules,
|
||||
Groups: s.Groups,
|
||||
AllRules: s.AllRules,
|
||||
Groups: s.Groups,
|
||||
RevokedTokens: s.RevokedTokens,
|
||||
HashedTokens: s.HashedTokens,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -97,6 +104,8 @@ func (s *Storage) SaveToDB() error {
|
||||
// Flush persists the current in-memory state to the backing store.
|
||||
// Call during graceful shutdown to ensure DB matches memory.
|
||||
func (s *Storage) Flush() error {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -123,6 +132,18 @@ func (s *Storage) LoadFromDB() error {
|
||||
if s.Groups == nil {
|
||||
s.Groups = make(GroupMap)
|
||||
}
|
||||
s.RevokedTokens = storage.RevokedTokens
|
||||
if s.RevokedTokens == nil {
|
||||
s.RevokedTokens = make(map[string]struct{})
|
||||
}
|
||||
s.HashedTokens = storage.HashedTokens
|
||||
if s.HashedTokens == nil {
|
||||
s.HashedTokens = make(map[string]uint)
|
||||
}
|
||||
s.HashedTokens = storage.HashedTokens
|
||||
if s.HashedTokens == nil {
|
||||
s.HashedTokens = make(map[string]uint)
|
||||
}
|
||||
s.mux.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -136,10 +157,12 @@ func (s *Storage) LoadFromDB() error {
|
||||
// if err != nil { /* handle error */ }
|
||||
func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
|
||||
var s = &Storage{
|
||||
AllRules: make(SourceRuleMap),
|
||||
Groups: make(GroupMap),
|
||||
DB: db,
|
||||
Users: usersStore,
|
||||
AllRules: make(SourceRuleMap),
|
||||
Groups: make(GroupMap),
|
||||
RevokedTokens: make(map[string]struct{}),
|
||||
HashedTokens: make(map[string]uint),
|
||||
DB: db,
|
||||
Users: usersStore,
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -153,18 +176,12 @@ func ClearCache() {
|
||||
rulesCache = cache.NewCache[map[string]FrontendAccessRule](1 * time.Minute)
|
||||
}
|
||||
|
||||
// ClearCacheForSource clears the cache for a specific source
|
||||
func (s *Storage) ClearCacheForSource(sourcePath string) {
|
||||
// Clear all caches related to this source
|
||||
accessCache.Delete(accessChangedKey + sourcePath)
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
versionCache.Delete("version:" + sourcePath)
|
||||
|
||||
// Clear all permission caches for this source
|
||||
// We need to iterate through all permission cache keys and remove those for this source
|
||||
// Since we can't iterate through cache keys, we'll just increment the version
|
||||
// which will invalidate all permission caches for this source
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
// clearAllCaches clears ALL caches. This should be called whenever rules are created, updated, or deleted.
|
||||
func (s *Storage) clearAllCaches() {
|
||||
accessCache.ClearAll()
|
||||
versionCache.ClearAll()
|
||||
permissionCache.ClearAll()
|
||||
rulesCache.ClearAll()
|
||||
}
|
||||
|
||||
// RemoveRuleByPath removes a rule by its exact path from the internal storage
|
||||
@@ -180,18 +197,11 @@ func (s *Storage) RemoveRuleByPath(sourcePath, indexPath string) {
|
||||
// Remove the rule by exact path match (don't normalize)
|
||||
if _, exists := rulesBySource[indexPath]; exists {
|
||||
delete(rulesBySource, indexPath)
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(rulesBySource) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
|
||||
// Save to database
|
||||
s.clearAllCaches()
|
||||
err := s.SaveToDB()
|
||||
if err != nil {
|
||||
logger.Errorf("error saving access rules to database: %v", err)
|
||||
@@ -233,9 +243,7 @@ func (s *Storage) DenyUser(sourcePath, indexPath, username string) error {
|
||||
return errors.ErrExist
|
||||
}
|
||||
rule.Deny.Users[username] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -254,9 +262,7 @@ func (s *Storage) AllowUser(sourcePath, indexPath, username string) error {
|
||||
return errors.ErrExist
|
||||
}
|
||||
rule.Allow.Users[username] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -273,9 +279,7 @@ func (s *Storage) DenyGroup(sourcePath, indexPath, groupname string) error {
|
||||
return errors.ErrExist
|
||||
}
|
||||
rule.Deny.Groups[groupname] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -292,9 +296,7 @@ func (s *Storage) AllowGroup(sourcePath, indexPath, groupname string) error {
|
||||
return errors.ErrExist
|
||||
}
|
||||
rule.Allow.Groups[groupname] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -307,19 +309,17 @@ func (s *Storage) DenyAll(sourcePath, indexPath string) error {
|
||||
return errors.ErrExist
|
||||
}
|
||||
rule.DenyAll = true
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
|
||||
func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
|
||||
// SECURITY: All paths MUST start with "/" - reject any path that doesn't
|
||||
// This prevents path normalization bypass attacks
|
||||
// Ensure leading slash
|
||||
if !strings.HasPrefix(indexPath, "/") {
|
||||
return false
|
||||
indexPath = "/" + indexPath
|
||||
}
|
||||
indexPath = utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
|
||||
// Get current version for the sourcePath
|
||||
versionKey := "version:" + sourcePath
|
||||
@@ -659,11 +659,7 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
|
||||
if exists {
|
||||
delete(rule.Allow.Users, username)
|
||||
}
|
||||
removed := false
|
||||
if exists {
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
removed = true
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
@@ -672,8 +668,7 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return exists, s.SaveToDB()
|
||||
}
|
||||
return false, nil
|
||||
@@ -692,11 +687,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
|
||||
if exists {
|
||||
delete(rule.Allow.Groups, groupname)
|
||||
}
|
||||
removed := false
|
||||
if exists {
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
removed = true
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
@@ -705,8 +696,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return exists, s.SaveToDB()
|
||||
}
|
||||
return exists, nil
|
||||
@@ -725,11 +715,7 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
|
||||
if exists {
|
||||
delete(rule.Deny.Users, username)
|
||||
}
|
||||
removed := false
|
||||
if exists {
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
removed = true
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
@@ -738,8 +724,7 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return exists, s.SaveToDB()
|
||||
}
|
||||
return false, nil
|
||||
@@ -758,11 +743,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
|
||||
if exists {
|
||||
delete(rule.Deny.Groups, groupname)
|
||||
}
|
||||
removed := false
|
||||
if exists {
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
removed = true
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
@@ -771,8 +752,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return exists, s.SaveToDB()
|
||||
}
|
||||
return exists, nil
|
||||
@@ -790,7 +770,6 @@ func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
|
||||
removed := false
|
||||
if rule.DenyAll {
|
||||
rule.DenyAll = false
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
removed = true
|
||||
}
|
||||
// If rule is now empty, remove it
|
||||
@@ -801,8 +780,7 @@ func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return true, s.SaveToDB()
|
||||
}
|
||||
return false, nil
|
||||
@@ -834,12 +812,8 @@ func (s *Storage) RemoveAllRulesForUser(username string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
for sp := range changedSourcePaths {
|
||||
s.incrementSourceVersion(sp)
|
||||
accessCache.Set(accessChangedKey+sp, "false")
|
||||
rulesCache.Delete(accessChangedKey + sp)
|
||||
}
|
||||
if changed {
|
||||
s.clearAllCaches()
|
||||
return s.SaveToDB()
|
||||
}
|
||||
return nil
|
||||
@@ -871,12 +845,8 @@ func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
for sp := range changedSourcePaths {
|
||||
s.incrementSourceVersion(sp)
|
||||
accessCache.Set(accessChangedKey+sp, "false")
|
||||
rulesCache.Delete(accessChangedKey + sp)
|
||||
}
|
||||
if changed {
|
||||
s.clearAllCaches()
|
||||
return s.SaveToDB()
|
||||
}
|
||||
return nil
|
||||
@@ -1084,25 +1054,11 @@ func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]F
|
||||
return allGroupRules
|
||||
}
|
||||
|
||||
// incrementSourceVersion increments the version of a sourcePath to invalidate caches.
|
||||
func (s *Storage) incrementSourceVersion(sourcePath string) {
|
||||
key := "version:" + sourcePath
|
||||
version := 0
|
||||
if v, ok := versionCache.Get(key); ok {
|
||||
version = v
|
||||
}
|
||||
versionCache.Set(key, version+1)
|
||||
}
|
||||
|
||||
// HasAnyVisibleItems checks if a user has access to any items in a given parent path.
|
||||
// This is used to determine if a user should see a folder's contents even when
|
||||
// they don't have direct access to the parent folder.
|
||||
func (s *Storage) HasAnyVisibleItems(sourcePath, parentPath string, itemNames []string, username string) bool {
|
||||
// Ensure parentPath has trailing slash for proper path construction
|
||||
if !strings.HasSuffix(parentPath, "/") {
|
||||
parentPath = parentPath + "/"
|
||||
}
|
||||
|
||||
parentPath = utils.AddTrailingSlashIfNotExists(parentPath)
|
||||
// Check if user has access to any of the items
|
||||
for _, itemName := range itemNames {
|
||||
indexPath := parentPath + itemName
|
||||
@@ -1164,9 +1120,7 @@ func (s *Storage) RemoveUserCascade(sourcePath, indexPath, username string, allo
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return removedCount, s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -1223,9 +1177,7 @@ func (s *Storage) RemoveGroupCascade(sourcePath, indexPath, groupname string, al
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
s.clearAllCaches()
|
||||
return removedCount, s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -1272,11 +1224,7 @@ func (s *Storage) UpdateRules(sourcePath, oldPath, newPath string) (int, error)
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
// Invalidate caches
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
|
||||
s.clearAllCaches()
|
||||
if err := s.SaveToDB(); err != nil {
|
||||
return updated, err
|
||||
}
|
||||
@@ -1307,12 +1255,67 @@ func (s *Storage) UpdateRulePath(sourcePath, oldPath, newPath string) error {
|
||||
// Remove the old rule and add it with the new path
|
||||
delete(rulesBySource, oldPath)
|
||||
rulesBySource[newPath] = rule
|
||||
|
||||
// Invalidate caches
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
|
||||
s.clearAllCaches()
|
||||
logger.Debugf("access rule path updated: source=%s, fromPath=%s, toPath=%s", sourcePath, oldPath, newPath)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
// RevokeToken adds a token hash to the revoked list and persists to DB.
|
||||
func (s *Storage) RevokeToken(tokenHash string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
tokenHash = utils.HashSHA256(tokenHash)
|
||||
s.RevokedTokens[tokenHash] = struct{}{}
|
||||
// Also remove from HashedTokens to prevent future lookups
|
||||
delete(s.HashedTokens, tokenHash)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
// IsTokenRevoked checks if a token hash is in the revoked list (memory-only read).
|
||||
func (s *Storage) IsTokenRevoked(tokenHash string) bool {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
tokenHash = utils.HashSHA256(tokenHash)
|
||||
_, exists := s.RevokedTokens[tokenHash]
|
||||
return exists
|
||||
}
|
||||
|
||||
// AddHashedToken adds a token hash to user ID mapping.
|
||||
func (s *Storage) AddApiToken(tokenString string, userID uint) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
s.HashedTokens[tokenHash] = userID
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
// GetUserIDFromToken retrieves the user ID for a given token string (memory-only read).
|
||||
func (s *Storage) GetUserIDFromToken(tokenString string) (uint, bool) {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
userID, exists := s.HashedTokens[tokenHash]
|
||||
return userID, exists
|
||||
}
|
||||
|
||||
// GetRevokedTokens returns a copy of all revoked token hashes.
|
||||
func (s *Storage) GetRevokedTokens() map[string]struct{} {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
// Return a copy to prevent external modification
|
||||
result := make(map[string]struct{}, len(s.RevokedTokens))
|
||||
for k := range s.RevokedTokens {
|
||||
result[k] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RemoveApiToken removes a token hash mapping (used when deleting API keys).
|
||||
func (s *Storage) RemoveApiToken(tokenString string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
delete(s.HashedTokens, tokenHash)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
@@ -38,17 +38,32 @@ func setupTestSources() {
|
||||
Path: "mnt/storage",
|
||||
Name: "storage",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false,
|
||||
DenyByDefault: false,
|
||||
DefaultUserScope: "/",
|
||||
},
|
||||
},
|
||||
"mnt/open": {
|
||||
Path: "mnt/open",
|
||||
Name: "open",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false,
|
||||
DenyByDefault: false,
|
||||
DefaultUserScope: "/",
|
||||
},
|
||||
},
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"storage": settings.Config.Server.SourceMap["mnt/storage"],
|
||||
"open": settings.Config.Server.SourceMap["mnt/open"],
|
||||
}
|
||||
settings.Config.Server.Sources = []*settings.Source{
|
||||
settings.Config.Server.SourceMap["mnt/storage"],
|
||||
settings.Config.Server.SourceMap["mnt/open"],
|
||||
}
|
||||
settings.Config.UserDefaults.DefaultScopes = []users.SourceScope{
|
||||
{Name: "mnt/storage", Scope: "/"},
|
||||
}
|
||||
// Initialize user package resolvers
|
||||
settings.InitializeUserResolvers()
|
||||
}
|
||||
|
||||
func TestPermitted_UserBlacklist(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, index *indexing.Index, username string) error {
|
||||
func (s *Storage) CheckChildItemAccess(response *iteminfo.FileInfo, index *indexing.Index, username string) error {
|
||||
|
||||
// Collect all item names to check
|
||||
allItemNames := make([]string, 0, len(response.Folders)+len(response.Files))
|
||||
@@ -19,10 +19,11 @@ func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, inde
|
||||
}
|
||||
|
||||
// Use standardized path with trailing slash for proper path construction
|
||||
parentPath := index.MakeIndexPath(response.Path)
|
||||
// response is an ExtendedFileInfo which represents a directory (has Folders and Files)
|
||||
parentPath := index.MakeIndexPath(response.Path, true)
|
||||
|
||||
// Check if user has access to any items
|
||||
if !s.HasAnyVisibleItems(index.Path, parentPath, allItemNames, username) {
|
||||
if !s.HasAnyVisibleItems(index.Path, parentPath, allItemNames, username) && len(allItemNames) > 0 {
|
||||
return errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package dbindex
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// IndexInfo stores persistent information about an index and its scanners
|
||||
type IndexInfo struct {
|
||||
// Index identifier - uses the real filesystem path (index.Path) as the key
|
||||
Path string `json:"path" storm:"id,index"`
|
||||
|
||||
// Source name (index.Name) for reference
|
||||
Source string `json:"source" storm:"index"`
|
||||
|
||||
// Index-level stats
|
||||
Complexity uint `json:"complexity"`
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
|
||||
// Scanner information - map of scanner path to scanner stats
|
||||
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
|
||||
}
|
||||
|
||||
// PersistedScannerInfo stores persistent information about a scanner
|
||||
type PersistedScannerInfo struct {
|
||||
Path string `json:"path"`
|
||||
Complexity uint `json:"complexity"`
|
||||
CurrentSchedule int `json:"currentSchedule"`
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
QuickScanTime int `json:"quickScanTime"`
|
||||
FullScanTime int `json:"fullScanTime"`
|
||||
LastScanned time.Time `json:"lastScanned"`
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package dbindex
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
|
||||
)
|
||||
|
||||
// StorageBackend is the interface to implement for an indexing storage.
|
||||
type StorageBackend interface {
|
||||
All() ([]*IndexInfo, error)
|
||||
GetByPath(path string) (*IndexInfo, error)
|
||||
GetBySource(source string) ([]*IndexInfo, error)
|
||||
Save(info *IndexInfo) error
|
||||
Delete(path string) error
|
||||
}
|
||||
|
||||
// crudBackend implements crud.CrudBackend[IndexInfo] for indexing storage.
|
||||
type crudBackend struct {
|
||||
back StorageBackend
|
||||
}
|
||||
|
||||
func (c *crudBackend) GetByID(id any) (*IndexInfo, error) {
|
||||
path, ok := id.(string)
|
||||
if !ok {
|
||||
return nil, errors.ErrInvalidDataType
|
||||
}
|
||||
return c.back.GetByPath(path)
|
||||
}
|
||||
|
||||
func (c *crudBackend) GetAll() ([]*IndexInfo, error) {
|
||||
return c.back.All()
|
||||
}
|
||||
|
||||
func (c *crudBackend) Save(obj *IndexInfo) error {
|
||||
return c.back.Save(obj)
|
||||
}
|
||||
|
||||
func (c *crudBackend) DeleteByID(id any) error {
|
||||
path, ok := id.(string)
|
||||
if !ok {
|
||||
return errors.ErrInvalidDataType
|
||||
}
|
||||
return c.back.Delete(path)
|
||||
}
|
||||
|
||||
// Storage is an indexing storage using generics.
|
||||
type Storage struct {
|
||||
Generic *crud.Storage[IndexInfo]
|
||||
back StorageBackend
|
||||
indexCache map[string]*IndexInfo
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewStorage creates an indexing storage from a backend.
|
||||
func NewStorage(back StorageBackend) *Storage {
|
||||
return &Storage{
|
||||
Generic: crud.NewStorage[IndexInfo](&crudBackend{back: back}),
|
||||
back: back,
|
||||
indexCache: make(map[string]*IndexInfo),
|
||||
}
|
||||
}
|
||||
|
||||
// All wraps StorageBackend.All
|
||||
func (s *Storage) All() ([]*IndexInfo, error) {
|
||||
infos, err := s.back.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for i, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
if existing, ok := s.indexCache[info.Path]; ok && existing != nil {
|
||||
infos[i] = existing
|
||||
} else {
|
||||
s.indexCache[info.Path] = info
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// GetByPath wraps StorageBackend.GetByPath
|
||||
func (s *Storage) GetByPath(path string) (*IndexInfo, error) {
|
||||
// return stable in-memory pointer if available
|
||||
s.mu.RLock()
|
||||
if info, ok := s.indexCache[path]; ok && info != nil {
|
||||
s.mu.RUnlock()
|
||||
return info, nil
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
info, err := s.back.GetByPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.indexCache[path] = info
|
||||
s.mu.Unlock()
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// GetBySource wraps StorageBackend.GetBySource
|
||||
func (s *Storage) GetBySource(source string) ([]*IndexInfo, error) {
|
||||
infos, err := s.back.GetBySource(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for i, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
if existing, ok := s.indexCache[info.Path]; ok && existing != nil {
|
||||
infos[i] = existing
|
||||
} else {
|
||||
s.indexCache[info.Path] = info
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// Save wraps StorageBackend.Save
|
||||
func (s *Storage) Save(info *IndexInfo) error {
|
||||
if err := s.back.Save(info); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.indexCache[info.Path] = info
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete wraps StorageBackend.Delete
|
||||
func (s *Storage) Delete(path string) error {
|
||||
if err := s.back.Delete(path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.indexCache, path)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush persists the current in-memory state of all indexes to the backing store.
|
||||
// Call during graceful shutdown to ensure DB matches memory.
|
||||
func (s *Storage) Flush() error {
|
||||
s.mu.RLock()
|
||||
infos := make([]*IndexInfo, 0, len(s.indexCache))
|
||||
for _, info := range s.indexCache {
|
||||
if info != nil {
|
||||
infos = append(infos, info)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
for _, info := range infos {
|
||||
if err := s.back.Save(info); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetAllComplexities sets the complexity to 0 for all indexes and their scanners.
|
||||
// This should be called when the index database is recreated (either fresh start or after corruption).
|
||||
func (s *Storage) ResetAllComplexities() error {
|
||||
// Get all indexes from storage
|
||||
infos, err := s.All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reset complexity for each index
|
||||
for _, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset index-level complexity
|
||||
info.Complexity = 0
|
||||
|
||||
// Reset scanner-level complexities
|
||||
for _, scanner := range info.Scanners {
|
||||
if scanner != nil {
|
||||
scanner.Complexity = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Save the updated info
|
||||
if err := s.Save(info); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -25,6 +25,9 @@ type CommonShare struct {
|
||||
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
|
||||
Path string `json:"path,omitempty"`
|
||||
DownloadURL string `json:"downloadURL,omitempty"`
|
||||
ShareURL string `json:"shareURL,omitempty"`
|
||||
FaviconUrl string `json:"faviconUrl,omitempty"`
|
||||
BannerUrl string `json:"bannerUrl,omitempty"`
|
||||
DisableShareCard bool `json:"disableShareCard,omitempty"`
|
||||
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
|
||||
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
|
||||
@@ -40,6 +43,8 @@ type CommonShare struct {
|
||||
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
|
||||
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
HasPassword bool `json:"hasPassword,omitempty"`
|
||||
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
|
||||
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
|
||||
}
|
||||
type CreateBody struct {
|
||||
CommonShare
|
||||
|
||||
+183
-142
@@ -56,86 +56,179 @@ func (c *crudBackend) DeleteByID(id any) error {
|
||||
|
||||
// Storage is a share storage using generics.
|
||||
type Storage struct {
|
||||
Generic *crud.Storage[Link]
|
||||
back StorageBackend
|
||||
shareCache map[string]*Link
|
||||
mu sync.RWMutex
|
||||
users *users.Storage
|
||||
Generic *crud.Storage[Link]
|
||||
back StorageBackend
|
||||
shareByHash map[string]*Link // key: link hash
|
||||
shareByPath map[string]map[string]string // key: pathKey(Source, Path), value: set of hashes (hash -> "")
|
||||
mu sync.RWMutex
|
||||
users *users.Storage
|
||||
}
|
||||
|
||||
// NewStorage creates a share links storage from a backend.
|
||||
// pathKey returns the cache key for shareByPath (source + path).
|
||||
func pathKey(source, path string) string {
|
||||
return source + ":" + path
|
||||
}
|
||||
|
||||
// setCacheLocked updates both shareByHash and shareByPath for the given link.
|
||||
// Caller must hold s.mu.
|
||||
func (s *Storage) setCacheLocked(link *Link) {
|
||||
if link == nil {
|
||||
return
|
||||
}
|
||||
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
|
||||
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
|
||||
s.shareByHash[link.Hash] = link
|
||||
key := pathKey(adjustedSource, adjustedPath)
|
||||
if s.shareByPath[key] == nil {
|
||||
s.shareByPath[key] = make(map[string]string)
|
||||
}
|
||||
s.shareByPath[key][link.Hash] = ""
|
||||
}
|
||||
|
||||
// setCache updates both caches for the given link. It acquires s.mu.
|
||||
func (s *Storage) setCache(link *Link) {
|
||||
s.mu.Lock()
|
||||
s.setCacheLocked(link)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// deleteFromCacheLocked removes the link (by hash) from both caches.
|
||||
// Caller must hold s.mu.
|
||||
func (s *Storage) deleteFromCacheLocked(hash string) {
|
||||
link, ok := s.shareByHash[hash]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
|
||||
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
|
||||
key := pathKey(adjustedSource, adjustedPath)
|
||||
if inner, ok := s.shareByPath[key]; ok {
|
||||
delete(inner, hash)
|
||||
if len(inner) == 0 {
|
||||
delete(s.shareByPath, key)
|
||||
}
|
||||
}
|
||||
delete(s.shareByHash, hash)
|
||||
}
|
||||
|
||||
// deleteFromCache removes the link (by hash) from both caches. It acquires s.mu.
|
||||
func (s *Storage) deleteFromCache(hash string) {
|
||||
s.mu.Lock()
|
||||
s.deleteFromCacheLocked(hash)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// setCacheAfterMove updates both caches when a link's Source or Path changed. It acquires s.mu.
|
||||
func (s *Storage) setCacheAfterMove(link *Link, oldSource, oldPath string) {
|
||||
s.mu.Lock()
|
||||
if link == nil {
|
||||
return
|
||||
}
|
||||
adjustedOldPath := utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
adjustedOldSource := utils.AddTrailingSlashIfNotExists(oldSource)
|
||||
oldKey := pathKey(adjustedOldSource, adjustedOldPath)
|
||||
if inner, ok := s.shareByPath[oldKey]; ok {
|
||||
delete(inner, link.Hash)
|
||||
if len(inner) == 0 {
|
||||
delete(s.shareByPath, oldKey)
|
||||
}
|
||||
}
|
||||
s.setCacheLocked(link)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// NewStorage creates a share links storage from a backend and populates the
|
||||
// in-memory cache from the database so all reads can be served from cache.
|
||||
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
|
||||
return &Storage{
|
||||
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
|
||||
back: back,
|
||||
shareCache: make(map[string]*Link),
|
||||
users: usersStore,
|
||||
s := &Storage{
|
||||
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
|
||||
back: back,
|
||||
shareByHash: make(map[string]*Link),
|
||||
shareByPath: make(map[string]map[string]string),
|
||||
users: usersStore,
|
||||
}
|
||||
s.loadShareCache()
|
||||
return s
|
||||
}
|
||||
|
||||
// All wraps StorageBackend.All and handles expiry.
|
||||
func (s *Storage) All() ([]*Link, error) {
|
||||
// loadShareCache fills shareByHash and shareByPath from the backend and removes expired links from the DB.
|
||||
// Call once at startup so all reads can be served from cache.
|
||||
func (s *Storage) loadShareCache() {
|
||||
links, err := s.back.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if err != nil && err != errors.ErrNotExist {
|
||||
return
|
||||
}
|
||||
filtered, err := s.filterExpired(links)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if links == nil {
|
||||
links = []*Link{}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for i, l := range filtered {
|
||||
if l == nil {
|
||||
for _, link := range links {
|
||||
if link == nil {
|
||||
continue
|
||||
}
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
filtered[i] = existing
|
||||
} else {
|
||||
s.shareCache[l.Hash] = l
|
||||
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
|
||||
_ = s.back.Delete(link.Hash)
|
||||
continue
|
||||
}
|
||||
link.InitUserDownloads()
|
||||
s.setCacheLocked(link)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// FindByUserID wraps StorageBackend.FindByUserID and handles expiry.
|
||||
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
|
||||
links, err := s.back.FindByUserID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered, err := s.filterExpired(links)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// LoadShareCacheFromDB repopulates the in-memory cache from the database.
|
||||
// Call at startup (e.g. from InitializeDb) to ensure shares are loaded after the store is ready.
|
||||
func (s *Storage) LoadShareCacheFromDB() {
|
||||
s.loadShareCache()
|
||||
}
|
||||
|
||||
// All returns all non-expired shares from the cache (populated at startup and by writes).
|
||||
func (s *Storage) All() ([]*Link, error) {
|
||||
s.mu.Lock()
|
||||
for i, l := range filtered {
|
||||
result := make([]*Link, 0, len(s.shareByHash))
|
||||
for _, l := range s.shareByHash {
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
filtered[i] = existing
|
||||
} else {
|
||||
s.shareCache[l.Hash] = l
|
||||
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
|
||||
_ = s.back.Delete(l.Hash)
|
||||
s.deleteFromCacheLocked(l.Hash)
|
||||
continue
|
||||
}
|
||||
result = append(result, l)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return filtered, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FindByUserID returns non-expired shares for the user from the cache.
|
||||
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
|
||||
s.mu.Lock()
|
||||
result := make([]*Link, 0)
|
||||
for _, l := range s.shareByHash {
|
||||
if l == nil || l.UserID != id {
|
||||
continue
|
||||
}
|
||||
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
|
||||
_ = s.back.Delete(l.Hash)
|
||||
s.deleteFromCacheLocked(l.Hash)
|
||||
continue
|
||||
}
|
||||
result = append(result, l)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByHash wraps StorageBackend.GetByHash and handles expiry.
|
||||
func (s *Storage) GetByHash(hash string) (*Link, error) {
|
||||
// return stable in-memory pointer if available
|
||||
s.mu.RLock()
|
||||
if link, ok := s.shareCache[hash]; ok && link != nil {
|
||||
if link, ok := s.shareByHash[hash]; ok && link != nil {
|
||||
s.mu.RUnlock()
|
||||
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
|
||||
_ = s.back.Delete(hash)
|
||||
s.mu.Lock()
|
||||
delete(s.shareCache, hash)
|
||||
s.mu.Unlock()
|
||||
s.deleteFromCache(hash)
|
||||
return nil, errors.ErrNotExist
|
||||
}
|
||||
return link, nil
|
||||
@@ -154,9 +247,7 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
|
||||
// Initialize UserDownloads map
|
||||
link.InitUserDownloads()
|
||||
|
||||
s.mu.Lock()
|
||||
s.shareCache[hash] = link
|
||||
s.mu.Unlock()
|
||||
s.setCache(link)
|
||||
return link, nil
|
||||
}
|
||||
|
||||
@@ -164,68 +255,69 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
|
||||
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
|
||||
l, err := s.back.GetPermanent(path, source, id)
|
||||
if err == nil && l != nil {
|
||||
s.mu.Lock()
|
||||
|
||||
s.shareCache[l.Hash] = l
|
||||
s.mu.Unlock()
|
||||
s.setCache(l)
|
||||
}
|
||||
return l, err
|
||||
}
|
||||
|
||||
// Gets wraps StorageBackend.Gets and handles expiry.
|
||||
// Gets returns shares for the given path, source, and user from the cache.
|
||||
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
|
||||
links, err := s.back.Gets(sourcePath, source, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered, err := s.filterExpired(links)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
for i, l := range filtered {
|
||||
if l == nil {
|
||||
adjustedPath := utils.AddTrailingSlashIfNotExists(sourcePath)
|
||||
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
|
||||
key := pathKey(adjustedSource, adjustedPath)
|
||||
hashes := s.shareByPath[key]
|
||||
result := make([]*Link, 0, len(hashes))
|
||||
for h := range hashes {
|
||||
l := s.shareByHash[h]
|
||||
if l == nil || l.UserID != id {
|
||||
continue
|
||||
}
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
filtered[i] = existing
|
||||
} else {
|
||||
s.shareCache[l.Hash] = l
|
||||
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
|
||||
_ = s.back.Delete(l.Hash)
|
||||
s.deleteFromCacheLocked(l.Hash)
|
||||
continue
|
||||
}
|
||||
result = append(result, l)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return filtered, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetBySourcePath wraps StorageBackend.GetBySourcePath and handles expiry.
|
||||
// GetBySourcePath returns shares for the given path and source from the cache.
|
||||
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
|
||||
links, err := s.back.GetBySourcePath(path, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered, err := s.filterExpired(links)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
for i, l := range filtered {
|
||||
adjustedPath := utils.AddTrailingSlashIfNotExists(path)
|
||||
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
|
||||
key := pathKey(adjustedSource, adjustedPath)
|
||||
hashes := s.shareByPath[key]
|
||||
result := make([]*Link, 0, len(hashes))
|
||||
for h := range hashes {
|
||||
l := s.shareByHash[h]
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
filtered[i] = existing
|
||||
} else {
|
||||
s.shareCache[l.Hash] = l
|
||||
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
|
||||
_ = s.back.Delete(l.Hash)
|
||||
s.deleteFromCacheLocked(l.Hash)
|
||||
continue
|
||||
}
|
||||
result = append(result, l)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return filtered, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsShared returns whether the given path and source have any shares in the cache.
|
||||
func (s *Storage) IsShared(path, source string) bool {
|
||||
links, _ := s.GetBySourcePath(path, source)
|
||||
return len(links) > 0
|
||||
}
|
||||
|
||||
// UpdateShares updates all shares that match oldSource and oldPath to point to newSource and newPath.
|
||||
// Handles both exact matches and subdirectories, regardless of trailing slashes.
|
||||
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
|
||||
links, err := s.back.All()
|
||||
links, err := s.All()
|
||||
if err != nil && err != errors.ErrNotExist {
|
||||
logger.Error("failed to list shares", "error", err)
|
||||
return 0, err
|
||||
@@ -247,7 +339,6 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
|
||||
continue
|
||||
}
|
||||
|
||||
oldFullPath := l.Path
|
||||
l.Source = newSource
|
||||
l.Path = newPath
|
||||
|
||||
@@ -256,22 +347,9 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
|
||||
return updated, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
existing.Source = l.Source
|
||||
existing.Path = l.Path
|
||||
} else {
|
||||
s.shareCache[l.Hash] = l
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
|
||||
s.setCacheAfterMove(l, oldSource, oldPath)
|
||||
updated++
|
||||
}
|
||||
|
||||
if updated == 0 {
|
||||
logger.Warning("no matching shares to update for provided source/path")
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
@@ -290,11 +368,7 @@ func (s *Storage) UpdateSharePath(hash, newPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.shareCache[hash]; ok && existing != nil {
|
||||
existing.Path = newPath
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.setCacheAfterMove(link, link.Source, oldPath)
|
||||
|
||||
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
|
||||
return nil
|
||||
@@ -305,9 +379,7 @@ func (s *Storage) Save(l *Link) error {
|
||||
if err := s.back.Save(l); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.shareCache[l.Hash] = l
|
||||
s.mu.Unlock()
|
||||
s.setCache(l)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -316,47 +388,16 @@ func (s *Storage) Delete(hash string) error {
|
||||
if err := s.back.Delete(hash); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.shareCache, hash)
|
||||
s.mu.Unlock()
|
||||
s.deleteFromCache(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush persists the current in-memory state of all shares to the backing store.
|
||||
// Call during graceful shutdown to ensure DB matches memory.
|
||||
// Flush is a no-op: every share write (Save, Delete, UpdateShares, UpdateSharePath) already
|
||||
// updates the database, so the cache and DB stay in sync without flushing.
|
||||
func (s *Storage) Flush() error {
|
||||
s.mu.RLock()
|
||||
links := make([]*Link, 0, len(s.shareCache))
|
||||
for _, l := range s.shareCache {
|
||||
if l != nil {
|
||||
links = append(links, l)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
for _, l := range links {
|
||||
if err := s.back.Save(l); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterExpired removes expired links and deletes them from storage.
|
||||
func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
|
||||
var filtered []*Link
|
||||
for _, link := range links {
|
||||
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
|
||||
_ = s.back.Delete(link.Hash)
|
||||
s.mu.Lock()
|
||||
delete(s.shareCache, link.Hash)
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, link)
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// GetCommonShareByHash retrieves only the CommonShare attributes for a given hash
|
||||
func (s *Storage) GetCommonShareByHash(hash string) (*CommonShare, error) {
|
||||
return s.back.GetCommonShareByHash(hash)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build cgosql
|
||||
// +build cgosql
|
||||
|
||||
package sql
|
||||
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const SqliteDriver = "sqlite3"
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !cgosql
|
||||
// +build !cgosql
|
||||
|
||||
package sql
|
||||
|
||||
import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const SqliteDriver = "sqlite"
|
||||
@@ -0,0 +1,121 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FileLocation represents a file location in the index with metadata
|
||||
// needed for duplicate detection operations.
|
||||
type FileLocation struct {
|
||||
DirPath string
|
||||
FileIdx int
|
||||
Name string
|
||||
NormalizedName string
|
||||
Extension string
|
||||
}
|
||||
|
||||
// CreateDuplicatesTable creates the files table and indexes needed for duplicate detection.
|
||||
// This should be called once after creating a TempDB for duplicate operations.
|
||||
// Indexes are created before data insertion so they're immediately available for queries.
|
||||
// For small datasets (typical for duplicate detection), the performance difference is negligible.
|
||||
func (t *TempDB) CreateDuplicatesTable() error {
|
||||
createTableSQL := `
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dir_path TEXT NOT NULL,
|
||||
file_idx INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL,
|
||||
extension TEXT NOT NULL,
|
||||
UNIQUE(dir_path, file_idx)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_size ON files(size);
|
||||
CREATE INDEX IF NOT EXISTS idx_size_count ON files(size, normalized_name);
|
||||
`
|
||||
|
||||
_, err := t.Exec(createTableSQL)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertFileForDuplicates inserts a file entry into the duplicates table.
|
||||
// This is called during the first pass through the index to stream files into the database.
|
||||
func (t *TempDB) InsertFileForDuplicates(dirPath string, fileIdx int, size int64, name, normalizedName, extension string) error {
|
||||
_, err := t.Exec(
|
||||
"INSERT OR IGNORE INTO files (dir_path, file_idx, size, name, normalized_name, extension) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
dirPath, fileIdx, size, name, normalizedName, extension,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSizeGroupsForDuplicates queries for all size groups that have 2+ files.
|
||||
// Returns sizes in descending order (largest first) as a slice, and a map of size -> count.
|
||||
// This is used to identify potential duplicate groups before detailed comparison.
|
||||
// The SQL query efficiently filters and sorts, avoiding the need to create intermediate maps.
|
||||
func (t *TempDB) GetSizeGroupsForDuplicates(minSize int64) ([]int64, map[int64]int, error) {
|
||||
// Query to get sizes with 2+ files, sorted by size DESC
|
||||
rows, err := t.Query(`
|
||||
SELECT size, COUNT(*) as count
|
||||
FROM files
|
||||
WHERE size >= ?
|
||||
GROUP BY size
|
||||
HAVING COUNT(*) >= 2
|
||||
ORDER BY size DESC
|
||||
`, minSize)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to query size groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sizes []int64
|
||||
sizeCounts := make(map[int64]int)
|
||||
for rows.Next() {
|
||||
var size int64
|
||||
var count int
|
||||
if err := rows.Scan(&size, &count); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to scan row: %w", err)
|
||||
}
|
||||
sizes = append(sizes, size)
|
||||
sizeCounts[size] = count
|
||||
}
|
||||
|
||||
return sizes, sizeCounts, rows.Err()
|
||||
}
|
||||
|
||||
// GetFilesBySizeForDuplicates queries for all files with a specific size.
|
||||
// Used for processing one size group at a time to minimize memory usage.
|
||||
func (t *TempDB) GetFilesBySizeForDuplicates(size int64) ([]FileLocation, error) {
|
||||
rows, err := t.Query(`
|
||||
SELECT dir_path, file_idx, name, normalized_name, extension
|
||||
FROM files
|
||||
WHERE size = ?
|
||||
ORDER BY normalized_name
|
||||
`, size)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query files by size: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var locations []FileLocation
|
||||
for rows.Next() {
|
||||
var loc FileLocation
|
||||
if err := rows.Scan(&loc.DirPath, &loc.FileIdx, &loc.Name, &loc.NormalizedName, &loc.Extension); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan row: %w", err)
|
||||
}
|
||||
locations = append(locations, loc)
|
||||
}
|
||||
|
||||
return locations, rows.Err()
|
||||
}
|
||||
|
||||
// BulkInsertFilesForDuplicates inserts multiple files in a single transaction.
|
||||
// This is more efficient than calling InsertFileForDuplicates multiple times.
|
||||
// The transaction must be started by the caller using BeginTransaction().
|
||||
func BulkInsertFilesForDuplicates(tx *sql.Tx, dirPath string, fileIdx int, size int64, name, normalizedName, extension string) error {
|
||||
_, err := tx.Exec(
|
||||
"INSERT OR IGNORE INTO files (dir_path, file_idx, size, name, normalized_name, extension) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
dirPath, fileIdx, size, name, normalizedName, extension,
|
||||
)
|
||||
return err
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,103 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SearchItem represents a single search result row from the database
|
||||
type SearchItem struct {
|
||||
Source string
|
||||
Path string
|
||||
Name string
|
||||
Size int64
|
||||
ModTime int64
|
||||
Type string
|
||||
IsDir bool
|
||||
HasPreview bool
|
||||
}
|
||||
|
||||
// SearchItems queries the database for items matching the search criteria for a single source.
|
||||
// Returns rows that can be iterated to scan search results.
|
||||
func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.Rows, error) {
|
||||
query := `
|
||||
SELECT path, name, size, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ?
|
||||
`
|
||||
args := []interface{}{source}
|
||||
|
||||
// Apply scope filter
|
||||
if scope != "" {
|
||||
// Use GLOB for prefix matching which is supported by SQLite and efficient
|
||||
query += " AND path GLOB ?"
|
||||
args = append(args, scope+"*")
|
||||
}
|
||||
|
||||
if largest {
|
||||
query += " ORDER BY size DESC"
|
||||
}
|
||||
|
||||
return db.Query(query, args...)
|
||||
}
|
||||
|
||||
// SearchItemsMultiSource queries the database for items matching the search criteria across multiple sources.
|
||||
// Returns rows that can be iterated to scan search results.
|
||||
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool) (*sql.Rows, error) {
|
||||
if len(sources) == 0 {
|
||||
return nil, fmt.Errorf("at least one source is required")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT source, path, name, size, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
`
|
||||
args := []interface{}{}
|
||||
whereClauses := []string{}
|
||||
|
||||
// Filter by sources using IN clause
|
||||
if len(sources) == 1 {
|
||||
whereClauses = append(whereClauses, "source = ?")
|
||||
args = append(args, sources[0])
|
||||
} else {
|
||||
placeholders := make([]string, len(sources))
|
||||
for i, source := range sources {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, source)
|
||||
}
|
||||
whereClauses = append(whereClauses, "source IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
|
||||
// Apply scope filters - need OR conditions for each source+scope combination
|
||||
// Build conditions for each source: if it has a scope, filter by it; otherwise include all paths for that source
|
||||
scopeConditions := []string{}
|
||||
for _, source := range sources {
|
||||
scope, hasScope := sourceScopes[source]
|
||||
if hasScope && scope != "" {
|
||||
// Source has a scope - filter by it
|
||||
scopeConditions = append(scopeConditions, "(source = ? AND path GLOB ?)")
|
||||
args = append(args, source, scope+"*")
|
||||
} else {
|
||||
// Source has no scope or empty scope - include all paths for this source
|
||||
// The source IN clause already filters by source, so we just need to ensure
|
||||
// this source is included in the OR condition (which it already is via source IN)
|
||||
// We add a simple source check to make the OR condition work correctly
|
||||
scopeConditions = append(scopeConditions, "source = ?")
|
||||
args = append(args, source)
|
||||
}
|
||||
}
|
||||
if len(scopeConditions) > 0 {
|
||||
whereClauses = append(whereClauses, "("+strings.Join(scopeConditions, " OR ")+")")
|
||||
}
|
||||
|
||||
if len(whereClauses) > 0 {
|
||||
query += " WHERE " + strings.Join(whereClauses, " AND ")
|
||||
}
|
||||
|
||||
if largest {
|
||||
query += " ORDER BY size DESC"
|
||||
}
|
||||
|
||||
return db.Query(query, args...)
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
// SQLite driver is imported in driver_cgo.go or driver_nocgo.go based on build tags
|
||||
)
|
||||
|
||||
const (
|
||||
// soft heap limit in bytes
|
||||
defaultSoftHeapLimitBytes = 32 * 1024 * 1024 // 32MB
|
||||
// Pager cache target expressed as number of 4KB pages (negative = pages).
|
||||
defaultCacheSizePages = -4096 // ~16MB
|
||||
)
|
||||
|
||||
func init() {
|
||||
if SqliteDriver == "sqlite3" {
|
||||
logger.Debugf("CGO SQLite driver initialized")
|
||||
} else {
|
||||
logger.Debugf("Default SQLite driver initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// TempDB manages a temporary SQLite database for operations that need
|
||||
// to stream large datasets without loading everything into memory.
|
||||
// This can be used by any part of the codebase that needs temporary SQLite storage.
|
||||
type TempDB struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
mu sync.Mutex
|
||||
startTime time.Time
|
||||
config *TempDBConfig
|
||||
BatchSize int // Exported batch size for bulk operations (from config)
|
||||
}
|
||||
|
||||
// TempDBConfig holds configuration options for temporary SQLite databases.
|
||||
type TempDBConfig struct {
|
||||
// BatchSize is the number of items to batch for bulk insert.
|
||||
// Default: 2500
|
||||
BatchSize int
|
||||
|
||||
// CacheSizeKB is the page cache size in KB. Negative values are in pages.
|
||||
// For one-time databases, a smaller cache (e.g., -4096 = ~16MB) is often sufficient.
|
||||
// Default: -4096 (approximately 16MB)
|
||||
CacheSizeKB int
|
||||
|
||||
// MmapSize is the memory-mapped I/O size in bytes. Set to 0 to disable mmap.
|
||||
// For databases that fit in RAM, set this larger than the expected DB size.
|
||||
// Default: 0 (disabled)
|
||||
MmapSize int64
|
||||
|
||||
// Synchronous controls the synchronous mode. OFF is fastest but less safe.
|
||||
// For temporary databases, OFF is acceptable.
|
||||
// Default: OFF
|
||||
Synchronous string
|
||||
|
||||
// TempStore controls where temporary tables and indices are stored.
|
||||
// Valid values: "FILE" (default), "MEMORY", "DEFAULT"
|
||||
// Default: FILE (temporary tables stored on disk)
|
||||
TempStore string
|
||||
|
||||
// JournalMode controls the journal mode. WAL is better for concurrent reads/writes.
|
||||
// DELETE is faster for write-heavy single-writer workloads.
|
||||
// Valid values: "DELETE", "WAL", "TRUNCATE", "PERSIST", "MEMORY", "OFF"
|
||||
// Default: WAL
|
||||
JournalMode string
|
||||
|
||||
// LockingMode controls the locking mode. EXCLUSIVE prevents other processes from accessing.
|
||||
// Valid values: "NORMAL", "EXCLUSIVE"
|
||||
// Default: NORMAL
|
||||
LockingMode string
|
||||
|
||||
// PageSize sets the database page size in bytes. Larger pages improve write performance.
|
||||
// Valid values: 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536
|
||||
// Default: 4096 (SQLite default)
|
||||
PageSize int
|
||||
|
||||
// AutoVacuum controls automatic vacuuming. NONE disables it for better write performance.
|
||||
// Valid values: "NONE", "FULL", "INCREMENTAL"
|
||||
// Default: NONE
|
||||
AutoVacuum string
|
||||
|
||||
// SoftHeapLimitBytes, when >0, sets PRAGMA soft_heap_limit so that SQLite
|
||||
// proactively frees caches before exceeding this many bytes. Zero disables it.
|
||||
SoftHeapLimitBytes int64
|
||||
|
||||
// HardHeapLimitBytes, when >0, sets PRAGMA hard_heap_limit as a hard cap on SQLite's heap.
|
||||
// Operations will fail if they would exceed this limit. Zero disables it.
|
||||
HardHeapLimitBytes int64
|
||||
|
||||
// CacheSpillThreshold sets PRAGMA cache_spill threshold in pages. When cache exceeds both
|
||||
// this threshold and cache_size, SQLite will spill dirty pages to disk.
|
||||
// Set to 0 to use default behavior (spill enabled but no specific threshold).
|
||||
// Default: 500 pages (~2MB) to trigger early spilling and reduce memory usage.
|
||||
CacheSpillThreshold int
|
||||
|
||||
// WalAutocheckpoint sets PRAGMA wal_autocheckpoint in pages. Controls how many pages
|
||||
// can accumulate in the WAL file before an automatic checkpoint.
|
||||
// Default: 1000 pages (~4MB). Larger values reduce checkpoint frequency.
|
||||
WalAutocheckpoint int
|
||||
|
||||
// JournalSizeLimit sets PRAGMA journal_size_limit in bytes. Limits the maximum size
|
||||
// of the WAL journal file. Set to -1 for no limit, 0 for default.
|
||||
// Default: 0 (uses SQLite default)
|
||||
JournalSizeLimit int64
|
||||
|
||||
// If empty, a temp file with random suffix will be created.
|
||||
// The file will not be deleted on Close() if this is set.
|
||||
PersistentFile string
|
||||
}
|
||||
|
||||
// mergeConfig merges the provided config with defaults, returning a new config.
|
||||
// If provided config is nil or empty, returns default config.
|
||||
func mergeConfig(provided *TempDBConfig) *TempDBConfig {
|
||||
defaults := &TempDBConfig{
|
||||
BatchSize: 2500,
|
||||
CacheSizeKB: defaultCacheSizePages, // ~16MB pager cache
|
||||
MmapSize: 0, // Disable mmap to keep RSS predictable
|
||||
Synchronous: "OFF",
|
||||
TempStore: "FILE", // Default to FILE, not MEMORY
|
||||
JournalMode: "WAL", // WAL for better concurrency by default
|
||||
LockingMode: "NORMAL",
|
||||
PageSize: 4096, // SQLite default
|
||||
AutoVacuum: "NONE",
|
||||
// Soft heap limit keeps SQLite from retaining excessive pager cache memory.
|
||||
SoftHeapLimitBytes: defaultSoftHeapLimitBytes,
|
||||
// Hard heap limit provides a hard cap - operations fail if exceeded.
|
||||
HardHeapLimitBytes: 0, // Disabled by default, can be set per-database
|
||||
// Cache spill threshold triggers early spilling to reduce memory usage.
|
||||
CacheSpillThreshold: 500, // ~2MB threshold to trigger early spilling
|
||||
}
|
||||
|
||||
if provided == nil {
|
||||
return defaults
|
||||
}
|
||||
|
||||
merged := *defaults // Copy defaults
|
||||
|
||||
// Override with provided values if they are non-zero/non-empty
|
||||
if provided.BatchSize != 0 {
|
||||
merged.BatchSize = provided.BatchSize
|
||||
}
|
||||
if provided.CacheSizeKB != 0 {
|
||||
merged.CacheSizeKB = provided.CacheSizeKB
|
||||
}
|
||||
if provided.MmapSize != 0 {
|
||||
merged.MmapSize = provided.MmapSize
|
||||
}
|
||||
if provided.Synchronous != "" {
|
||||
merged.Synchronous = provided.Synchronous
|
||||
}
|
||||
if provided.TempStore != "" {
|
||||
merged.TempStore = provided.TempStore
|
||||
}
|
||||
if provided.JournalMode != "" {
|
||||
merged.JournalMode = provided.JournalMode
|
||||
}
|
||||
if provided.LockingMode != "" {
|
||||
merged.LockingMode = provided.LockingMode
|
||||
}
|
||||
if provided.PageSize != 0 {
|
||||
merged.PageSize = provided.PageSize
|
||||
}
|
||||
if provided.AutoVacuum != "" {
|
||||
merged.AutoVacuum = provided.AutoVacuum
|
||||
}
|
||||
if provided.HardHeapLimitBytes != 0 {
|
||||
merged.HardHeapLimitBytes = provided.HardHeapLimitBytes
|
||||
}
|
||||
if provided.CacheSpillThreshold != 0 {
|
||||
merged.CacheSpillThreshold = provided.CacheSpillThreshold
|
||||
}
|
||||
// SoftHeapLimitBytes: use provided value if non-zero (default is 32MB, but provided might be different)
|
||||
if provided.SoftHeapLimitBytes != 0 {
|
||||
merged.SoftHeapLimitBytes = provided.SoftHeapLimitBytes
|
||||
}
|
||||
// PersistentFile: always copy if non-empty (this determines if file is persistent or temp)
|
||||
if provided.PersistentFile != "" {
|
||||
merged.PersistentFile = provided.PersistentFile
|
||||
}
|
||||
|
||||
return &merged
|
||||
}
|
||||
|
||||
// NewTempDB creates a new temporary SQLite database.
|
||||
// The database is created in the cache directory's sql/ subdirectory.
|
||||
// The database will be cleaned up on Close().
|
||||
func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Merge provided config with defaults
|
||||
var providedConfig *TempDBConfig
|
||||
if len(config) > 0 {
|
||||
providedConfig = config[0]
|
||||
}
|
||||
cfg := mergeConfig(providedConfig)
|
||||
|
||||
// Create sql subdirectory in the cache directory
|
||||
dbDir := filepath.Join(settings.Config.Server.CacheDir, "sql")
|
||||
if err := os.MkdirAll(dbDir, fileutils.PermDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create sql directory: %w", err)
|
||||
}
|
||||
|
||||
// Determine database file path
|
||||
var tmpPath string
|
||||
if cfg.PersistentFile != "" {
|
||||
// Use fixed filename for persistent databases
|
||||
tmpPath = filepath.Join(dbDir, cfg.PersistentFile)
|
||||
// File might already exist (persistent), that's fine
|
||||
} else {
|
||||
// Create temporary file with random suffix
|
||||
tmpFile, err := os.CreateTemp(dbDir, fmt.Sprintf("%s.db", id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
tmpPath = tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
}
|
||||
|
||||
// Open SQLite database with basic connection string
|
||||
// Driver is selected at compile time: "sqlite3" (CGO) or "sqlite" (pure Go)
|
||||
db, err := sql.Open(SqliteDriver, tmpPath)
|
||||
if err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
os.Remove(tmpPath)
|
||||
return nil, fmt.Errorf("failed to ping SQLite database: %w", err)
|
||||
}
|
||||
|
||||
// Limit connection pool to 1 for SQLite - it's a file-based database and multiple connections
|
||||
// can cause locking issues. With busy_timeout, SQLite will queue operations automatically.
|
||||
if cfg.LockingMode == "EXCLUSIVE" {
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
}
|
||||
|
||||
// Calculate cache_size in pages (negative value) to ensure consistent unit handling
|
||||
// SQLite's cache_size: positive = KB, negative = pages
|
||||
// When using positive KB values, SQLite seems to have issues with cache_spill being less than cache_size
|
||||
// So we convert to pages (negative) to ensure both cache_size and cache_spill use the same units
|
||||
cacheSizeInPages := cfg.CacheSizeKB
|
||||
if cfg.CacheSizeKB > 0 {
|
||||
// Convert KB to pages: KB / 4 = pages, then negate to indicate pages
|
||||
cacheSizeInPages = -(cfg.CacheSizeKB / 4)
|
||||
}
|
||||
|
||||
basePragmas := []struct {
|
||||
sql string
|
||||
err string
|
||||
}{
|
||||
{fmt.Sprintf("PRAGMA journal_mode = %s;", cfg.JournalMode), "failed to set journal_mode"},
|
||||
{fmt.Sprintf("PRAGMA cache_size = %d;", cacheSizeInPages), "failed to set cache_size"},
|
||||
{fmt.Sprintf("PRAGMA synchronous = %s;", cfg.Synchronous), "failed to set synchronous"},
|
||||
{fmt.Sprintf("PRAGMA temp_store = %s;", cfg.TempStore), "failed to set temp_store"},
|
||||
{fmt.Sprintf("PRAGMA locking_mode = %s;", cfg.LockingMode), "failed to set locking_mode"},
|
||||
{fmt.Sprintf("PRAGMA auto_vacuum = %s;", cfg.AutoVacuum), "failed to set auto_vacuum"},
|
||||
}
|
||||
|
||||
// Memory management PRAGMAs - set early to constrain memory usage
|
||||
if cfg.SoftHeapLimitBytes > 0 {
|
||||
basePragmas = append([]struct {
|
||||
sql string
|
||||
err string
|
||||
}{{fmt.Sprintf("PRAGMA soft_heap_limit = %d;", cfg.SoftHeapLimitBytes), "failed to set soft_heap_limit"}}, basePragmas...)
|
||||
}
|
||||
|
||||
if cfg.HardHeapLimitBytes > 0 {
|
||||
basePragmas = append([]struct {
|
||||
sql string
|
||||
err string
|
||||
}{{fmt.Sprintf("PRAGMA hard_heap_limit = %d;", cfg.HardHeapLimitBytes), "failed to set hard_heap_limit"}}, basePragmas...)
|
||||
}
|
||||
|
||||
// Cache spill will be set later, after all other pragmas, to avoid interaction issues
|
||||
|
||||
// Page size must be set before any tables are created
|
||||
if cfg.PageSize > 0 {
|
||||
basePragmas = append([]struct {
|
||||
sql string
|
||||
err string
|
||||
}{{fmt.Sprintf("PRAGMA page_size = %d;", cfg.PageSize), "failed to set page_size"}}, basePragmas...)
|
||||
}
|
||||
|
||||
// Always explicitly set mmap_size to ensure it's disabled (0) or enabled as configured
|
||||
// Setting it to 0 explicitly disables memory-mapped I/O, which helps reduce OS page cache usage
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{fmt.Sprintf("PRAGMA mmap_size = %d;", cfg.MmapSize), "failed to set mmap_size"})
|
||||
|
||||
// WAL autocheckpoint - controls checkpoint frequency in WAL mode
|
||||
if cfg.WalAutocheckpoint > 0 {
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{fmt.Sprintf("PRAGMA wal_autocheckpoint = %d;", cfg.WalAutocheckpoint), "failed to set wal_autocheckpoint"})
|
||||
}
|
||||
|
||||
// Journal size limit - limits WAL file growth
|
||||
if cfg.JournalSizeLimit > 0 {
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{fmt.Sprintf("PRAGMA journal_size_limit = %d;", cfg.JournalSizeLimit), "failed to set journal_size_limit"})
|
||||
} else if cfg.JournalSizeLimit == -1 {
|
||||
// -1 means no limit
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{"PRAGMA journal_size_limit = -1;", "failed to set journal_size_limit"})
|
||||
}
|
||||
|
||||
for _, pragma := range basePragmas {
|
||||
if _, err := db.Exec(pragma.sql); err != nil {
|
||||
db.Close()
|
||||
os.Remove(tmpPath)
|
||||
return nil, fmt.Errorf("%s: %w", pragma.err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set cache_spill LAST, after all other pragmas are configured
|
||||
// SQLite may adjust cache_spill based on internal constraints or formulas
|
||||
if cfg.CacheSpillThreshold > 0 {
|
||||
// Try setting cache_spill multiple times with different approaches
|
||||
// Approach 1: Standard syntax
|
||||
if _, err := db.Exec(fmt.Sprintf("PRAGMA cache_spill = %d;", cfg.CacheSpillThreshold)); err != nil {
|
||||
logger.Warningf("[SQLITE_CONFIG] Failed to set cache_spill with standard syntax: %v", err)
|
||||
}
|
||||
|
||||
// Immediately verify what value was actually set
|
||||
var actualValue int64
|
||||
if err := db.QueryRow("PRAGMA cache_spill").Scan(&actualValue); err == nil {
|
||||
if actualValue != int64(cfg.CacheSpillThreshold) {
|
||||
// SQLite adjusted our value - calculate what percentage it used
|
||||
percentage := float64(actualValue) / float64(-cacheSizeInPages) * 100.0
|
||||
logger.Warningf("[SQLITE_CONFIG] SQLite adjusted cache_spill from %d to %d pages (%.1f%% of cache_size). This may be due to internal constraints.",
|
||||
cfg.CacheSpillThreshold, actualValue, percentage)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Enable cache_spill with default behavior
|
||||
if _, err := db.Exec("PRAGMA cache_spill = 1;"); err != nil {
|
||||
logger.Warningf("[SQLITE_CONFIG] Failed to enable cache_spill: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify PRAGMA settings were applied correctly
|
||||
if err := verifyPragmaSettings(db, cfg); err != nil {
|
||||
logger.Warningf("Failed to verify PRAGMA settings: %v", err)
|
||||
}
|
||||
|
||||
return &TempDB{
|
||||
db: db,
|
||||
path: tmpPath,
|
||||
startTime: startTime,
|
||||
config: cfg,
|
||||
BatchSize: cfg.BatchSize, // Export batch size for external packages
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DB returns the underlying *sql.DB connection.
|
||||
// This allows callers to execute custom SQL queries if needed.
|
||||
func (t *TempDB) DB() *sql.DB {
|
||||
return t.db
|
||||
}
|
||||
|
||||
// BeginTransaction starts a transaction for bulk operations.
|
||||
// The caller must call Commit() or Rollback() on the returned transaction.
|
||||
// IMPORTANT: The mutex is acquired here and MUST be released by calling
|
||||
// t.mu.Unlock() after commit/rollback to ensure exclusive transaction access.
|
||||
func (t *TempDB) BeginTransaction() (*sql.Tx, error) {
|
||||
t.mu.Lock()
|
||||
// NOTE: Mutex is NOT unlocked here! Caller must unlock after commit/rollback
|
||||
return t.db.Begin()
|
||||
}
|
||||
|
||||
// Exec executes a SQL statement that doesn't return rows.
|
||||
// This is a convenience method that handles locking.
|
||||
func (t *TempDB) Exec(query string, args ...interface{}) (sql.Result, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.db.Exec(query, args...)
|
||||
}
|
||||
|
||||
// Query executes a query that returns rows.
|
||||
// This is a convenience method that handles locking.
|
||||
func (t *TempDB) Query(query string, args ...interface{}) (*sql.Rows, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.db.Query(query, args...)
|
||||
}
|
||||
|
||||
// QueryRow executes a query that is expected to return at most one row.
|
||||
// This is a convenience method that handles locking.
|
||||
func (t *TempDB) QueryRow(query string, args ...interface{}) *sql.Row {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.db.QueryRow(query, args...)
|
||||
}
|
||||
|
||||
// Close closes the database connection and removes the temporary file if it is not persistent.
|
||||
func (t *TempDB) Close() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.db != nil {
|
||||
if err := t.db.Close(); err != nil {
|
||||
// Only remove file if it's not persistent
|
||||
if t.config != nil && t.config.PersistentFile == "" {
|
||||
os.Remove(t.path)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove file if it's not persistent
|
||||
if t.config != nil && t.config.PersistentFile == "" {
|
||||
return os.Remove(t.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyPragmaSettings reads back PRAGMA values to verify they were applied correctly
|
||||
func verifyPragmaSettings(db *sql.DB, cfg *TempDBConfig) error {
|
||||
var cacheSize, mmapSize, softHeapLimit, hardHeapLimit, cacheSpill int64
|
||||
var journalMode, synchronous, tempStore, lockingMode, autoVacuum string
|
||||
|
||||
// Read back PRAGMA values
|
||||
if err := db.QueryRow("PRAGMA cache_size").Scan(&cacheSize); err != nil {
|
||||
return fmt.Errorf("failed to read cache_size: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA mmap_size").Scan(&mmapSize); err != nil {
|
||||
return fmt.Errorf("failed to read mmap_size: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA soft_heap_limit").Scan(&softHeapLimit); err != nil {
|
||||
return fmt.Errorf("failed to read soft_heap_limit: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA hard_heap_limit").Scan(&hardHeapLimit); err != nil {
|
||||
return fmt.Errorf("failed to read hard_heap_limit: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA cache_spill").Scan(&cacheSpill); err != nil {
|
||||
return fmt.Errorf("failed to read cache_spill: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
||||
return fmt.Errorf("failed to read journal_mode: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil {
|
||||
return fmt.Errorf("failed to read synchronous: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA temp_store").Scan(&tempStore); err != nil {
|
||||
return fmt.Errorf("failed to read temp_store: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA locking_mode").Scan(&lockingMode); err != nil {
|
||||
return fmt.Errorf("failed to read locking_mode: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
return fmt.Errorf("failed to read auto_vacuum: %w", err)
|
||||
}
|
||||
|
||||
// Convert cache_size from pages to KB (negative = pages, positive = KB)
|
||||
cacheSizeKB := cacheSize
|
||||
if cacheSize < 0 {
|
||||
cacheSizeKB = -cacheSize * 4 // pages * 4KB per page
|
||||
}
|
||||
|
||||
// Expected cache_spill value in pages (cache_spill is always specified and returned in pages)
|
||||
expectedCacheSpill := int64(cfg.CacheSpillThreshold)
|
||||
|
||||
// Convert numeric PRAGMA values to human-readable strings
|
||||
tempStoreMap := map[string]string{"0": "DEFAULT", "1": "FILE", "2": "MEMORY"}
|
||||
synchronousMap := map[string]string{"0": "OFF", "1": "NORMAL", "2": "FULL", "3": "EXTRA"}
|
||||
autoVacuumMap := map[string]string{"0": "NONE", "1": "FULL", "2": "INCREMENTAL"}
|
||||
|
||||
tempStoreStr := tempStoreMap[tempStore]
|
||||
if tempStoreStr == "" {
|
||||
tempStoreStr = tempStore
|
||||
}
|
||||
synchronousStr := synchronousMap[synchronous]
|
||||
if synchronousStr == "" {
|
||||
synchronousStr = synchronous
|
||||
}
|
||||
autoVacuumStr := autoVacuumMap[autoVacuum]
|
||||
if autoVacuumStr == "" {
|
||||
autoVacuumStr = autoVacuum
|
||||
}
|
||||
|
||||
// Log all settings
|
||||
logger.Debugf("[SQLITE_CONFIG] Database PRAGMA settings verified:")
|
||||
logger.Debugf(" cache_size : %d KB", cacheSizeKB)
|
||||
logger.Debugf(" mmap_size : %d bytes", mmapSize)
|
||||
logger.Debugf(" soft_heap_limit : %d bytes", softHeapLimit)
|
||||
logger.Debugf(" hard_heap_limit : %d bytes", hardHeapLimit)
|
||||
logger.Debugf(" cache_spill : %d pages", cacheSpill)
|
||||
logger.Debugf(" journal_mode : %s", journalMode)
|
||||
logger.Debugf(" synchronous : %s", synchronousStr)
|
||||
logger.Debugf(" temp_store : %s", tempStoreStr)
|
||||
logger.Debugf(" locking_mode : %s", lockingMode)
|
||||
logger.Debugf(" auto_vacuum : %s", autoVacuumStr)
|
||||
|
||||
// Verify critical settings match
|
||||
if cfg.CacheSizeKB != 0 {
|
||||
expectedCache := int64(cfg.CacheSizeKB)
|
||||
if expectedCache < 0 {
|
||||
expectedCache = -expectedCache * 4 // Convert pages to KB
|
||||
}
|
||||
if cacheSizeKB != expectedCache {
|
||||
logger.Warningf("[SQLITE_CONFIG] cache_size mismatch: got %d KB, expected %d KB", cacheSizeKB, expectedCache)
|
||||
}
|
||||
}
|
||||
if cfg.MmapSize > 0 && mmapSize != cfg.MmapSize {
|
||||
logger.Warningf("[SQLITE_CONFIG] mmap_size mismatch: got %d bytes, expected %d bytes", mmapSize, cfg.MmapSize)
|
||||
}
|
||||
if cfg.SoftHeapLimitBytes > 0 && softHeapLimit != cfg.SoftHeapLimitBytes {
|
||||
logger.Warningf("[SQLITE_CONFIG] soft_heap_limit mismatch: got %d bytes, expected %d bytes", softHeapLimit, cfg.SoftHeapLimitBytes)
|
||||
}
|
||||
if cfg.HardHeapLimitBytes > 0 && hardHeapLimit != cfg.HardHeapLimitBytes {
|
||||
logger.Warningf("[SQLITE_CONFIG] hard_heap_limit mismatch: got %d bytes, expected %d bytes", hardHeapLimit, cfg.HardHeapLimitBytes)
|
||||
}
|
||||
// Verify cache_spill matches expected value (accounting for unit conversion)
|
||||
if cfg.CacheSpillThreshold > 0 && cacheSpill != expectedCacheSpill {
|
||||
logger.Warningf("[SQLITE_CONFIG] cache_spill mismatch: got %d pages, expected %d pages (from threshold %d pages)", cacheSpill, expectedCacheSpill, cfg.CacheSpillThreshold)
|
||||
// If cache_spill equals cache_size in pages, it means SQLite ignored our setting
|
||||
var cacheSizePages int64
|
||||
if cfg.CacheSizeKB > 0 {
|
||||
cacheSizePages = int64(cfg.CacheSizeKB) / 4 // KB to pages
|
||||
} else {
|
||||
cacheSizePages = -int64(cfg.CacheSizeKB) // Already in pages (negative)
|
||||
}
|
||||
if cacheSpill == cacheSizePages {
|
||||
logger.Warningf("[SQLITE_CONFIG] cache_spill equals cache_size (%d pages), indicating SQLite may have ignored our setting", cacheSizePages)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify string-based PRAGMA settings match (using converted values)
|
||||
if synchronousStr != cfg.Synchronous {
|
||||
logger.Warningf("[SQLITE_CONFIG] synchronous mismatch: got %s, expected %s", synchronousStr, cfg.Synchronous)
|
||||
}
|
||||
if tempStoreStr != cfg.TempStore {
|
||||
logger.Warningf("[SQLITE_CONFIG] temp_store mismatch: got %s, expected %s", tempStoreStr, cfg.TempStore)
|
||||
}
|
||||
if autoVacuumStr != cfg.AutoVacuum {
|
||||
logger.Warningf("[SQLITE_CONFIG] auto_vacuum mismatch: got %s, expected %s", autoVacuumStr, cfg.AutoVacuum)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path returns the path to the temporary database file.
|
||||
// This is useful for debugging or if you need to inspect the database.
|
||||
func (t *TempDB) Path() string {
|
||||
return t.path
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
@@ -18,6 +19,7 @@ type BoltStore struct {
|
||||
Auth *auth.Storage
|
||||
Settings *settings.Storage
|
||||
Access *access.Storage
|
||||
Indexing *dbindex.Storage
|
||||
}
|
||||
|
||||
// NewStorage creates a storage.Storage based on Bolt DB.
|
||||
@@ -33,5 +35,6 @@ func NewStorage(db *storm.DB) (*BoltStore, error) {
|
||||
Auth: authStore,
|
||||
Settings: settings.NewStorage(settingsBackend{db: db}),
|
||||
Access: access.NewStorage(db, userStore),
|
||||
Indexing: dbindex.NewStorage(indexingBackend{db: db}),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
storm "github.com/asdine/storm/v3"
|
||||
"github.com/asdine/storm/v3/q"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
|
||||
)
|
||||
|
||||
type indexingBackend struct {
|
||||
db *storm.DB
|
||||
}
|
||||
|
||||
func (s indexingBackend) All() ([]*dbindex.IndexInfo, error) {
|
||||
var v []*dbindex.IndexInfo
|
||||
err := s.db.All(&v)
|
||||
if err == storm.ErrNotFound {
|
||||
return v, errors.ErrNotExist
|
||||
}
|
||||
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (s indexingBackend) GetByPath(path string) (*dbindex.IndexInfo, error) {
|
||||
var v dbindex.IndexInfo
|
||||
err := s.db.One("Path", path, &v)
|
||||
if err == storm.ErrNotFound {
|
||||
return nil, errors.ErrNotExist
|
||||
}
|
||||
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (s indexingBackend) GetBySource(source string) ([]*dbindex.IndexInfo, error) {
|
||||
var v []*dbindex.IndexInfo
|
||||
err := s.db.Select(q.Eq("Source", source)).Find(&v)
|
||||
if err == storm.ErrNotFound {
|
||||
return nil, errors.ErrNotExist
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (s indexingBackend) Save(info *dbindex.IndexInfo) error {
|
||||
return s.db.Save(info)
|
||||
}
|
||||
|
||||
func (s indexingBackend) Delete(path string) error {
|
||||
err := s.db.DeleteStruct(&dbindex.IndexInfo{Path: path})
|
||||
if err == storm.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
storm "github.com/asdine/storm/v3"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
@@ -77,7 +76,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
|
||||
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
|
||||
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
|
||||
if passwordUser && enforcedOtp && !user.OtpEnabled {
|
||||
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
|
||||
return errors.ErrNoTotpConfigured
|
||||
}
|
||||
if user.LoginMethod == "" {
|
||||
@@ -110,19 +109,19 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
|
||||
// converting scopes to map of paths intead of names (names can change)
|
||||
if slices.Contains(fields, "Scopes") {
|
||||
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
|
||||
adjustedScopes, err := user.GetBackendScopes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.Scopes = adjustedScopes
|
||||
err = files.MakeUserDirs(user, true)
|
||||
if err != nil {
|
||||
return err
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
}
|
||||
// converting scopes to map of paths intead of names (names can change)
|
||||
if slices.Contains(fields, "SidebarLinks") {
|
||||
adjustedLinks, err := settings.ConvertToBackendSidebarLinks(user.SidebarLinks)
|
||||
adjustedLinks, err := user.GetBackendSidebarLinks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -158,12 +157,6 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
}
|
||||
}
|
||||
|
||||
// last revoke api keys if needed.
|
||||
if existingUser.Permissions.Api && !user.Permissions.Api && slices.Contains(fields, "Permissions") {
|
||||
for _, key := range existingUser.ApiKeys {
|
||||
auth.RevokeAPIKey(key.Key) // add to blacklist
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -188,7 +181,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
|
||||
}
|
||||
|
||||
// converting scopes to map of paths intead of names (names can change)
|
||||
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
|
||||
adjustedScopes, err := user.GetBackendScopes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -257,7 +250,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
field := t.Field(i)
|
||||
// which=all can't update password
|
||||
switch strings.ToLower(field.Name) {
|
||||
case "id", "username", "password", "apikeys", "totpsecret", "totpnonce":
|
||||
case "id", "username", "password", "tokens", "totpsecret", "totpnonce":
|
||||
// Skip these fields
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -5,9 +5,35 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
func init() {
|
||||
users.BcryptCost = 4 // bcrypt.MinCost for faster tests
|
||||
|
||||
// Initialize test source configuration
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
"/tmp/test": {
|
||||
Path: "/tmp/test",
|
||||
Name: "test",
|
||||
Config: settings.SourceConfig{
|
||||
DefaultUserScope: "/",
|
||||
},
|
||||
},
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"test": settings.Config.Server.SourceMap["/tmp/test"],
|
||||
}
|
||||
settings.Config.Server.Sources = []*settings.Source{
|
||||
settings.Config.Server.SourceMap["/tmp/test"],
|
||||
}
|
||||
// Don't set default scopes for tests - let tests explicitly set scopes when needed
|
||||
settings.Config.UserDefaults.DefaultScopes = []users.SourceScope{}
|
||||
// Initialize user package resolvers
|
||||
settings.InitializeUserResolvers()
|
||||
}
|
||||
|
||||
func createTestUsersBackend(t *testing.T) users.StorageBackend {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
@@ -36,6 +36,8 @@ func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
|
||||
// Load access rules from DB on startup
|
||||
// ignoring errors because
|
||||
_ = store.Access.LoadFromDB()
|
||||
// Load share cache from DB so shareByHash/shareByPath are populated at startup
|
||||
store.Share.LoadShareCacheFromDB()
|
||||
userStore = store.Users
|
||||
err = bolt.Save(db, "version", 2)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetSourceNames returns all source names the user has access to (assumes backend-style scopes)
|
||||
func (u *User) GetSourceNames() []string {
|
||||
if sourceConfig == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
sources := []string{}
|
||||
allSources := sourceConfig.GetAllSources()
|
||||
|
||||
// Preserves order of sources
|
||||
for _, source := range allSources {
|
||||
_, err := u.GetScopeForSourcePath(source.Path)
|
||||
if err == nil {
|
||||
sources = append(sources, source.Name)
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
// GetBackendScopes converts the user's scopes from frontend-style to backend-style
|
||||
func (u *User) GetBackendScopes() ([]SourceScope, error) {
|
||||
// Only convert scopes if they are not empty
|
||||
// Empty scopes during update should remain empty (not filled with defaults)
|
||||
if len(u.Scopes) == 0 {
|
||||
return []SourceScope{}, nil
|
||||
}
|
||||
if sourceConfig == nil {
|
||||
return nil, fmt.Errorf("source config not initialized")
|
||||
}
|
||||
|
||||
newScopes := []SourceScope{}
|
||||
for _, scope := range u.Scopes {
|
||||
// First check if its already a path name and keep it
|
||||
source, ok := sourceConfig.GetSourceByPath(scope.Name)
|
||||
if ok {
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.DefaultUserScope
|
||||
}
|
||||
scope.Scope = normalizeScope(scope.Scope)
|
||||
newScopes = append(newScopes, SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if its the name of a source and convert it to a path
|
||||
source, ok = sourceConfig.GetSourceByName(scope.Name)
|
||||
if !ok {
|
||||
// source might no longer be configured
|
||||
continue
|
||||
}
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.DefaultUserScope
|
||||
}
|
||||
scope.Scope = normalizeScope(scope.Scope)
|
||||
newScopes = append(newScopes, SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
return newScopes, nil
|
||||
}
|
||||
|
||||
// GetFrontendScopes converts the user's scopes from backend-style to frontend-style
|
||||
// Backend scopes use source paths, frontend scopes use source names
|
||||
func (u *User) GetFrontendScopes() []SourceScope {
|
||||
if sourceConfig == nil {
|
||||
return []SourceScope{}
|
||||
}
|
||||
|
||||
newScopes := []SourceScope{}
|
||||
for _, scope := range u.Scopes {
|
||||
if source, ok := sourceConfig.GetSourceByPath(scope.Name); ok {
|
||||
// Replace scope.Name with source.Name while keeping the same Scope value
|
||||
newScopes = append(newScopes, SourceScope{
|
||||
Name: source.Name,
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
return newScopes
|
||||
}
|
||||
|
||||
// GetBackendSidebarLinks converts the user's sidebar links from frontend-style to backend-style
|
||||
// Validates that sources exist and converts source names to paths
|
||||
func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
|
||||
if sourceConfig == nil {
|
||||
return nil, fmt.Errorf("source config not initialized")
|
||||
}
|
||||
|
||||
newLinks := []SidebarLink{}
|
||||
for _, link := range u.SidebarLinks {
|
||||
// For source links, validate that the source exists using SourceName
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
if link.SourceName == "" {
|
||||
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
|
||||
}
|
||||
// Validate source exists
|
||||
sourceInfo, ok := sourceConfig.GetSourceByName(link.SourceName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
|
||||
}
|
||||
link.SourceName = sourceInfo.Path // if name changes keep link alive
|
||||
}
|
||||
// Store the link as-is with all fields preserved
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks, nil
|
||||
}
|
||||
|
||||
// GetFrontendSidebarLinks converts the user's sidebar links from backend-style to frontend-style
|
||||
// Converts source paths to source names for the frontend
|
||||
func (u *User) GetFrontendSidebarLinks() []SidebarLink {
|
||||
if sourceConfig == nil {
|
||||
return []SidebarLink{}
|
||||
}
|
||||
|
||||
newLinks := []SidebarLink{}
|
||||
for _, link := range u.SidebarLinks {
|
||||
// For source links, validate that the source still exists
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
if link.SourceName == "" {
|
||||
continue
|
||||
}
|
||||
// Check if source exists
|
||||
sourceInfo, ok := sourceConfig.GetSourceByPath(link.SourceName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
link.SourceName = sourceInfo.Name
|
||||
}
|
||||
// For share links, just pass through (shares are validated separately)
|
||||
// For all other links, pass through as-is
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks
|
||||
}
|
||||
|
||||
// normalizeScope ensures scope starts with / and doesn't end with / (except for root)
|
||||
func normalizeScope(scope string) string {
|
||||
if !strings.HasPrefix(scope, "/") {
|
||||
scope = "/" + scope
|
||||
}
|
||||
if scope != "/" && strings.HasSuffix(scope, "/") {
|
||||
scope = strings.TrimSuffix(scope, "/")
|
||||
}
|
||||
return scope
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var BcryptCost = bcrypt.DefaultCost
|
||||
|
||||
// HashPwd hashes a password.
|
||||
func HashPwd(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ type Store interface {
|
||||
Save(user *User, changePass bool, disableScopeChange bool) error
|
||||
Delete(id interface{}) error
|
||||
LastUpdate(id uint) int64
|
||||
AddApiKey(username uint, name string, key AuthToken) error
|
||||
DeleteApiKey(username uint, name string) error
|
||||
AddApiToken(userID uint, name string, tokenString string, metadata AuthToken) error
|
||||
DeleteApiToken(userID uint, name string) error
|
||||
}
|
||||
|
||||
// crudBackend implements crud.CrudBackend[User] for users storage.
|
||||
@@ -114,17 +114,18 @@ func (s *Storage) Update(user *User, adminIActor bool, fields ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
|
||||
func (s *Storage) AddApiToken(userID uint, name string, tokenString string, metadata AuthToken) error {
|
||||
user, err := s.Get(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Initialize the ApiKeys map if it is nil
|
||||
if user.ApiKeys == nil {
|
||||
user.ApiKeys = make(map[string]AuthToken)
|
||||
// Initialize the TokenHashes map if it is nil
|
||||
if user.Tokens == nil {
|
||||
user.Tokens = make(map[string]AuthToken)
|
||||
}
|
||||
user.ApiKeys[name] = key
|
||||
err = s.Update(user, true, "ApiKeys")
|
||||
metadata.Token = tokenString
|
||||
user.Tokens[name] = metadata
|
||||
err = s.Update(user, true, "Tokens")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -132,17 +133,17 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteApiKey(userID uint, name string) error {
|
||||
func (s *Storage) DeleteApiToken(userID uint, name string) error {
|
||||
user, err := s.Get(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Initialize the ApiKeys map if it is nil
|
||||
if user.ApiKeys == nil {
|
||||
user.ApiKeys = make(map[string]AuthToken)
|
||||
// Initialize the Tokens map if it is nil
|
||||
if user.Tokens == nil {
|
||||
user.Tokens = make(map[string]AuthToken)
|
||||
}
|
||||
delete(user.ApiKeys, name)
|
||||
err = s.Update(user, true, "ApiKeys")
|
||||
delete(user.Tokens, name)
|
||||
err = s.Update(user, true, "Tokens")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+108
-17
@@ -1,6 +1,7 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
@@ -14,16 +15,24 @@ const (
|
||||
LoginMethodPassword LoginMethod = "password"
|
||||
LoginMethodProxy LoginMethod = "proxy"
|
||||
LoginMethodOidc LoginMethod = "oidc"
|
||||
LoginMethodLdap LoginMethod = "ldap"
|
||||
LoginMethodJwt LoginMethod = "jwt"
|
||||
)
|
||||
|
||||
type AuthToken struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Created int64 `json:"createdAt"`
|
||||
Expires int64 `json:"expiresAt"`
|
||||
BelongsTo uint `json:"belongsTo"`
|
||||
Permissions Permissions `json:"Permissions"`
|
||||
jwt.RegisteredClaims `json:"-"`
|
||||
MinimalAuthToken
|
||||
Key string `json:"key,omitempty"` // for backward compatibility
|
||||
Token string `json:"token,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
BelongsTo uint `json:"belongsTo,omitempty"`
|
||||
IssuedAt int64 `json:"issuedAt,omitempty"`
|
||||
ExpiresAt int64 `json:"expiresAt,omitempty"`
|
||||
Permissions Permissions `json:"Permissions,omitempty"`
|
||||
}
|
||||
|
||||
// MinimalAuthToken is used for tokens that only include JWT standard claims
|
||||
type MinimalAuthToken struct {
|
||||
jwt.RegisteredClaims `swaggerignore:"true"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
@@ -45,7 +54,6 @@ type Sorting struct {
|
||||
|
||||
type Preview struct {
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // disable the hide sidebar preview for previews and editors
|
||||
HighQuality bool `json:"highQuality"` // generate high quality thumbnail preview images
|
||||
Image bool `json:"image"` // show thumbnail preview image for image files
|
||||
Video bool `json:"video"` // show thumbnail preview image for video files
|
||||
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
@@ -66,15 +74,16 @@ type User struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
Permissions Permissions `json:"permissions"`
|
||||
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
|
||||
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
|
||||
Tokens map[string]AuthToken `json:"tokens,omitempty"`
|
||||
TOTPSecret string `json:"totpSecret,omitempty"`
|
||||
TOTPNonce string `json:"totpNonce,omitempty"`
|
||||
LoginMethod LoginMethod `json:"loginMethod"`
|
||||
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
|
||||
Version int `json:"version"`
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
// legacy for migration purposes... og filebrowser has perm attribute
|
||||
Perm Permissions `json:"perm,omitzero"`
|
||||
Version int `json:"version"`
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
|
||||
}
|
||||
|
||||
type SourceScope struct {
|
||||
@@ -112,20 +121,20 @@ type NonAdminEditable struct {
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order instead
|
||||
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
MaxConcurrent int `json:"maxConcurrentUpload"`
|
||||
ChunkSize int `json:"uploadChunkSizeMb"`
|
||||
ClearAll bool `json:"clearAll"`
|
||||
MaxConcurrent int `json:"maxConcurrentUpload"`
|
||||
UploadChunkSize int `json:"uploadChunkSizeMb"`
|
||||
ClearAll bool `json:"clearAll"`
|
||||
DownloadChunkSize int `json:"downloadChunkSizeMb"`
|
||||
}
|
||||
|
||||
// SidebarLink represents a customizable link in the sidebar.
|
||||
type SidebarLink struct {
|
||||
Name string `json:"name"` // Display name of the link
|
||||
Category string `json:"category"` // Category type: "source", "share", "tool", "custom", etc.
|
||||
Category string `json:"category"` // Category type: "source", "source-link", "share", "tool", "custom", etc.
|
||||
Target string `json:"target"` // Target path/URL for the link (relative for source/share)
|
||||
Icon string `json:"icon"` // Material icon name
|
||||
SourceName string `json:"sourceName,omitempty"` // Source identifier for source-type links
|
||||
@@ -137,3 +146,85 @@ func CleanUsername(s string) string {
|
||||
s = strings.Replace(s, "..", "", -1)
|
||||
return s
|
||||
}
|
||||
|
||||
// SourceNameResolver is a function type that resolves source names to source paths
|
||||
// This avoids circular dependencies by allowing the settings package to provide the resolver
|
||||
type SourceNameResolver func(sourceName string) (sourcePath string, err error)
|
||||
|
||||
// SourceInfo represents basic information about a source needed for user operations
|
||||
type SourceInfo struct {
|
||||
Path string
|
||||
Name string
|
||||
DefaultUserScope string
|
||||
}
|
||||
|
||||
// SourceConfigProvider provides access to source configuration
|
||||
type SourceConfigProvider struct {
|
||||
GetSourceByPath func(path string) (SourceInfo, bool)
|
||||
GetSourceByName func(name string) (SourceInfo, bool)
|
||||
GetAllSources func() []SourceInfo
|
||||
GetDefaultScopes func() []SourceScope
|
||||
}
|
||||
|
||||
// Global variables set by the settings package
|
||||
var (
|
||||
sourceNameResolver SourceNameResolver
|
||||
sourceConfig *SourceConfigProvider
|
||||
)
|
||||
|
||||
// SetSourceNameResolver sets the global source name resolver
|
||||
// This should be called once during initialization by the settings package
|
||||
func SetSourceNameResolver(resolver SourceNameResolver) {
|
||||
sourceNameResolver = resolver
|
||||
}
|
||||
|
||||
// SetSourceConfig sets the global source configuration provider
|
||||
// This should be called once during initialization by the settings package
|
||||
func SetSourceConfig(config *SourceConfigProvider) {
|
||||
sourceConfig = config
|
||||
}
|
||||
|
||||
// GetScopeForSourcePath returns the scope for a given source path (backend-style)
|
||||
// This method works with backend-style scopes where Name is the source path
|
||||
func (u *User) GetScopeForSourcePath(sourcePath string) (string, error) {
|
||||
for _, scope := range u.Scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return scope.Scope, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("scope not found for source %v", sourcePath)
|
||||
}
|
||||
|
||||
// GetScopeForSourceName returns the scope for a given source name (frontend-style)
|
||||
// Uses the global SourceNameResolver to convert source names to paths
|
||||
func (u *User) GetScopeForSourceName(sourceName string) (string, error) {
|
||||
if sourceNameResolver == nil {
|
||||
return "", fmt.Errorf("source name resolver not initialized")
|
||||
}
|
||||
|
||||
sourcePath, err := sourceNameResolver(sourceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return u.GetScopeForSourcePath(sourcePath)
|
||||
}
|
||||
|
||||
// HasSourceByPath checks if the user has access to a given source path
|
||||
func (u *User) HasSourceByPath(sourcePath string) bool {
|
||||
for _, scope := range u.Scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSourcePaths returns all source paths the user has access to
|
||||
func (u *User) GetSourcePaths() []string {
|
||||
paths := make([]string, 0, len(u.Scopes))
|
||||
for _, scope := range u.Scopes {
|
||||
paths = append(paths, scope.Name)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -109,13 +109,20 @@ func SendToUsers(eventType, message string, users []string) {
|
||||
}
|
||||
|
||||
func SendSourceUpdate(source string, message string) {
|
||||
sourceUpdateChan <- sourceEvent{
|
||||
event := sourceEvent{
|
||||
source: source,
|
||||
event: EventMessage{
|
||||
EventType: "sourceUpdate",
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
select {
|
||||
case sourceUpdateChan <- event:
|
||||
// Event sent successfully
|
||||
default:
|
||||
// Channel is full, log warning but don't block
|
||||
// This shouldn't happen under normal circumstances
|
||||
}
|
||||
}
|
||||
|
||||
func DebouncedBroadcast(eventType, message string) {
|
||||
@@ -129,15 +136,27 @@ func handleSourceUpdates() {
|
||||
for update := range sourceUpdateChan {
|
||||
sourceClientsMu.RLock()
|
||||
clients := sourceClients[update.source]
|
||||
clientCount := len(clients)
|
||||
sourceClientsMu.RUnlock()
|
||||
|
||||
if clientCount == 0 {
|
||||
// No clients registered for this source - this is normal if no one is connected
|
||||
continue
|
||||
}
|
||||
|
||||
sentCount := 0
|
||||
for ch := range clients {
|
||||
select {
|
||||
case ch <- update.event:
|
||||
sentCount++
|
||||
default:
|
||||
// Optional: log dropped message
|
||||
// Channel full, message dropped
|
||||
}
|
||||
}
|
||||
// Log if we have clients but couldn't send to all
|
||||
//if sentCount < clientCount {
|
||||
// // Some messages were dropped due to full channels
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ package ffmpeg
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
var (
|
||||
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
|
||||
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
|
||||
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
|
||||
MediaCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
|
||||
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
|
||||
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package ffmpeg
|
||||
|
||||
import "github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
|
||||
func CanConvertImage(ext string) bool {
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := settings.Config.Integrations.Media.Convert.ImagePreview[settings.ImagePreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func CanConvertVideo(ext string) bool {
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := settings.Config.Integrations.Media.Convert.VideoPreview[settings.VideoPreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func MediaEnabled() bool {
|
||||
return settings.Env.FFmpegPath != "" && settings.Env.FFprobePath != ""
|
||||
}
|
||||
+14
-2
@@ -11,12 +11,16 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
|
||||
// GetImageOrientation extracts the EXIF orientation from an image file using exiftool
|
||||
func (s *FFmpegService) GetImageOrientation(imagePath string) (string, error) {
|
||||
// Use exiftool to get orientation information
|
||||
cmd := exec.Command("exiftool", "-Orientation", "-s3", imagePath)
|
||||
exiftoolPath := settings.Config.Integrations.Media.ExiftoolPath
|
||||
if exiftoolPath == "" {
|
||||
return "Horizontal (normal)", nil
|
||||
}
|
||||
cmd := exec.Command(exiftoolPath, "-Orientation", "-s3", imagePath)
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
@@ -378,3 +382,11 @@ func (s *FFmpegService) ConvertHEICToJPEG(ctx context.Context, heicPath string,
|
||||
|
||||
return jpegBytes, nil
|
||||
}
|
||||
|
||||
// ConvertImageToJPEG converts any image format (including problematic JPEGs) to standard JPEG using FFmpeg
|
||||
// This handles extended JPEG formats that Go's image/jpeg decoder doesn't support (e.g., SOF1/C1 marker)
|
||||
// and can be used as a fallback for images that fail to decode with Go's standard library.
|
||||
func (s *FFmpegService) ConvertImageToJPEG(ctx context.Context, imagePath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
|
||||
// Use the direct conversion method which works for all image formats
|
||||
return s.ConvertHEICToJPEGDirect(ctx, imagePath, targetWidth, targetHeight, quality)
|
||||
}
|
||||
+33
-136
@@ -10,20 +10,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// SubtitleTrack represents a subtitle track (embedded or external file)
|
||||
type SubtitleTrack struct {
|
||||
Name string `json:"name"` // filename for external, or descriptive name for embedded
|
||||
Language string `json:"language,omitempty"` // language code
|
||||
Title string `json:"title,omitempty"` // title/description
|
||||
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
|
||||
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
|
||||
Content string `json:"content,omitempty"` // subtitle content
|
||||
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
|
||||
}
|
||||
|
||||
// FFProbeOutput represents the JSON output from ffprobe
|
||||
type FFProbeOutput struct {
|
||||
Streams []struct {
|
||||
@@ -36,7 +26,7 @@ type FFProbeOutput struct {
|
||||
}
|
||||
|
||||
// DetectAllSubtitles finds both embedded and external subtitle tracks
|
||||
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []SubtitleTrack {
|
||||
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []utils.SubtitleTrack {
|
||||
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
|
||||
|
||||
// Check cache first
|
||||
@@ -44,7 +34,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
|
||||
return cached
|
||||
}
|
||||
|
||||
var allSubtitles []SubtitleTrack
|
||||
var allSubtitles []utils.SubtitleTrack
|
||||
|
||||
// First, get embedded subtitles
|
||||
embeddedSubs := detectEmbeddedSubtitles(videoPath)
|
||||
@@ -60,9 +50,25 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
|
||||
return allSubtitles
|
||||
}
|
||||
|
||||
// DetectEmbeddedSubtitles detects embedded subtitle streams using ffprobe.
|
||||
// This is the public API that can be called from other packages.
|
||||
// Returns empty array if ffprobe is not available or fails.
|
||||
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
|
||||
// Check cache first
|
||||
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
|
||||
if cached, ok := MediaCache.Get(key); ok {
|
||||
return cached
|
||||
}
|
||||
// Detect embedded subtitles
|
||||
subtitles := detectEmbeddedSubtitles(videoPath)
|
||||
// Cache the results
|
||||
MediaCache.Set(key, subtitles)
|
||||
return subtitles
|
||||
}
|
||||
|
||||
// detectEmbeddedSubtitles uses ffprobe to find embedded subtitle tracks
|
||||
// Always runs ffprobe - results are cached for performance
|
||||
func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
|
||||
func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
|
||||
cmd := exec.Command("ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
@@ -82,13 +88,13 @@ func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
|
||||
return nil
|
||||
}
|
||||
|
||||
var subtitles []SubtitleTrack
|
||||
var subtitles []utils.SubtitleTrack
|
||||
for _, stream := range probeOutput.Streams {
|
||||
if stream.CodecType == "subtitle" {
|
||||
track := SubtitleTrack{
|
||||
Index: &stream.Index,
|
||||
Codec: stream.CodecName,
|
||||
IsFile: false, // This is an embedded subtitle
|
||||
track := utils.SubtitleTrack{
|
||||
Index: &stream.Index,
|
||||
Codec: stream.CodecName,
|
||||
Embedded: true, // This is an embedded subtitle
|
||||
}
|
||||
|
||||
// Extract language and title from tags
|
||||
@@ -117,8 +123,8 @@ func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
|
||||
}
|
||||
|
||||
// detectExternalSubtitles finds external subtitle files in the same directory
|
||||
func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack {
|
||||
var subtitles []SubtitleTrack
|
||||
func detectExternalSubtitles(videoPath string, parentDir string) []utils.SubtitleTrack {
|
||||
var subtitles []utils.SubtitleTrack
|
||||
|
||||
// Get the base name of the video (without extension)
|
||||
videoBaseName := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
|
||||
@@ -131,9 +137,9 @@ func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack
|
||||
subtitlePath := filepath.Join(parentDir, videoBaseName+ext)
|
||||
if _, err := os.Stat(subtitlePath); err == nil {
|
||||
// File exists
|
||||
track := SubtitleTrack{
|
||||
Name: filepath.Base(subtitlePath),
|
||||
IsFile: true, // This is an external file
|
||||
track := utils.SubtitleTrack{
|
||||
Name: filepath.Base(subtitlePath),
|
||||
Embedded: false, // This is an external file
|
||||
}
|
||||
|
||||
// Try to infer language from filename patterns like "video.en.srt"
|
||||
@@ -170,120 +176,11 @@ func ExtractSubtitleContent(videoPath string, streamIndex int) (string, error) {
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// LoadAndConvertSubtitleFile loads a subtitle file and converts it to WebVTT format
|
||||
func LoadAndConvertSubtitleFile(subtitlePath string) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(subtitlePath))
|
||||
|
||||
if ext == ".vtt" {
|
||||
// Already WebVTT, just read it
|
||||
content, err := os.ReadFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
if ext == ".srt" {
|
||||
// Convert SRT to WebVTT using ffmpeg
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", subtitlePath,
|
||||
"-c:s", "webvtt",
|
||||
"-f", "webvtt",
|
||||
"-") // output to stdout
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to convert SRT to WebVTT: %v", err)
|
||||
}
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// For other formats, try to read as plain text and hope for the best
|
||||
// This is a fallback - ideally you'd want format-specific converters
|
||||
// LoadSubtitleFile loads a subtitle file and returns its raw content
|
||||
func LoadSubtitleFile(subtitlePath string) (string, error) {
|
||||
content, err := os.ReadFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Basic conversion wrapper for non-VTT formats
|
||||
vttHeader := "WEBVTT\n\n"
|
||||
return vttHeader + string(content), nil
|
||||
}
|
||||
|
||||
// ExtractSingleSubtitle extracts content for a specific subtitle track by array index
|
||||
func ExtractSingleSubtitle(videoPath string, parentDir string, trackIndex int, modtime time.Time) (SubtitleTrack, error) {
|
||||
// Get all subtitle tracks
|
||||
allTracks := DetectAllSubtitles(videoPath, parentDir, modtime)
|
||||
|
||||
if trackIndex >= len(allTracks) {
|
||||
return SubtitleTrack{}, fmt.Errorf("subtitle track %d not found (only %d tracks available)", trackIndex, len(allTracks))
|
||||
}
|
||||
|
||||
track := allTracks[trackIndex]
|
||||
|
||||
// Load content based on type
|
||||
if track.IsFile {
|
||||
// Load external subtitle file
|
||||
subtitlePath := filepath.Join(parentDir, track.Name)
|
||||
content, err := LoadAndConvertSubtitleFile(subtitlePath)
|
||||
if err != nil {
|
||||
return SubtitleTrack{}, fmt.Errorf("failed to load external subtitle: %v", err)
|
||||
}
|
||||
track.Content = content
|
||||
} else {
|
||||
// Extract embedded subtitle content
|
||||
if track.Index == nil {
|
||||
return SubtitleTrack{}, fmt.Errorf("embedded subtitle track has no stream index")
|
||||
}
|
||||
content, err := ExtractSubtitleContent(videoPath, *track.Index)
|
||||
if err != nil {
|
||||
return SubtitleTrack{}, fmt.Errorf("failed to extract embedded subtitle: %v", err)
|
||||
}
|
||||
track.Content = content
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// LoadAllSubtitleContent loads the actual content for all detected subtitle tracks
|
||||
func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime time.Time) error {
|
||||
for idx := range subtitles {
|
||||
subtitle := &subtitles[idx]
|
||||
|
||||
// Check if content is already cached
|
||||
contentKey := fmt.Sprintf("subtitle_content:%s:%d:%s", videoPath, idx, modtime.Format(time.RFC3339))
|
||||
if cached, ok := SubtitleContentCache.Get(contentKey); ok {
|
||||
subtitle.Content = cached
|
||||
continue
|
||||
}
|
||||
|
||||
var content string
|
||||
var err error
|
||||
|
||||
if subtitle.IsFile {
|
||||
// Load external subtitle file content and convert to WebVTT
|
||||
subtitlePath := filepath.Join(filepath.Dir(videoPath), subtitle.Name)
|
||||
content, err = LoadAndConvertSubtitleFile(subtitlePath)
|
||||
if err != nil {
|
||||
logger.Debug("failed to read/convert subtitle file " + subtitlePath + ": " + err.Error())
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Load embedded subtitle content
|
||||
if subtitle.Index == nil {
|
||||
logger.Debug("embedded subtitle has no stream index")
|
||||
continue
|
||||
}
|
||||
content, err = ExtractSubtitleContent(videoPath, *subtitle.Index)
|
||||
if err != nil {
|
||||
logger.Debug("failed to extract embedded subtitle: " + err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
subtitle.Content = content
|
||||
// Cache the content for future requests
|
||||
SubtitleContentCache.Set(contentKey, content)
|
||||
}
|
||||
return nil
|
||||
return string(content), nil
|
||||
}
|
||||
+75
-58
@@ -4,35 +4,42 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.16.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/gen2brain/go-fitz v1.24.15
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/gtsteffaniak/go-cache v1.0.0
|
||||
github.com/gtsteffaniak/go-logger v0.2.3
|
||||
github.com/kovidgoyal/imaging v1.7.2
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-logger v1.0.2
|
||||
github.com/kovidgoyal/imaging v1.8.19
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.44.0
|
||||
golang.org/x/image v0.33.0
|
||||
golang.org/x/mod v0.30.0
|
||||
golang.org/x/oauth2 v0.33.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/mod v0.33.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/tools v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
codeberg.org/chavacava/garif v0.2.0 // indirect
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
|
||||
dev.gaijin.team/go/golib v0.6.0 // indirect
|
||||
@@ -43,20 +50,22 @@ require (
|
||||
github.com/Antonboom/errname v1.1.1 // indirect
|
||||
github.com/Antonboom/nilnil v1.1.1 // indirect
|
||||
github.com/Antonboom/testifylint v1.6.4 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/Djarvur/go-err113 v0.1.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.2.1 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.63.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||
github.com/air-verse/air v1.64.5 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.0 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.2 // indirect
|
||||
github.com/alfatraining/structtag v1.0.0 // indirect
|
||||
github.com/alingse/asasalint v0.0.11 // indirect
|
||||
github.com/alingse/nilnesserr v0.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
|
||||
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
@@ -66,19 +75,19 @@ require (
|
||||
github.com/bkielbasa/cyclop v1.2.3 // indirect
|
||||
github.com/blizzy78/varnamelen v0.8.0 // indirect
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.3.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.6.0 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/breml/bidichk v0.3.3 // indirect
|
||||
github.com/breml/errchkjson v0.4.1 // indirect
|
||||
github.com/butuzov/ireturn v0.4.0 // indirect
|
||||
github.com/butuzov/mirror v1.3.0 // indirect
|
||||
github.com/catenacyber/perfsprint v0.10.0 // indirect
|
||||
github.com/catenacyber/perfsprint v0.10.1 // indirect
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charithe/durationcheck v0.0.11 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/ckaznocha/intrange v0.3.1 // indirect
|
||||
@@ -91,28 +100,30 @@ require (
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0-alpha.4 // indirect
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.6 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fzipp/gocyclo v0.6.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.17 // indirect
|
||||
github.com/go-critic/go-critic v0.14.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.20 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-critic/go-critic v0.14.3 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.4 // indirect
|
||||
github.com/go-openapi/spec v0.22.3 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-toolsmith/astcast v1.1.0 // indirect
|
||||
@@ -122,32 +133,33 @@ require (
|
||||
github.com/go-toolsmith/astp v1.1.0 // indirect
|
||||
github.com/go-toolsmith/strparse v1.1.0 // indirect
|
||||
github.com/go-toolsmith/typep v1.1.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/godoc-lint/godoc-lint v0.10.1 // indirect
|
||||
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/gohugoio/hugo v0.149.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33 // indirect
|
||||
github.com/golang/geo v0.0.0-20260129164528-943061e2742c // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golangci/asciicheck v0.5.0 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.6.2 // indirect
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect
|
||||
github.com/golangci/misspell v0.7.0 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1 // indirect
|
||||
github.com/golangci/golines v0.15.0 // indirect
|
||||
github.com/golangci/misspell v0.8.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
github.com/golangci/revgrep v0.8.0 // indirect
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gordonklaus/ineffassign v0.2.0 // indirect
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
|
||||
github.com/gostaticanalysis/comment v1.5.0 // indirect
|
||||
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
|
||||
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hexops/gotextdiff v1.0.3 // indirect
|
||||
@@ -155,18 +167,21 @@ require (
|
||||
github.com/jgautheron/goconst v1.8.2 // indirect
|
||||
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
|
||||
github.com/jjti/go-spancheck v0.6.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/jupiterrider/ffi v0.5.1 // indirect
|
||||
github.com/jupiterrider/ffi v0.6.0 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.9.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
github.com/kulti/thelper v0.7.1 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.15 // indirect
|
||||
github.com/lasiar/canonicalheader v1.1.2 // indirect
|
||||
github.com/ldez/exptostd v0.4.5 // indirect
|
||||
github.com/ldez/gomoddirectives v0.7.1 // indirect
|
||||
github.com/ldez/gomoddirectives v0.8.0 // indirect
|
||||
github.com/ldez/grignotin v0.10.1 // indirect
|
||||
github.com/ldez/structtags v0.6.1 // indirect
|
||||
github.com/ldez/tagliatelle v0.7.2 // indirect
|
||||
github.com/ldez/usetesting v0.5.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -184,19 +199,19 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/mgechev/revive v1.12.0 // indirect
|
||||
github.com/mgechev/revive v1.14.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
|
||||
github.com/moricho/tparallel v0.3.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/nakabonne/nestif v0.3.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nishanths/exhaustive v0.12.0 // indirect
|
||||
github.com/nishanths/predeclared v0.2.2 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.21.2 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/polyfloyd/go-errorlint v1.8.0 // indirect
|
||||
github.com/prometheus/client_golang v1.12.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.32.1 // indirect
|
||||
@@ -207,6 +222,7 @@ require (
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
|
||||
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
|
||||
github.com/raeperd/recvcheck v0.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
@@ -217,18 +233,18 @@ require (
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.22.10 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/securego/gosec/v2 v2.23.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sivchari/containedctx v1.0.3 // indirect
|
||||
github.com/sonatard/noctx v0.4.0 // indirect
|
||||
github.com/sourcegraph/go-diff v0.7.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/cobra v1.10.1 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.12.0 // indirect
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
|
||||
github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.4.1 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
@@ -236,7 +252,7 @@ require (
|
||||
github.com/tetafro/godot v1.5.4 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
|
||||
github.com/timonwong/loggercheck v0.11.0 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
|
||||
github.com/ultraware/funlen v0.2.0 // indirect
|
||||
github.com/ultraware/whitespace v0.2.0 // indirect
|
||||
@@ -251,22 +267,23 @@ require (
|
||||
gitlab.com/bosi/decorder v0.4.2 // indirect
|
||||
go-simpler.org/musttag v0.14.0 // indirect
|
||||
go-simpler.org/sloglint v0.11.1 // indirect
|
||||
go.augendre.info/arangolint v0.3.1 // indirect
|
||||
go.augendre.info/arangolint v0.4.0 // indirect
|
||||
go.augendre.info/fatcontext v0.9.0 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
honnef.co/go/tools v0.6.1 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
|
||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||
|
||||
+194
-135
@@ -36,6 +36,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY=
|
||||
codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ=
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI=
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y=
|
||||
@@ -57,11 +59,13 @@ github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksuf
|
||||
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
|
||||
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
|
||||
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
@@ -71,37 +75,41 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/MirrexOne/unqueryvet v1.2.1 h1:M+zdXMq84g+E1YOLa7g7ExN3dWfZQrdDSTCM7gC+m/A=
|
||||
github.com/MirrexOne/unqueryvet v1.2.1/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/air-verse/air v1.63.1 h1:N6kD5niKKVx0wF2mW0mgK6LNfJqP5/lCAqm3WWl9vlw=
|
||||
github.com/air-verse/air v1.63.1/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
|
||||
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
|
||||
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
|
||||
github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw=
|
||||
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
|
||||
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
|
||||
github.com/alexkohler/prealloc v1.0.2/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
|
||||
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
|
||||
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
|
||||
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
|
||||
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
|
||||
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
|
||||
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
|
||||
@@ -152,8 +160,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
|
||||
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
|
||||
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
|
||||
github.com/bombsimon/wsl/v5 v5.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM=
|
||||
github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
|
||||
github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
|
||||
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
@@ -165,8 +173,8 @@ github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E
|
||||
github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70=
|
||||
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
|
||||
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
|
||||
github.com/catenacyber/perfsprint v0.10.0 h1:AZj1mYyxbxLRqmnYOeguZXEQwWOgQGm2wzLI5d7Hl/0=
|
||||
github.com/catenacyber/perfsprint v0.10.0/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
|
||||
github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
|
||||
github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
@@ -180,8 +188,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
@@ -195,8 +203,10 @@ github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyM
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -236,8 +246,10 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.0-alpha.4 h1:JzPbdf+cqbyT9sZtP4xnqelwUXwf7LvD8xKS6+ofTds=
|
||||
github.com/ebitengine/purego v0.10.0-alpha.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -259,18 +271,20 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
|
||||
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
|
||||
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/ghostiam/protogetter v0.3.17 h1:sjGPErP9o7i2Ym+z3LsQzBdLCNaqbYy2iJQPxGXg04Q=
|
||||
github.com/ghostiam/protogetter v0.3.17/go.mod h1:AivIX1eKA/TcUmzZdzbl+Tb8tjIe8FcyG6JFyemQAH4=
|
||||
github.com/go-critic/go-critic v0.14.2 h1:PMvP5f+LdR8p6B29npvChUXbD1vrNlKDf60NJtgMBOo=
|
||||
github.com/go-critic/go-critic v0.14.2/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
|
||||
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
|
||||
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
|
||||
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
@@ -285,34 +299,36 @@ github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
|
||||
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
|
||||
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
|
||||
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
|
||||
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
@@ -323,8 +339,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
@@ -349,18 +365,18 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
|
||||
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
|
||||
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
|
||||
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
|
||||
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godoc-lint/godoc-lint v0.10.1 h1:ZPUVzlDtJfA+P688JfPJPkI/SuzcBr/753yGIk5bOPA=
|
||||
github.com/godoc-lint/godoc-lint v0.10.1/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM=
|
||||
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
@@ -385,8 +401,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
|
||||
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33 h1:1iCpF+lKihFpyN/Ujqvy9cWVlB516bGZItvb3l9HOSo=
|
||||
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20260129164528-943061e2742c h1:ysO2h2Odnl1AJM1I2Lm/fa6JvO0pECMSt2CwBaa+ITo=
|
||||
github.com/golang/geo v0.0.0-20260129164528-943061e2742c/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@@ -426,12 +442,12 @@ 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-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
|
||||
github.com/golangci/golangci-lint/v2 v2.6.2 h1:jkMSVv36JmyTENcEertckvimvjPcD5qxNM7W7qhECvI=
|
||||
github.com/golangci/golangci-lint/v2 v2.6.2/go.mod h1:fSIMDiBt9kzdpnvvV7GO6iWzyv5uaeZ+iPor+2uRczE=
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8=
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ=
|
||||
github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
|
||||
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
|
||||
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
|
||||
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
|
||||
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
|
||||
github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
|
||||
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
|
||||
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
|
||||
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
|
||||
@@ -465,9 +481,11 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
|
||||
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs=
|
||||
@@ -486,10 +504,10 @@ github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduw
|
||||
github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M=
|
||||
github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8=
|
||||
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
|
||||
github.com/gtsteffaniak/go-cache v1.0.0 h1:CuZw6eI/be6Oi3oR94Jb5LhcZShMCs6+1wvS7WGN8oQ=
|
||||
github.com/gtsteffaniak/go-cache v1.0.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
|
||||
github.com/gtsteffaniak/go-logger v0.2.3 h1:8nSC5xSMnMYQI61tsBS1tARpnkv/rOh5lzEXtAG72Nw=
|
||||
github.com/gtsteffaniak/go-logger v0.2.3/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
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-logger v1.0.2 h1:S4AgR7yDu2geD8y2D9NyJVOBqpZRnl37r2mBi7GDNR0=
|
||||
github.com/gtsteffaniak/go-logger v1.0.2/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
|
||||
@@ -497,8 +515,8 @@ github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1T
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -510,6 +528,18 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
|
||||
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
@@ -520,6 +550,8 @@ github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjz
|
||||
github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c=
|
||||
github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
|
||||
github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
@@ -533,8 +565,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
|
||||
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
|
||||
github.com/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
|
||||
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
|
||||
github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
|
||||
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
|
||||
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
|
||||
@@ -548,10 +580,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
|
||||
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
|
||||
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
|
||||
github.com/kovidgoyal/imaging v1.7.2 h1:mmT6k6Az3mC6dbqdZ6Q9KQCdZFWTAQ+q97NyGZgJ/2c=
|
||||
github.com/kovidgoyal/imaging v1.7.2/go.mod h1:GdkCORjfZMMGFY0Pb7TDmRhj7PDhxF/QShKukSCj0VU=
|
||||
github.com/kovidgoyal/imaging v1.8.15 h1:FwKRGvmbQ04OFYF5WIalcvUontcnj1chBeHmR5I7UYg=
|
||||
github.com/kovidgoyal/imaging v1.8.15/go.mod h1:aXZHGm/gKcaXpxn1NnQiVtuz3Z2BCnXWyjF0ZGrmJ1g=
|
||||
github.com/kovidgoyal/imaging v1.8.19 h1:zWJdQqF2tfSKjvoB7XpLRhVGbYsze++M0iaqZ4ZkhNk=
|
||||
github.com/kovidgoyal/imaging v1.8.19/go.mod h1:I0q8RdoEuyc4G8GFOF9CaluTUHQSf68d6TmsqpvfRI8=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -570,10 +600,12 @@ github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0
|
||||
github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI=
|
||||
github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
|
||||
github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
|
||||
github.com/ldez/gomoddirectives v0.7.1 h1:FaULkvUIG36hj6chpwa+FdCNGZBsD7/fO+p7CCsM6pE=
|
||||
github.com/ldez/gomoddirectives v0.7.1/go.mod h1:auDNtakWJR1rC+YX7ar+HmveqXATBAyEK1KYpsIRW/8=
|
||||
github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk=
|
||||
github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q=
|
||||
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
|
||||
github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
|
||||
github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk=
|
||||
github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY=
|
||||
github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk=
|
||||
github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI=
|
||||
github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
|
||||
@@ -612,10 +644,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mgechev/revive v1.12.0 h1:Q+/kkbbwerrVYPv9d9efaPGmAO/NsxwW/nE6ahpQaCU=
|
||||
github.com/mgechev/revive v1.12.0/go.mod h1:VXsY2LsTigk8XU9BpZauVLjVrhICMOV3k1lpB3CXrp8=
|
||||
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
|
||||
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -639,14 +673,16 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
|
||||
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
|
||||
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/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
|
||||
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
|
||||
github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
|
||||
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
|
||||
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
|
||||
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
|
||||
github.com/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po=
|
||||
github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY=
|
||||
github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8=
|
||||
github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
@@ -657,10 +693,10 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
||||
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
|
||||
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE=
|
||||
github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
|
||||
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
|
||||
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
|
||||
@@ -682,12 +718,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q=
|
||||
github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
@@ -722,6 +754,8 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4l
|
||||
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ=
|
||||
github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI=
|
||||
github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
@@ -745,8 +779,8 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
|
||||
github.com/securego/gosec/v2 v2.22.10 h1:ntbBqdWXnu46DUOXn+R2SvPo3PiJCDugTCgTW2g4tQg=
|
||||
github.com/securego/gosec/v2 v2.22.10/go.mod h1:9UNjK3tLpv/w2b0+7r82byV43wCJDNtEDQMeS+H/g2w=
|
||||
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
|
||||
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
|
||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||
@@ -755,8 +789,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
|
||||
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
|
||||
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
|
||||
@@ -767,8 +801,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -779,8 +813,8 @@ github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ=
|
||||
github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
@@ -788,7 +822,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
|
||||
@@ -817,8 +850,8 @@ github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
|
||||
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
|
||||
github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
|
||||
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
|
||||
@@ -839,6 +872,8 @@ github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pH
|
||||
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
|
||||
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
|
||||
github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
|
||||
@@ -864,8 +899,8 @@ go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
|
||||
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
|
||||
go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
|
||||
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
|
||||
go.augendre.info/arangolint v0.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo=
|
||||
go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g=
|
||||
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
|
||||
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
|
||||
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
|
||||
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
|
||||
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
@@ -876,8 +911,6 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
@@ -895,8 +928,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -907,16 +940,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -942,8 +975,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -986,16 +1019,16 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1011,8 +1044,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1057,7 +1090,6 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -1066,8 +1098,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -1084,8 +1116,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1141,8 +1173,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
@@ -1248,7 +1280,6 @@ gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
@@ -1258,8 +1289,36 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
|
||||
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
|
||||
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
|
||||
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
|
||||
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI=
|
||||
|
||||
+128
-81
@@ -3,56 +3,63 @@ package http
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// createApiKeyHandler creates an API key for the user.
|
||||
// @Summary Create API key
|
||||
// @Description Create an API key with specified name, duration, and permissions.
|
||||
// @Tags API Keys
|
||||
// createApiTokenHandler creates an API token for the user.
|
||||
// @Summary Create API Token
|
||||
// @Description Create an API token with specified name, duration, and permissions.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name query string true "Name of the API key"
|
||||
// @Param days query string true "Duration of the API key in days"
|
||||
// @Param permissions query string true "Permissions for the API key (comma-separated)"
|
||||
// @Success 200 {object} HttpResponse "Token created successfully, response contains json object with token key"
|
||||
// @Param name query string true "Name of the API token"
|
||||
// @Param days query string true "Duration of the API token in days"
|
||||
// @Param permissions query string true "Permissions for the API token (comma-separated)"
|
||||
// @Success 200 {object} HttpResponse "Token created successfully, response contains json object with token"
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Failure 409 {object} map[string]string "Conflict"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/token [put]
|
||||
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// @Router /api/auth/token [post]
|
||||
func createApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
name := r.URL.Query().Get("name")
|
||||
durationStr := r.URL.Query().Get("days")
|
||||
permissionsStr := r.URL.Query().Get("permissions")
|
||||
minimal := permissionsStr == ""
|
||||
|
||||
if !d.user.Permissions.Api {
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api keys")
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api tokens")
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("api name must be valid")
|
||||
if name == "" || strings.HasPrefix(name, "WEB_TOKEN") {
|
||||
return http.StatusBadRequest, fmt.Errorf("api token name must be valid")
|
||||
}
|
||||
if durationStr == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("api duration must be valid")
|
||||
return http.StatusBadRequest, fmt.Errorf("api token duration must be valid")
|
||||
}
|
||||
if permissionsStr == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("api permissions must be valid")
|
||||
}
|
||||
// Parse permissions from the query parameter
|
||||
permissions := users.Permissions{
|
||||
Api: strings.Contains(permissionsStr, "api") && d.user.Permissions.Api,
|
||||
Admin: strings.Contains(permissionsStr, "admin") && d.user.Permissions.Admin,
|
||||
Modify: strings.Contains(permissionsStr, "modify") && d.user.Permissions.Modify,
|
||||
Delete: strings.Contains(permissionsStr, "delete") && d.user.Permissions.Delete,
|
||||
Create: strings.Contains(permissionsStr, "create") && d.user.Permissions.Create,
|
||||
Share: strings.Contains(permissionsStr, "share") && d.user.Permissions.Share,
|
||||
Realtime: strings.Contains(permissionsStr, "realtime") && d.user.Permissions.Realtime,
|
||||
|
||||
// For full tokens (minimal=false), permissions are required in the claim
|
||||
// For minimal tokens (minimal=true), permissions are not in the token
|
||||
var permissions users.Permissions
|
||||
if !minimal {
|
||||
// Parse permissions from the query parameter
|
||||
permissions = users.Permissions{
|
||||
Api: strings.Contains(permissionsStr, "api") && d.user.Permissions.Api,
|
||||
Admin: strings.Contains(permissionsStr, "admin") && d.user.Permissions.Admin,
|
||||
Modify: strings.Contains(permissionsStr, "modify") && d.user.Permissions.Modify,
|
||||
Delete: strings.Contains(permissionsStr, "delete") && d.user.Permissions.Delete,
|
||||
Create: strings.Contains(permissionsStr, "create") && d.user.Permissions.Create,
|
||||
Share: strings.Contains(permissionsStr, "share") && d.user.Permissions.Share,
|
||||
Realtime: strings.Contains(permissionsStr, "realtime") && d.user.Permissions.Realtime,
|
||||
Download: strings.Contains(permissionsStr, "download") && d.user.Permissions.Download,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the duration string to an int64
|
||||
@@ -63,104 +70,144 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// Here we assume the duration is in seconds; convert to time.Duration
|
||||
duration := time.Duration(durationInt) * time.Hour * 24
|
||||
// get request body like:
|
||||
token, err := makeSignedTokenAPI(d.user, name, duration, permissions)
|
||||
tokenString, authToken, err := auth.MakeSignedTokenAPI(d.user, name, duration, permissions, minimal)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "key already exists with same name") {
|
||||
return http.StatusConflict, err
|
||||
}
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Store API token metadata in user's Tokens map
|
||||
err = store.Users.AddApiToken(d.user.ID, name, tokenString, authToken)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Store token hash → user ID mapping in access storage for fast lookups
|
||||
err = store.Access.AddApiToken(tokenString, d.user.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
response := HttpResponse{
|
||||
Message: "here is your token!",
|
||||
Token: token.Key,
|
||||
Token: tokenString,
|
||||
}
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
// deleteApiKeyHandler deletes an API key for the user.
|
||||
// @Summary Delete API key
|
||||
// @Description Delete an API key with specified name.
|
||||
// @Tags API Keys
|
||||
// deleteApiTokenHandler deletes an API token for the user.
|
||||
// @Summary Delete API token
|
||||
// @Description Delete an API token with specified name.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name query string true "Name of the API key to delete"
|
||||
// @Success 200 {object} HttpResponse "API key deleted successfully"
|
||||
// @Param name query string true "Name of the API token to delete"
|
||||
// @Success 200 {object} HttpResponse "API token deleted successfully"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/token [delete]
|
||||
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
func deleteApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if !d.user.Permissions.Api {
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api keys")
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api tokens")
|
||||
}
|
||||
|
||||
keyInfo, ok := d.user.ApiKeys[name]
|
||||
tokenInfo, ok := d.user.Tokens[name]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("api key not found")
|
||||
return http.StatusNotFound, fmt.Errorf("api token not found")
|
||||
}
|
||||
|
||||
// Perform the user update
|
||||
err := store.Users.DeleteApiKey(d.user.ID, name)
|
||||
err := store.Users.DeleteApiToken(d.user.ID, name)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
|
||||
auth.RevokeAPIKey(keyInfo.Key) // add to blacklist
|
||||
// Revoke the token (adds to RevokedTokens set)
|
||||
if err := auth.RevokeApiToken(store.Access, tokenInfo.Token); err != nil {
|
||||
logger.Errorf("Failed to revoke token: %v", err)
|
||||
}
|
||||
if err := store.Access.RemoveApiToken(tokenInfo.Token); err != nil {
|
||||
logger.Errorf("Failed to remove api token: %v", err)
|
||||
}
|
||||
|
||||
response := HttpResponse{
|
||||
Message: "successfully deleted api key from user",
|
||||
Message: "successfully deleted api token from user",
|
||||
}
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
type AuthTokenMin struct {
|
||||
Key string `json:"key"`
|
||||
type AuthTokenFrontend struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Created int64 `json:"created"`
|
||||
Expires int64 `json:"expires"`
|
||||
Permissions users.Permissions `json:"Permissions"`
|
||||
IssuedAt int64 `json:"issuedAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Permissions users.Permissions `json:"Permissions,omitempty"`
|
||||
}
|
||||
|
||||
// listApiKeysHandler lists all API keys or retrieves details for a specific key.
|
||||
// @Summary List API keys
|
||||
// @Description List all API keys or retrieve details for a specific key.
|
||||
// @Tags API Keys
|
||||
// listApiTokensHandler lists all API tokens or retrieves details for a specific token.
|
||||
// @Summary List API tokens
|
||||
// @Description List all API tokens or retrieve details for a specific token.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name query string false "Name of the API to retrieve details"
|
||||
// @Success 200 {object} AuthTokenMin "List of API keys or specific key details"
|
||||
// @Success 200 {array} AuthTokenFrontend "List of API tokens"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/tokens [get]
|
||||
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
key := r.URL.Query().Get("name")
|
||||
// @Router /api/auth/token/list [get]
|
||||
func listApiTokensHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.user.Permissions.Api {
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api keys")
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api tokens")
|
||||
}
|
||||
|
||||
if key != "" {
|
||||
keyInfo, ok := d.user.ApiKeys[key]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("api key not found")
|
||||
}
|
||||
modifiedKey := AuthTokenMin{
|
||||
Key: keyInfo.Key,
|
||||
Name: key,
|
||||
Created: keyInfo.Created,
|
||||
Expires: keyInfo.Expires,
|
||||
Permissions: keyInfo.Permissions,
|
||||
}
|
||||
return renderJSON(w, r, modifiedKey)
|
||||
if len(d.user.Tokens) == 0 {
|
||||
return http.StatusNotFound, fmt.Errorf("no api tokens found")
|
||||
}
|
||||
|
||||
modifiedList := map[string]AuthTokenMin{}
|
||||
for key, value := range d.user.ApiKeys {
|
||||
modifiedList[key] = AuthTokenMin{
|
||||
Key: value.Key,
|
||||
Created: value.Created,
|
||||
Expires: value.Expires,
|
||||
Permissions: value.Permissions,
|
||||
}
|
||||
AuthTokensFrontend := make([]AuthTokenFrontend, 0, len(d.user.Tokens))
|
||||
for name, token := range d.user.Tokens {
|
||||
AuthTokensFrontend = append(AuthTokensFrontend, AuthTokenFrontend{
|
||||
Token: token.Token,
|
||||
Name: name,
|
||||
IssuedAt: token.RegisteredClaims.IssuedAt.Unix(),
|
||||
ExpiresAt: token.RegisteredClaims.ExpiresAt.Unix(),
|
||||
Permissions: token.Permissions,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(AuthTokensFrontend, func(i, j int) bool {
|
||||
return AuthTokensFrontend[i].Name < AuthTokensFrontend[j].Name
|
||||
})
|
||||
|
||||
return renderJSON(w, r, modifiedList)
|
||||
return renderJSON(w, r, AuthTokensFrontend)
|
||||
}
|
||||
|
||||
// getApiTokenHandler gets a specific API token.
|
||||
// @Summary Get API token
|
||||
// @Description Get a specific API token.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name query string true "Name of the API token to retrieve"
|
||||
// @Success 200 {object} AuthTokenFrontend "API token details"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/token [get]
|
||||
func getApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if !d.user.Permissions.Api {
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api tokens")
|
||||
}
|
||||
tokenInfo, ok := d.user.Tokens[name]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("api token not found")
|
||||
}
|
||||
AuthTokenFrontendResponse := AuthTokenFrontend{
|
||||
Token: tokenInfo.Token,
|
||||
Name: name,
|
||||
IssuedAt: tokenInfo.IssuedAt,
|
||||
ExpiresAt: tokenInfo.ExpiresAt,
|
||||
Permissions: tokenInfo.Permissions,
|
||||
}
|
||||
return renderJSON(w, r, AuthTokenFrontendResponse)
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// archiveCreateHandler creates an archive on the server at the given destination.
|
||||
// POST /resources/archive — server-side only; does not return archive data.
|
||||
//
|
||||
// @Summary Create an archive on the server
|
||||
// @Description Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission.
|
||||
// @Description
|
||||
// @Description **Request body parameters:**
|
||||
// @Description - **fromSource** (string, required): Source name where the paths to archive live. Example: `"default"`
|
||||
// @Description - **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: `"backups"`
|
||||
// @Description - **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: `["/docs/file.txt", "/photos"]`
|
||||
// @Description - **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: `"/backups/my-archive.zip"`
|
||||
// @Description - **format** (string, optional): Archive format. One of: `"zip"`, `"tar.gz"`. Default inferred from destination extension. Example: `"zip"`
|
||||
// @Description - **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: `6`
|
||||
// @Description - **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: `true`
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body archiveCreateRequest true "Request body: fromSource, toSource (optional), paths, destination, format (optional), compression (optional)"
|
||||
// @Success 200 {object} map[string]string "Created; returns {\"path\": \"<destination path>\"}"
|
||||
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, invalid path)"
|
||||
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
|
||||
// @Failure 404 {object} map[string]string "Source not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources/archive [post]
|
||||
func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share != nil {
|
||||
return http.StatusForbidden, fmt.Errorf("archive create not allowed for shares")
|
||||
}
|
||||
if !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create resources")
|
||||
}
|
||||
|
||||
var req archiveCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
|
||||
}
|
||||
if req.FromSource == "" || len(req.Paths) == 0 || req.Destination == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("fromSource, paths, and destination are required")
|
||||
}
|
||||
|
||||
destClean, err := utils.SanitizeUserPath(req.Destination)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination path: %v", err)
|
||||
}
|
||||
req.Destination = destClean
|
||||
pathsClean := make([]string, 0, len(req.Paths))
|
||||
for _, p := range req.Paths {
|
||||
var clean string
|
||||
clean, err = utils.SanitizeUserPath(p)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path %q: %v", p, err)
|
||||
}
|
||||
pathsClean = append(pathsClean, clean)
|
||||
}
|
||||
req.Paths = pathsClean
|
||||
|
||||
destSource := req.ToSource
|
||||
if destSource == "" {
|
||||
destSource = req.FromSource
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(req.FromSource)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", req.FromSource)
|
||||
}
|
||||
userScope, err := d.user.GetScopeForSourceName(req.FromSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
// Resolve destination on ToSource (or Source if not set)
|
||||
idxTo := indexing.GetIndex(destSource)
|
||||
if idxTo == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", destSource)
|
||||
}
|
||||
userScopeTo, err := d.user.GetScopeForSourceName(destSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullDest := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDest, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
// Destination is a file we are about to create; resolve parent dir only (parent must exist or be creatable).
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
}
|
||||
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
|
||||
}
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
}
|
||||
destRealPath := filepath.Join(destParentReal, filepath.Base(req.Destination))
|
||||
if err = os.MkdirAll(destParentReal, fileutils.PermDir); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
|
||||
}
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(req.Format))
|
||||
if format == "" {
|
||||
ext := strings.ToLower(filepath.Ext(destRealPath))
|
||||
if ext == ".gz" && len(destRealPath) > 3 && strings.HasSuffix(strings.ToLower(destRealPath), ".tar.gz") {
|
||||
format = "tar.gz"
|
||||
} else if ext == ".zip" {
|
||||
format = "zip"
|
||||
} else {
|
||||
format = "zip"
|
||||
}
|
||||
}
|
||||
if format != "zip" && format != "tar.gz" {
|
||||
return http.StatusBadRequest, fmt.Errorf("format must be zip or tar.gz")
|
||||
}
|
||||
|
||||
compression := req.Compression
|
||||
if compression < 0 || compression > 9 {
|
||||
compression = 0
|
||||
}
|
||||
|
||||
// Build full paths for items (same source)
|
||||
itemPaths := make([]string, 0, len(req.Paths))
|
||||
for _, it := range req.Paths {
|
||||
full := utils.JoinPathAsUnix(userScope, it)
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, full, d.user.Username) {
|
||||
continue // silently skip
|
||||
}
|
||||
itemPaths = append(itemPaths, full)
|
||||
}
|
||||
if len(itemPaths) == 0 {
|
||||
return http.StatusBadRequest, fmt.Errorf("no paths accessible; add at least one path you have access to")
|
||||
}
|
||||
|
||||
estimatedSize, err := computeArchiveSize(req.FromSource, req.Paths, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
if config.Server.MaxArchiveSizeGB > 0 {
|
||||
maxSize := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
|
||||
if estimatedSize > maxSize {
|
||||
return http.StatusRequestEntityTooLarge, fmt.Errorf("pre-archive combined size of files exceeds maximum limit of %d GB", config.Server.MaxArchiveSizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
var createErr error
|
||||
if format == "zip" {
|
||||
createErr = createZip(d, req.FromSource, destRealPath, itemPaths...)
|
||||
} else {
|
||||
createErr = createTarGzWithLevel(d, req.FromSource, destRealPath, compression, itemPaths...)
|
||||
}
|
||||
if createErr != nil {
|
||||
return http.StatusInternalServerError, createErr
|
||||
}
|
||||
|
||||
if req.DeleteAfter && d.user.Permissions.Delete {
|
||||
type itemToDelete struct {
|
||||
realPath string
|
||||
isDir bool
|
||||
}
|
||||
var toDelete []itemToDelete
|
||||
for _, full := range itemPaths {
|
||||
realPath, isDir, err := idx.GetRealPath(full)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
toDelete = append(toDelete, itemToDelete{realPath: realPath, isDir: isDir})
|
||||
}
|
||||
for i := 0; i < len(toDelete); i++ {
|
||||
for j := i + 1; j < len(toDelete); j++ {
|
||||
if len(toDelete[j].realPath) > len(toDelete[i].realPath) {
|
||||
toDelete[i], toDelete[j] = toDelete[j], toDelete[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range toDelete {
|
||||
if err := files.DeleteFiles(req.FromSource, item.realPath, item.isDir); err != nil {
|
||||
logger.Errorf("Failed to delete source after archive: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return renderJSON(w, r, map[string]string{"path": req.Destination}, http.StatusOK)
|
||||
}
|
||||
|
||||
// unarchiveHandler extracts an archive on the server. POST /resources/unarchive — server-side only.
|
||||
//
|
||||
// @Summary Extract an archive on the server
|
||||
// @Description Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission.
|
||||
// @Description
|
||||
// @Description **Request body parameters:**
|
||||
// @Description - **fromSource** (string, required): Source name where the archive file lives. Example: `"default"`
|
||||
// @Description - **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: `"restored"`
|
||||
// @Description - **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: `"/downloads/data.zip"`
|
||||
// @Description - **destination** (string, required): Directory path (on toSource) to extract into. Example: `"/projects/imported"`
|
||||
// @Description - **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: `true`
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body unarchiveRequest true "Request body: fromSource, toSource (optional), path, destination, deleteAfter (optional)"
|
||||
// @Success 200 {object} map[string]string "Extracted; returns {\"path\": \"<destination path>\", \"source\": \"<toSource>\"}"
|
||||
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, unsupported format)"
|
||||
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
|
||||
// @Failure 404 {object} map[string]string "Source or archive file not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources/unarchive [post]
|
||||
func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share != nil {
|
||||
return http.StatusForbidden, fmt.Errorf("unarchive not allowed for shares")
|
||||
}
|
||||
if !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create resources")
|
||||
}
|
||||
|
||||
var req unarchiveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
|
||||
}
|
||||
if req.FromSource == "" || req.Path == "" || req.Destination == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("fromSource, path, and destination are required")
|
||||
}
|
||||
if req.ToSource == "" {
|
||||
req.ToSource = req.FromSource
|
||||
}
|
||||
|
||||
pathClean, err := utils.SanitizeUserPath(req.Path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
destClean, err := utils.SanitizeUserPath(req.Destination)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination: %v", err)
|
||||
}
|
||||
req.Path = pathClean
|
||||
req.Destination = destClean
|
||||
|
||||
idxFrom := indexing.GetIndex(req.FromSource)
|
||||
if idxFrom == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", req.FromSource)
|
||||
}
|
||||
userScopeFrom, err := d.user.GetScopeForSourceName(req.FromSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
fullArchivePath := utils.JoinPathAsUnix(userScopeFrom, req.Path)
|
||||
if store.Access != nil && !store.Access.Permitted(idxFrom.Path, fullArchivePath, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to archive %s", req.Path)
|
||||
}
|
||||
archiveReal, _, err := idxFrom.GetRealPath(fullArchivePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("archive path not found: %v", err)
|
||||
}
|
||||
|
||||
idxTo := indexing.GetIndex(req.ToSource)
|
||||
if idxTo == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", req.ToSource)
|
||||
}
|
||||
userScopeTo, err := d.user.GetScopeForSourceName(req.ToSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullDestPath := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestPath, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
// Destination may not exist yet; resolve parent dir then build destination path.
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
}
|
||||
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
|
||||
}
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
}
|
||||
destReal := filepath.Join(destParentReal, filepath.Base(req.Destination))
|
||||
if err = os.MkdirAll(destReal, fileutils.PermDir); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(archiveReal)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("archive not found: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is not an archive file: %s", req.Path)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(archiveReal)
|
||||
var extractErr error
|
||||
if strings.HasSuffix(lower, ".zip") {
|
||||
extractErr = extractZip(archiveReal, destReal)
|
||||
} else if strings.HasSuffix(lower, ".tar.gz") || (strings.HasSuffix(lower, ".tgz")) {
|
||||
extractErr = extractTarGz(archiveReal, destReal)
|
||||
} else {
|
||||
return http.StatusBadRequest, fmt.Errorf("unsupported archive format (use .zip or .tar.gz)")
|
||||
}
|
||||
if extractErr != nil {
|
||||
return http.StatusInternalServerError, extractErr
|
||||
}
|
||||
|
||||
if req.DeleteAfter {
|
||||
if err := os.Remove(archiveReal); err != nil {
|
||||
logger.Errorf("Failed to delete archive after extract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return renderJSON(w, r, map[string]string{"path": req.Destination, "source": req.ToSource}, http.StatusOK)
|
||||
}
|
||||
|
||||
// addFile adds a file or directory to a tar or zip archive, respecting access rules.
|
||||
// For shares, path is already resolved; for users, access is checked via store.Access.
|
||||
func addFile(source string, path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
// Check access control directly for each file and silently skip if access is denied
|
||||
if d.share == nil && store.Access != nil {
|
||||
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
return nil // Silently skip this file/folder
|
||||
}
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
_, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the base name of the top-level folder or file
|
||||
baseName := filepath.Base(realPath)
|
||||
|
||||
if info.IsDir() {
|
||||
// Walk through directory contents
|
||||
return filepath.Walk(realPath, func(filePath string, fileInfo os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate the relative path
|
||||
relPath, err := filepath.Rel(realPath, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Normalize for tar: convert \ to /
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
|
||||
// Skip adding `.` (current directory)
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check access control for each file/folder during walk
|
||||
if d.share == nil {
|
||||
indexRelPath := utils.JoinPathAsUnix(path, relPath)
|
||||
indexRelPath = filepath.ToSlash(indexRelPath)
|
||||
if !store.Access.Permitted(idx.Path, indexRelPath, d.user.Username) {
|
||||
if fileInfo.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend base folder name unless flatten is true
|
||||
if !flatten {
|
||||
relPath = filepath.Join(baseName, relPath)
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
if tarWriter != nil {
|
||||
header := &tar.Header{
|
||||
Name: relPath + "/",
|
||||
Mode: int64(fileutils.PermDir),
|
||||
Typeflag: tar.TypeDir,
|
||||
ModTime: fileInfo.ModTime(),
|
||||
}
|
||||
return tarWriter.WriteHeader(header)
|
||||
}
|
||||
if zipWriter != nil {
|
||||
_, err := zipWriter.Create(relPath + "/")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
|
||||
})
|
||||
}
|
||||
// For a single file, use the base name as the archive path
|
||||
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
|
||||
}
|
||||
|
||||
// addSingleFile writes one file into the given zip or tar writer.
|
||||
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "is a directory") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if tarWriter != nil {
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.ToSlash(archivePath)
|
||||
if err = tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
return err
|
||||
}
|
||||
|
||||
if zipWriter != nil {
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = archivePath
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeArchiveSize returns the combined size of the given paths, respecting access rules.
|
||||
// Paths denied by access are skipped (not counted).
|
||||
func computeArchiveSize(source string, fileList []string, d *requestContext) (int64, error) {
|
||||
var estimatedSize int64
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return 0, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
var userScope string
|
||||
var err error
|
||||
if d.share == nil {
|
||||
userScope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range fileList {
|
||||
var fullPath string
|
||||
if d.share == nil {
|
||||
fullPath = utils.JoinPathAsUnix(userScope, path)
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, fullPath, d.user.Username) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
fullPath = path
|
||||
}
|
||||
|
||||
realPath, isDir, err := idx.GetRealPath(fullPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
indexPath := idx.MakeIndexPath(realPath, isDir)
|
||||
info, ok := idx.GetReducedMetadata(indexPath, isDir)
|
||||
if !ok {
|
||||
info, err = idx.GetFsInfo(indexPath, false, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
|
||||
}
|
||||
}
|
||||
estimatedSize += info.Size
|
||||
}
|
||||
return estimatedSize, nil
|
||||
}
|
||||
|
||||
// createZip writes a ZIP archive to tmpPath containing the given paths; access rules apply.
|
||||
func createZip(d *requestContext, source string, tmpPath string, filenames ...string) error {
|
||||
file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(file)
|
||||
|
||||
for _, fname := range filenames {
|
||||
err := addFile(source, fname, d, nil, zipWriter, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize ZIP archive: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTarGz writes a tar.gz archive to tmpPath containing the given paths; access rules apply.
|
||||
func createTarGz(d *requestContext, source string, tmpPath string, filenames ...string) error {
|
||||
file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzWriter := gzip.NewWriter(file)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
for _, fname := range filenames {
|
||||
err := addFile(source, fname, d, tarWriter, nil, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize TAR archive: %w", err)
|
||||
}
|
||||
if err := gzWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize GZIP compression: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTarGzWithLevel writes a tar.gz archive with the given gzip compression level (0=default, 1-9).
|
||||
func createTarGzWithLevel(d *requestContext, source string, destPath string, level int, filenames ...string) error {
|
||||
file, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var gzWriter *gzip.Writer
|
||||
if level >= 1 && level <= 9 {
|
||||
gzWriter, err = gzip.NewWriterLevel(file, level)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
gzWriter = gzip.NewWriter(file)
|
||||
}
|
||||
defer gzWriter.Close()
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
defer tarWriter.Close()
|
||||
|
||||
for _, fname := range filenames {
|
||||
err := addFile(source, fname, d, tarWriter, nil, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize TAR archive: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildAndStreamArchive resolves paths, creates a zip or tar.gz archive, and streams it to w.
|
||||
// It respects access rules and max archive size. Used only by the raw handler for multi-file/directory download.
|
||||
func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
|
||||
firstFilePath := fileList[0]
|
||||
var userscope string
|
||||
var err error
|
||||
|
||||
if d.share == nil {
|
||||
userscope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
realPath, isDir, err := idx.GetRealPath(firstFilePath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
estimatedSize, err := computeArchiveSize(source, fileList, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
if config.Server.MaxArchiveSizeGB > 0 {
|
||||
maxSize := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
|
||||
if estimatedSize > maxSize {
|
||||
return http.StatusRequestEntityTooLarge, fmt.Errorf("pre-archive combined size of files exceeds maximum limit of %d GB", config.Server.MaxArchiveSizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
algo := r.URL.Query().Get("algo")
|
||||
var extension string
|
||||
switch algo {
|
||||
case "zip", "true", "":
|
||||
extension = ".zip"
|
||||
case "tar.gz":
|
||||
extension = ".tar.gz"
|
||||
default:
|
||||
return http.StatusInternalServerError, errors.New("format not implemented")
|
||||
}
|
||||
|
||||
baseDirName := filepath.Base(filepath.Dir(firstFilePath))
|
||||
if baseDirName == "" || baseDirName == "/" {
|
||||
baseDirName = "download"
|
||||
}
|
||||
if len(fileList) == 1 && isDir {
|
||||
baseDirName = filepath.Base(realPath)
|
||||
}
|
||||
originalFileName := baseDirName + extension
|
||||
|
||||
archiveData := filepath.Join(config.Server.CacheDir, utils.InsecureRandomIdentifier(10))
|
||||
if extension == ".zip" {
|
||||
archiveData = archiveData + ".zip"
|
||||
err = createZip(d, source, archiveData, fileList...)
|
||||
} else {
|
||||
archiveData = archiveData + ".tar.gz"
|
||||
err = createTarGz(d, source, archiveData, fileList...)
|
||||
}
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
fd, err := os.Open(archiveData)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
fileInfo, err := fd.Stat()
|
||||
if err != nil {
|
||||
os.Remove(archiveData)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
sizeInMB := fileInfo.Size() / 1024 / 1024
|
||||
if sizeInMB > 500 {
|
||||
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, originalFileName)
|
||||
}
|
||||
|
||||
setContentDisposition(w, r, originalFileName)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
var reader io.Reader = fd
|
||||
if d.share != nil && d.share.MaxBandwidth > 0 {
|
||||
limit := rate.Limit(d.share.MaxBandwidth * 1024)
|
||||
burst := d.share.MaxBandwidth * 1024
|
||||
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
|
||||
}
|
||||
_, err = io.Copy(w, reader)
|
||||
os.Remove(archiveData)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to copy archive data to response: %v", err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// archiveCreateRequest is the body for POST /resources/archive (server-side create).
|
||||
type archiveCreateRequest struct {
|
||||
// Source name where the paths to archive live (required). Example: "default"
|
||||
FromSource string `json:"fromSource"`
|
||||
// Source name where the archive file will be written (optional; default: fromSource). Example: "backups"
|
||||
ToSource string `json:"toSource"`
|
||||
// Paths of files or directories to add; directories are walked; access-denied entries skipped (required). Example: ["/docs/file.txt", "/photos"]
|
||||
Paths []string `json:"paths"`
|
||||
// Full path where the archive will be created; use .zip or .tar.gz extension (required). Example: "/backups/my-archive.zip"
|
||||
Destination string `json:"destination"`
|
||||
// Archive format: "zip" or "tar.gz" (optional; inferred from destination if omitted). Example: "zip"
|
||||
Format string `json:"format"`
|
||||
// Gzip compression level for tar.gz only, 0-9; 0 = default; ignored for zip (optional). Example: 6
|
||||
Compression int `json:"compression"`
|
||||
// If true, delete the source files/directories after successful archive creation (optional; requires delete permission). Example: true
|
||||
DeleteAfter bool `json:"deleteAfter"`
|
||||
}
|
||||
|
||||
// unarchiveRequest is the body for POST /resources/unarchive (server-side extract).
|
||||
type unarchiveRequest struct {
|
||||
// Source name where the archive file lives (required). Example: "default"
|
||||
FromSource string `json:"fromSource"`
|
||||
// Source name where contents will be extracted (optional; default: fromSource). Example: "restored"
|
||||
ToSource string `json:"toSource"`
|
||||
// Path to the archive file on fromSource; .zip, .tar.gz, or .tgz (required). Example: "/downloads/data.zip"
|
||||
Path string `json:"path"`
|
||||
// Directory path on toSource to extract into (required). Example: "/projects/imported"
|
||||
Destination string `json:"destination"`
|
||||
// If true, delete the archive file after successful extraction (optional; default: false). Example: true
|
||||
DeleteAfter bool `json:"deleteAfter"`
|
||||
}
|
||||
|
||||
// safeExtractPath ensures name does not escape destDir (no ".." or absolute).
|
||||
func safeExtractPath(destDir, name string) (string, error) {
|
||||
clean := filepath.Clean(name)
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
}
|
||||
abs := filepath.Join(destDir, clean)
|
||||
destAbs, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
entryAbs, err := filepath.Abs(abs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(entryAbs, destAbs) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func extractZip(archivePath, destDir string) error {
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
destPath, err := safeExtractPath(destDir, f.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
if err = os.MkdirAll(destPath, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, rc)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTarGz(archivePath, destDir string) error {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destPath, err := safeExtractPath(destDir, h.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(destPath, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+195
-97
@@ -1,15 +1,16 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
libError "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v4/request"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
@@ -54,9 +55,19 @@ func extractToken(r *http.Request) (string, error) {
|
||||
hasToken = true
|
||||
// Split the header to get "Bearer {token}"
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
token := parts[1]
|
||||
return token, nil
|
||||
if len(parts) > 1 {
|
||||
// some clients don't respect RFC regarding cases
|
||||
switch strings.ToLower(parts[0]) {
|
||||
case "bearer":
|
||||
return parts[1], nil
|
||||
case "basic":
|
||||
// compatibility for basic auth
|
||||
// user ignored, password is token
|
||||
_, token, ok := r.BasicAuth()
|
||||
if ok {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,25 +86,22 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
if err.Error() != "the resource does not exist" {
|
||||
return nil, err
|
||||
}
|
||||
if config.Auth.Methods.ProxyAuth.CreateUser {
|
||||
user := users.User{
|
||||
LoginMethod: users.LoginMethodProxy,
|
||||
Username: proxyUser,
|
||||
}
|
||||
settings.ApplyUserDefaults(&user)
|
||||
if user.Username == config.Auth.AdminUsername {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
err = storage.CreateUser(user, user.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.user, err = store.Users.Get(proxyUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("proxy authentication failed - no user found")
|
||||
// Auto-create user on first proxy authentication
|
||||
user := users.User{
|
||||
LoginMethod: users.LoginMethodProxy,
|
||||
Username: proxyUser,
|
||||
}
|
||||
settings.ApplyUserDefaults(&user)
|
||||
if user.Username == config.Auth.AdminUsername {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
err = storage.CreateUser(user, user.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.user, err = store.Users.Get(proxyUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if data.user.LoginMethod != users.LoginMethodProxy {
|
||||
@@ -109,27 +117,121 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
return data.user, nil
|
||||
}
|
||||
|
||||
// setupJwtUser retrieves or creates a user based on external JWT token claims
|
||||
func setupJwtUser(r *http.Request, data *requestContext, username string, claims map[string]interface{}) (*users.User, error) {
|
||||
var err error
|
||||
// Retrieve the user from the store
|
||||
data.user, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
if err.Error() != "the resource does not exist" {
|
||||
return nil, err
|
||||
}
|
||||
// Auto-create user on first JWT authentication
|
||||
user := users.User{
|
||||
LoginMethod: users.LoginMethodJwt,
|
||||
Username: username,
|
||||
}
|
||||
settings.ApplyUserDefaults(&user)
|
||||
|
||||
// Check if user should be admin based on groups
|
||||
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
|
||||
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
|
||||
for _, group := range groups {
|
||||
if group == config.Auth.Methods.JwtAuth.AdminGroup {
|
||||
user.Permissions.Admin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if username matches admin username
|
||||
if user.Username == config.Auth.AdminUsername {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
|
||||
err = storage.CreateUser(user, user.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.user, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Verify login method matches
|
||||
if data.user.LoginMethod != users.LoginMethodJwt {
|
||||
return nil, errors.ErrWrongLoginMethod
|
||||
}
|
||||
|
||||
// Check if user is in allowed groups (if UserGroups is configured)
|
||||
if len(config.Auth.Methods.JwtAuth.UserGroups) > 0 {
|
||||
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
|
||||
allowed := false
|
||||
for _, userGroup := range groups {
|
||||
for _, allowedGroup := range config.Auth.Methods.JwtAuth.UserGroups {
|
||||
if userGroup == allowedGroup {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if allowed {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return nil, fmt.Errorf("user is not in allowed groups")
|
||||
}
|
||||
}
|
||||
|
||||
// Update admin status if needed
|
||||
shouldBeAdmin := false
|
||||
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
|
||||
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
|
||||
for _, group := range groups {
|
||||
if group == config.Auth.Methods.JwtAuth.AdminGroup {
|
||||
shouldBeAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.user.Username == config.Auth.AdminUsername {
|
||||
shouldBeAdmin = true
|
||||
}
|
||||
|
||||
if shouldBeAdmin != data.user.Permissions.Admin {
|
||||
data.user.Permissions.Admin = shouldBeAdmin
|
||||
err = store.Users.Update(data.user, true, "Permissions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return data.user, nil
|
||||
}
|
||||
|
||||
// loginHandler handles user authentication via password.
|
||||
// @Summary User login
|
||||
// @Description Authenticate a user with a username and password.
|
||||
// @Description Authenticate a user with a username and password. The password must be URL-encoded and sent in the X-Password header to support special characters (e.g., ^, %, £, €, etc.).
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param username query string true "Username"
|
||||
// @Param recaptcha query string false "ReCaptcha response token (if enabled)"
|
||||
// @Param X-Password header string true "URL-encoded password"
|
||||
// @Param X-Secret header string false "TOTP code (if 2FA is enabled)"
|
||||
// @Success 200 {string} string "JWT token for authentication"
|
||||
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/login [post]
|
||||
func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.user.LoginMethod == users.LoginMethodProxy {
|
||||
return printToken(w, r, d.user)
|
||||
}
|
||||
passwordUser := d.user.LoginMethod == users.LoginMethodPassword
|
||||
enforcedOtp := config.Auth.Methods.PasswordAuth.EnforcedOtp
|
||||
missingOtp := d.user.TOTPSecret == ""
|
||||
if passwordUser && enforcedOtp && missingOtp {
|
||||
return http.StatusForbidden, errors.ErrNoTotpConfigured
|
||||
}
|
||||
return printToken(w, r, d.user) // Pass the data object
|
||||
return printToken(w, r, d.user)
|
||||
}
|
||||
|
||||
// logoutHandler handles user logout
|
||||
@@ -141,7 +243,9 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
// @Success 200 {object} map[string]string "{"logoutUrl": "http://..."}"
|
||||
// @Router /api/auth/logout [post]
|
||||
func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
defer auth.RevokeAPIKey(d.token)
|
||||
if err := auth.RevokeApiToken(store.Access, d.token); err != nil {
|
||||
logger.Errorf("Failed to revoke token on logout: %v", err)
|
||||
}
|
||||
|
||||
// Clear the authentication cookie by setting it to expire in the past
|
||||
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
|
||||
@@ -154,7 +258,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
Value: "",
|
||||
Domain: strings.Split(host, ":")[0],
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: time.Unix(0, 0), // Expire immediately
|
||||
MaxAge: -1, // Delete cookie
|
||||
}
|
||||
@@ -171,6 +275,16 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
if oidcRedirectUrl != "" {
|
||||
logoutUrl = oidcRedirectUrl
|
||||
}
|
||||
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodLdap {
|
||||
ldapRedirectUrl := config.Auth.Methods.LdapAuth.LogoutRedirectUrl
|
||||
if ldapRedirectUrl != "" {
|
||||
logoutUrl = ldapRedirectUrl
|
||||
}
|
||||
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodJwt {
|
||||
jwtRedirectUrl := config.Auth.Methods.JwtAuth.LogoutRedirectUrl
|
||||
if jwtRedirectUrl != "" {
|
||||
logoutUrl = jwtRedirectUrl
|
||||
}
|
||||
}
|
||||
if logoutUrl == "" {
|
||||
logger.Debug("no logout url found, using default")
|
||||
@@ -241,7 +355,7 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
// @Success 200 {string} string "New JWT token generated"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid token"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/renew [post]
|
||||
// @Router /api/auth/renew [post]
|
||||
func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// check if x-auth header is present and token is
|
||||
return printToken(w, r, d.user)
|
||||
@@ -249,7 +363,7 @@ func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
|
||||
func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int, error) {
|
||||
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
|
||||
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions)
|
||||
tokenString, _, err := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "key already exists with same name") {
|
||||
return http.StatusConflict, err
|
||||
@@ -261,86 +375,52 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int,
|
||||
// This allows backend to identify expired sessions and provide better user feedback
|
||||
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
|
||||
|
||||
// Set the authentication token as an HTTP cookie
|
||||
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: signed.Key,
|
||||
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: expiresTime,
|
||||
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
|
||||
// Secure: true, // Enable this in production with HTTPS
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
setSessionCookie(w, r, tokenString, expiresTime)
|
||||
|
||||
// Still return token in body for backward compatibility and state management
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if _, err := w.Write([]byte(signed.Key)); err != nil {
|
||||
if _, err := w.Write([]byte(tokenString)); err != nil {
|
||||
return 401, errors.ErrUnauthorized
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions) (users.AuthToken, error) {
|
||||
_, ok := user.ApiKeys[name]
|
||||
if ok {
|
||||
return users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
|
||||
}
|
||||
now := time.Now()
|
||||
expires := now.Add(duration)
|
||||
claim := users.AuthToken{
|
||||
Permissions: perms,
|
||||
Created: now.Unix(),
|
||||
Expires: expires.Unix(),
|
||||
Name: name,
|
||||
BelongsTo: user.ID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expires),
|
||||
Issuer: "FileBrowser Quantum",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
|
||||
tokenString, err := token.SignedString([]byte(config.Auth.Key))
|
||||
if err != nil {
|
||||
return claim, err
|
||||
}
|
||||
claim.Key = tokenString
|
||||
if strings.HasPrefix(name, "WEB_TOKEN") {
|
||||
// don't add to api tokens, its a short lived web token
|
||||
return claim, err
|
||||
}
|
||||
// Perform the user update
|
||||
err = store.Users.AddApiKey(user.ID, name, claim)
|
||||
if err != nil {
|
||||
return claim, err
|
||||
}
|
||||
return claim, err
|
||||
}
|
||||
|
||||
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||
if l.PasswordHash == "" {
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("token") == l.Token {
|
||||
return 200, nil
|
||||
tokenParam := r.URL.Query().Get("token")
|
||||
if tokenParam != "" {
|
||||
// Verify the token signature if it's in the new signed format
|
||||
if strings.Contains(tokenParam, ".") {
|
||||
parts := strings.Split(tokenParam, ".")
|
||||
if len(parts) == 2 {
|
||||
payload := parts[0]
|
||||
signature := parts[1]
|
||||
|
||||
// Verify HMAC signature
|
||||
mac := hmac.New(sha256.New, []byte(config.Auth.Key))
|
||||
mac.Write([]byte(payload))
|
||||
expectedSignature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
if hmac.Equal([]byte(signature), []byte(expectedSignature)) {
|
||||
// Token signature is valid, now check if it matches stored token
|
||||
if tokenParam == l.Token {
|
||||
return 200, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy token format (plain base64) - direct comparison
|
||||
if tokenParam == l.Token {
|
||||
return 200, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
password := r.Header.Get("X-SHARE-PASSWORD")
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
if password == "" {
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
|
||||
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return http.StatusUnauthorized, nil
|
||||
@@ -349,3 +429,21 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||
}
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
// setSessionCookie - sets the authentication token as an HTTP cookie
|
||||
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: token,
|
||||
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
|
||||
Expires: expiresTime,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
Loaded 100 of 400 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user