Compare commits

...
24 Commits
Author SHA1 Message Date
Graham Steffaniak 683c837d0c updated readme (#1937) 2026-02-05 12:07:18 -06:00
Graham Steffaniak b95ce79d50 updated favicon logic more 2026-02-05 11:48:12 -06:00
Graham Steffaniak a0c6b123de updated share apis (#1936) 2026-02-05 10:38:46 -06:00
Graham Steffaniak 5832a2ebfd updated with some api changes 2026-02-05 10:19:07 -06:00
Graham Steffaniak a9d738d419 Make icon package (#1932) 2026-02-04 18:14:50 -06:00
Graham Steffaniak 952484cd3d fix motion preview sticking (#1931) 2026-02-04 13:13:51 -06:00
+4 f73ca51f94 Beta/v1.2.2 (#1928)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-02-03 18:07:51 -06:00
+4 ad839f92ac Beta/v1.2.1 (#1889)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-25 07:13:59 -06:00
dependabot[bot] eb47df7172 Bump lodash from 4.17.21 to 4.17.23 in /frontend in the npm_and_yarn group across 1 directory (#1882)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 10:26:15 -06:00
+4 f2f5d776ca Beta/v1.2.0 (#1858)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-20 15:56:52 -06:00
Graham Steffaniak 4f73b4cc6b Beta/v1.1.6 (#1812) 2026-01-06 13:08:54 -06:00
SmilingPie 5d7d94d86a fix(frontend): fix preview page sorting (#1787) 2026-01-03 10:09:29 -06:00
Graham SteffaniakandKevin B. 9723799e4f Beta/v1.1.5 (#1781)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2025-12-31 13:19:00 -06:00
Graham Steffaniak 3e5f0312f3 updated token claim process (#1774) 2025-12-31 11:13:10 -06:00
MrPanda376 131c3a3686 Bug fix: file permissions not working (#1762) 2025-12-28 15:43:20 -06:00
ATUR 7e5a579dde fix(http): handle Close() errors in archive creation (#1745) 2025-12-20 14:10:37 -06:00
Adam ABICHOU cb2d4dc518 Move custom CSS and user-selected theme styles to the end of the body… (#1744) 2025-12-20 13:53:20 -06:00
Graham SteffaniakandKevin B. 8587ffc092 Beta/v1.1.4 (#1724)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2025-12-15 11:14:40 -06:00
Graham Steffaniak de6fefad4e Beta/v1.1.3 (#1695) 2025-12-02 09:55:48 -06:00
9d18a369c7 Beta/v1.1.2 (#1679)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-30 13:34:00 -05:00
StefPanther 9b599347f7 Update French translation (#1682) 2025-11-30 11:18:29 -05:00
Graham Steffaniak 37596b1e23 Update README.md (#1667) 2025-11-26 19:04:24 -06:00
d35fb56587 Beta/v1.1.1 (#1655)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-24 15:01:49 -06:00
Graham Steffaniak 5dab4c7b30 Update README.md (#1624) 2025-11-19 14:30:37 -06:00
283 changed files with 27620 additions and 23027 deletions

No files matched your search

+42 -8
View File
@@ -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"
+1
View File
@@ -27,6 +27,7 @@ frontend/tsconfig.tsbuildinfo
/backend/srv
/backend/http/dist
/backend/http/embed
/backend/debug
.DS_Store
node_modules
+214
View File
@@ -2,6 +2,220 @@
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.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**:
+7 -3
View File
@@ -2,7 +2,7 @@
[![Go Report Card](https://goreportcard.com/badge/github.com/gtsteffaniak/filebrowser/backend)](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![latest version](https://img.shields.io/github/release/gtsteffaniak/filebrowser/all.svg)](https://github.com/gtsteffaniak/filebrowser/releases)
[![latest version](https://img.shields.io/github/v/release/gtsteffaniak/filebrowser)](https://github.com/gtsteffaniak/filebrowser/releases)
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
@@ -17,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 -2
View File
@@ -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
@@ -27,8 +27,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,6 +43,7 @@ COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]
RUN [ "exiftool", "-ver" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
+3 -1
View File
@@ -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
+2 -1
View File
@@ -17,12 +17,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
+15 -10
View File
@@ -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'
+5
View File
@@ -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
View File
@@ -6,7 +6,7 @@ tmp_dir = "tmp"
bin = "tmp/filebrowser"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs"]
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
+1 -1
View File
@@ -18,4 +18,4 @@ tmp_dir = "tmp"
time = true
[misc]
clean_on_exit = true
clean_on_exit = false
+1
View File
@@ -3,3 +3,4 @@ air.log
filebrowser
generated.yaml
test_config.yaml
/data
+59 -4
View File
@@ -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)
}
+138
View File
@@ -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
}
+101 -32
View File
@@ -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")
}
+309 -283
View File
@@ -2,138 +2,190 @@ 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++
}
}
if isDir {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
} else {
// For files, get info from parent directory to ensure HasPreview is set correctly
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
// 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 {
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
// 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 FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
if access == nil {
return response, fmt.Errorf("access not provided")
}
if user == nil {
return response, fmt.Errorf("user not provided")
}
if opts.Path == "" {
return response, fmt.Errorf("path not provided")
}
if opts.Source == "" {
return response, fmt.Errorf("source not provided")
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
// Resolve user scope
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
if scopeErr != nil || userScope == "" {
return response, fmt.Errorf("user has no access to source: %v", opts.Source)
}
safePath, err := utils.SanitizeUserPath(opts.Path)
if err != nil {
return response, 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 response, errors.ErrAccessDenied
}
// 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,
IsRoutineScan: false, // API call
})
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(response, idx, user.Username); err != nil {
return response, err
}
}
// 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)
}
// Finalize response (OnlyOffice ID, scope stripping)
finalizeResponse(response, info, response.RealPath, user, userScope)
return response, nil
}
@@ -150,7 +202,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 {
@@ -177,13 +229,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 +267,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 +307,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 +323,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 +337,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 +359,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 +374,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 +406,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 +432,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 +441,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 +499,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 +509,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 +570,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 +637,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 +645,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 +683,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 +711,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 {
+19 -8
View File
@@ -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
+36
View File
@@ -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
}
+15 -6
View File
@@ -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,31 @@
//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
}
@@ -0,0 +1,35 @@
//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
}
+5
View File
@@ -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.
+6 -3
View File
@@ -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,9 +183,10 @@ 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 != "" {
-108
View File
@@ -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]
-4
View File
@@ -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
View File
@@ -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"`
+67 -8
View File
@@ -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.")
}
@@ -143,12 +182,32 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
cacheDir := settings.Config.Server.CacheDir
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())
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+60
View File
@@ -37,6 +37,9 @@ func validateUserInfo(newDB bool) {
if updateShowFirstLogin(user) {
updateUser = true
}
if updateSidebarLinks(user) {
updateUser = true
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
@@ -186,3 +189,60 @@ 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 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 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
}
+282 -90
View File
@@ -3,6 +3,7 @@ package settings
import (
"bytes"
"fmt"
"io"
"log"
"os"
"path/filepath"
@@ -51,6 +52,7 @@ func Initialize(configFile string) {
setupServer()
setupAuth(false)
setupSources(false)
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
setupUrls()
setupFrontend(false)
setupMedia()
@@ -60,6 +62,11 @@ 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 +94,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) {
@@ -156,42 +278,42 @@ func setupFrontend(generate bool) {
}
// Load custom favicon if configured
loadCustomFavicon()
// Generate PWA icons from custom favicon if needed
generatePWAIcons()
}
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
// 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
}
}
@@ -533,7 +655,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 +680,6 @@ func setDefaults(generate bool) Settings {
Frontend: Frontend{
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
@@ -583,27 +709,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 +736,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 +759,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 +772,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 = ""
@@ -669,18 +788,29 @@ func loadCustomFavicon() {
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
}
// generatePWAIcons sets up PWA icon paths (actual generation happens after preview service starts)
func generatePWAIcons() {
// Set PWA icons directory in cache
Env.PWAIconsDir = filepath.Join(Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
Env.PWAIcon192 = "icons/pwa-icon-192.png"
Env.PWAIcon256 = "icons/pwa-icon-256.png"
Env.PWAIcon512 = "icons/pwa-icon-512.png"
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", Env.PWAIconsDir, err)
}
}
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 +820,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 +837,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 +938,7 @@ func setConditionals(config *Source) {
resolved.FolderNames[rule.FolderName] = rule
}
}
config.Config.ResolvedConditionals = resolved
config.Config.ResolvedRules = resolved
}
func modifyExcludeInclude(config *Source) {
@@ -798,20 +970,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:]
}
+16 -2
View File
@@ -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 {
+45 -151
View File
@@ -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
},
})
}
+3 -59
View File
@@ -649,11 +649,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,16 +702,15 @@ 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)
@@ -1136,53 +1130,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
}
+71 -46
View File
@@ -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
// 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"`
@@ -92,14 +105,15 @@ type Media struct {
}
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 +124,7 @@ func (i ImagePreviewType) String() string {
// AllImagePreviewTypes contains all supported image preview types.
var AllImagePreviewTypes = []ImagePreviewType{
HEICImagePreview,
JPEGImagePreview,
//RAWImagePreview,
}
@@ -181,24 +196,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 +229,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 +269,10 @@ 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
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
}
type StylingConfig struct {
@@ -331,5 +357,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
}
+11 -11
View File
@@ -26,7 +26,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -52,7 +52,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -81,16 +81,16 @@ integrations:
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// Check explicitly set formats
if !config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
if !*config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
t.Error("Expected mp4 to be enabled")
}
if config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
t.Error("Expected webm to be disabled")
}
if config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
t.Error("Expected mkv to be disabled")
}
if config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
t.Error("Expected wmv to be disabled")
}
@@ -100,7 +100,7 @@ integrations:
M4VVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
}
for _, format := range unmentionedFormats {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled (not mentioned in config), but it was disabled", format)
}
}
@@ -155,13 +155,13 @@ integrations:
}
for _, format := range expectedEnabled {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled, but it was disabled", format)
}
}
for _, format := range expectedDisabled {
if config.Integrations.Media.Convert.VideoPreview[format] {
if *config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be disabled, but it was enabled", format)
}
}
@@ -182,7 +182,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -217,7 +217,7 @@ integrations:
for category, formats := range categories {
t.Run(category, func(t *testing.T) {
for _, format := range formats {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s (%s) to be enabled by default, but it was disabled", format, category)
}
}
+19 -2
View File
@@ -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,29 @@ func GetChecksum(fullPath, algo string) (string, error) {
// FileOptions are the options when getting a file info.
type FileOptions struct {
Username string // username for access control
Path string // realpath
Source string
IsDir bool
Expand bool
ReadHeader bool
Content bool
Recursive bool // whether to recursively index directories
Metadata bool // whether to get metadata
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
ShowHidden bool // whether to show hidden files (true = show, false = hide)
FollowSymlinks bool // whether to follow symlinks
}
// 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 resolves . and .. segments
clean := filepath.Clean(userPath)
// Reject absolute paths or attempts to escape scope
if strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("invalid path")
}
return clean, nil
}
+15
View File
@@ -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 "/"
+65
View File
@@ -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
}
+31 -111
View File
@@ -153,18 +153,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 +174,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 +220,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 +239,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 +256,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 +273,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 +286,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 +636,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 +645,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 +664,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 +673,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 +692,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 +701,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 +720,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 +729,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 +747,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 +757,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 +789,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 +822,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 +1031,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 +1097,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 +1154,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 +1201,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 +1232,7 @@ 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()
}
+17 -2
View File
@@ -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) {
+3 -2
View File
@@ -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
}
+34
View File
@@ -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"`
}
+203
View File
@@ -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
}
+5
View File
@@ -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,omitempty"` // disable login option in share (true = hide, false = show)
}
type CreateBody struct {
CommonShare
-7
View File
@@ -247,7 +247,6 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = newPath
@@ -264,14 +263,8 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
+10
View File
@@ -0,0 +1,10 @@
//go:build cgosql
// +build cgosql
package sql
import (
_ "github.com/mattn/go-sqlite3"
)
const SqliteDriver = "sqlite3"
+10
View File
@@ -0,0 +1,10 @@
//go:build !cgosql
// +build !cgosql
package sql
import (
_ "modernc.org/sqlite"
)
const SqliteDriver = "sqlite"
+121
View File
@@ -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
+103
View File
@@ -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...)
}
+565
View File
@@ -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
}
+3
View File
@@ -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
}
+55
View File
@@ -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
}
+4 -4
View File
@@ -110,19 +110,19 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "Scopes") {
adjustedScopes, err := 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
}
@@ -188,7 +188,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
}
@@ -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")
+156
View File
@@ -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 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 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
}
+3 -1
View File
@@ -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
}
+97 -11
View File
@@ -1,6 +1,7 @@
package users
import (
"fmt"
"strings"
jwt "github.com/golang-jwt/jwt/v4"
@@ -17,13 +18,16 @@ const (
)
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"`
Name string `json:"name,omitempty"`
BelongsTo uint `json:"belongsTo,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 {
@@ -112,14 +116,14 @@ 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.
@@ -137,3 +141,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
}
-4646
View File
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
+21 -2
View File
@@ -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
//}
}
}
+29
View File
@@ -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 != ""
}
+8
View File
@@ -378,3 +378,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)
}
+60 -46
View File
@@ -4,35 +4,40 @@ 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-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.33
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.47.0
golang.org/x/image v0.35.0
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.40.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
)
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,17 +48,17 @@ 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/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.4.0 // 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.2 // indirect
github.com/alecthomas/chroma/v2 v2.21.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.1 // 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
@@ -72,7 +77,7 @@ require (
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
@@ -91,6 +96,7 @@ 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/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
@@ -98,21 +104,21 @@ require (
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.18 // 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
@@ -125,29 +131,30 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.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.1 // 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/golangci-lint/v2 v2.8.0 // indirect
github.com/golangci/golines v0.14.0 // indirect
github.com/golangci/misspell v0.7.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 +162,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/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 +194,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.13.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/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 +217,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 +228,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/securego/gosec/v2 v2.22.11 // indirect
github.com/sirupsen/logrus v1.9.3 // 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 +247,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
@@ -258,15 +269,18 @@ require (
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 v0.0.0-20260112195511-716be5621a96 // 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/sync v0.19.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.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
modernc.org/libc v1.67.7 // 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
+144 -102
View File
@@ -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=
@@ -60,8 +62,8 @@ github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9
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,22 +73,22 @@ 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.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ=
github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
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.2 h1:H49xKRa4r3uFUycmCCA+JectVuZLNMPGdypLFpoHVcY=
github.com/air-verse/air v1.64.2/go.mod h1:SOwv08NTvKextanAY819AK0FK6XzQO5/tC6s1R+rk8o=
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.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.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=
@@ -94,8 +96,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
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.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE=
github.com/alexkohler/prealloc v1.0.1/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=
@@ -165,8 +167,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=
@@ -195,8 +197,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,6 +240,8 @@ 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/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.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -259,18 +265,18 @@ 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.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo=
github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
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=
@@ -290,29 +296,29 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
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 +329,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=
@@ -357,10 +363,10 @@ 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.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA=
github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8=
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 +391,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,10 +432,10 @@ 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/golangci-lint/v2 v2.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA=
github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI=
github.com/golangci/golines v0.14.0 h1:xt9d3RKBjhasA3qpoXs99J2xN2t6eBlpLHt0TrgyyXc=
github.com/golangci/golines v0.14.0/go.mod h1:gf555vPG2Ia7mmy2mzmhVQbVjuK8Orw0maR1G4vVAAQ=
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/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
@@ -468,6 +474,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
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/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 +494,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 +505,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=
@@ -520,6 +528,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=
@@ -548,10 +558,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 +578,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 +622,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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/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.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM=
github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0=
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,6 +651,8 @@ 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=
@@ -657,8 +671,8 @@ 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/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
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/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
@@ -682,8 +696,6 @@ 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=
@@ -722,6 +734,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 +759,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.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg=
github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw=
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=
@@ -767,8 +781,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 +793,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=
@@ -817,8 +831,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=
@@ -895,8 +909,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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
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 +921,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-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
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/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.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
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 +956,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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
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 +1000,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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
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.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.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 +1025,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=
@@ -1066,8 +1080,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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.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 +1098,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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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 +1155,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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
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=
@@ -1260,6 +1274,34 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
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=
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.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
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.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/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.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
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.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/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=
+121 -24
View File
@@ -7,10 +7,47 @@ import (
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-cache/cache"
)
var (
ApiTokenUserCache = cache.NewCache[uint](24 * time.Hour) // API token to user ID mapping gets cached for 24 hours
)
// getUserFromApiToken finds the user ID associated with a given API token
func getUserFromApiToken(token string) (uint, error) {
// Check cache first
if cached, ok := ApiTokenUserCache.Get(token); ok {
return cached, nil
}
// Get all users
allUsers, err := store.Users.Gets()
if err != nil {
return 0, fmt.Errorf("failed to get all users: %w", err)
}
// Iterate through all users and their API keys
for _, user := range allUsers {
if user.ApiKeys == nil {
continue
}
for _, apiKey := range user.ApiKeys {
if apiKey.Key == token {
// Cache the result
ApiTokenUserCache.Set(token, user.ID)
return user.ID, nil
}
}
}
// Token not found
return 0, fmt.Errorf("token not found")
}
// createApiKeyHandler creates an API key for the user.
// @Summary Create API key
// @Description Create an API key with specified name, duration, and permissions.
@@ -30,6 +67,7 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
name := r.URL.Query().Get("name")
durationStr := r.URL.Query().Get("days")
permissionsStr := r.URL.Query().Get("permissions")
minimal := r.URL.Query().Get("minimal") == "true"
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api keys")
@@ -41,18 +79,25 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if durationStr == "" {
return http.StatusBadRequest, fmt.Errorf("api 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 {
if permissionsStr == "" {
return http.StatusBadRequest, fmt.Errorf("api permissions must be valid for full tokens")
}
// 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
@@ -64,7 +109,7 @@ 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)
token, err := 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
@@ -117,7 +162,8 @@ type AuthTokenMin struct {
Name string `json:"name"`
Created int64 `json:"created"`
Expires int64 `json:"expires"`
Permissions users.Permissions `json:"Permissions"`
Permissions users.Permissions `json:"Permissions,omitempty"`
Minimal bool `json:"minimal"`
}
// listApiKeysHandler lists all API keys or retrieves details for a specific key.
@@ -142,24 +188,75 @@ func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if !ok {
return http.StatusNotFound, fmt.Errorf("api key not found")
}
// Determine if token is minimal by parsing the JWT claim
// For minimal tokens, the JWT claim doesn't have BelongsTo even though the database record does
minimal := false
if keyInfo.Key != "" {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(config.Auth.Key), nil
}
var claim users.AuthToken
token, err := jwt.ParseWithClaims(keyInfo.Key, &claim, keyFunc)
if err == nil && token.Valid {
// If the parsed claim doesn't have BelongsTo, it's a minimal token
minimal = claim.BelongsTo == 0
}
}
modifiedKey := AuthTokenMin{
Key: keyInfo.Key,
Name: key,
Created: keyInfo.Created,
Expires: keyInfo.Expires,
Permissions: keyInfo.Permissions,
Key: keyInfo.Key,
Name: key,
Created: 0,
Expires: 0,
Minimal: minimal,
}
// Safely access IssuedAt and ExpiresAt (they're pointers)
if keyInfo.IssuedAt != nil {
modifiedKey.Created = keyInfo.IssuedAt.Unix()
}
if keyInfo.ExpiresAt != nil {
modifiedKey.Expires = keyInfo.ExpiresAt.Unix()
}
// Only include Permissions for full tokens (not minimal tokens)
if !minimal {
modifiedKey.Permissions = keyInfo.Permissions
}
return renderJSON(w, r, modifiedKey)
}
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,
// Determine if token is minimal by parsing the JWT claim
// For minimal tokens, the JWT claim doesn't have BelongsTo even though the database record does
minimal := false
if value.Key != "" {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(config.Auth.Key), nil
}
var claim users.AuthToken
token, err := jwt.ParseWithClaims(value.Key, &claim, keyFunc)
if err == nil && token.Valid {
// If the parsed claim doesn't have BelongsTo, it's a minimal token
minimal = claim.BelongsTo == 0
}
}
modifiedKey := AuthTokenMin{
Key: value.Key,
Created: 0,
Expires: 0,
Minimal: minimal,
}
// Safely access IssuedAt and ExpiresAt (they're pointers)
if value.IssuedAt != nil {
modifiedKey.Created = value.IssuedAt.Unix()
}
if value.ExpiresAt != nil {
modifiedKey.Expires = value.ExpiresAt.Unix()
}
// Only include Permissions for full tokens (not minimal tokens)
if !minimal {
modifiedKey.Permissions = value.Permissions
}
modifiedList[key] = modifiedKey
}
return renderJSON(w, r, modifiedList)
+129 -48
View File
@@ -1,6 +1,9 @@
package http
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
libError "errors"
"fmt"
@@ -54,9 +57,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
}
}
}
}
@@ -111,10 +124,14 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
// 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"
@@ -249,7 +266,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)
signed, err := 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,23 +278,7 @@ 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, signed.Key, expiresTime)
// Still return token in body for backward compatibility and state management
w.Header().Set("Content-Type", "text/plain")
@@ -287,41 +288,78 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int,
return 0, nil
}
func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions) (users.AuthToken, error) {
func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions, minimal bool) (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",
var tokenString string
var err error
if minimal {
// Create minimal token with only JWT standard claims
minimalClaim := users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: "FileBrowser Quantum",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, minimalClaim)
tokenString, err = token.SignedString([]byte(config.Auth.Key))
if err != nil {
return users.AuthToken{}, err
}
} else {
// Create full token with permissions and user ID
fullClaim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: "FileBrowser Quantum",
},
},
Name: name,
Permissions: perms,
BelongsTo: user.ID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, fullClaim)
tokenString, err = token.SignedString([]byte(config.Auth.Key))
if err != nil {
return users.AuthToken{}, err
}
}
// Create the AuthToken to store in database (always includes permissions and user ID)
storedClaim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: "FileBrowser Quantum",
},
},
Key: tokenString,
Name: name,
Permissions: perms,
BelongsTo: user.ID,
}
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
return storedClaim, nil
}
// Perform the user update
err = store.Users.AddApiKey(user.ID, name, claim)
err = store.Users.AddApiKey(user.ID, name, storedClaim)
if err != nil {
return claim, err
return storedClaim, err
}
return claim, err
return storedClaim, nil
}
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
@@ -329,8 +367,34 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
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")
@@ -338,9 +402,6 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
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 +410,23 @@ 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.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
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)
}
+638 -232
View File
File diff suppressed because it is too large. Load diff
+493
View File
@@ -0,0 +1,493 @@
package http
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/events"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// fileWatchResponse represents the response from file watch
type fileWatchResponse struct {
Contents string `json:"contents,omitempty"` // Text content for text files
IsText bool `json:"isText"` // Whether the file is a text file
Metadata *fileWatchMetadata `json:"metadata,omitempty"` // File metadata for non-text files
}
// fileWatchMetadata contains file information for non-text files
type fileWatchMetadata struct {
Name string `json:"name"` // File name
Size int64 `json:"size"` // File size in bytes
Type string `json:"type"` // MIME type
Modified time.Time `json:"modified"` // Modification time
Path string `json:"path"` // File path
}
// readLastNLines reads the last N lines from a file efficiently
func readLastNLines(filePath string, n int) (string, error) {
if n <= 0 {
return "", fmt.Errorf("number of lines must be positive")
}
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
// Get file size
stat, err := file.Stat()
if err != nil {
return "", err
}
fileSize := stat.Size()
if fileSize == 0 {
return "", nil
}
// For small files, just read everything
// For larger files, read from the end
const maxReadSize = 1024 * 1024 // 1MB
var lines []string
if fileSize <= maxReadSize {
// Read entire file for small files
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return "", err
}
} else {
// For large files, read from the end
// Read the last chunk (up to 1MB) and count newlines
readSize := maxReadSize
if fileSize < int64(readSize) {
readSize = int(fileSize)
}
if _, err := file.Seek(-int64(readSize), io.SeekEnd); err != nil {
return "", err
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return "", err
}
}
// Return only the last N lines
if len(lines) > n {
lines = lines[len(lines)-n:]
}
// Truncate lines that exceed 250 characters
const maxLineLength = 250
for i, line := range lines {
if len(line) > maxLineLength {
lines[i] = line[:maxLineLength] + "..."
}
}
return strings.Join(lines, "\n"), nil
}
// fileWatchHandler handles file watching requests
// @Summary Watch a file
// @Description Returns the last N lines of a file
// @Tags Tools
// @Accept json
// @Produce json
// @Param path query string true "Path to the file"
// @Param source query string true "Source name"
// @Param lines query int false "Number of lines to read (default: 10, max: 50)"
// @Param latencyCheck query bool false "Return minimal response for latency checking"
// @Success 200 {object} fileWatchResponse
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 403 {object} map[string]string "Permission denied"
// @Failure 404 {object} map[string]string "File not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/tools/watch [get]
func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Check for latency check request - return immediately with minimal response
if r.URL.Query().Get("latencyCheck") != "" {
return http.StatusOK, nil
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
linesStr := r.URL.Query().Get("lines")
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
var err error
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
var parsedLines int
parsedLines, err = strconv.Atoi(linesStr)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid lines parameter: %v", err)
}
if parsedLines < 1 || parsedLines > 50 {
return http.StatusBadRequest, fmt.Errorf("lines must be between 1 and 50")
}
lines = parsedLines
}
// Validate user has access to the source
userScope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
// Check download permission (required to read file content)
if !d.user.Permissions.Download {
return http.StatusForbidden, fmt.Errorf("user is not allowed to read file content")
}
// Resolve the full path
scopePath := utils.JoinPathAsUnix(userScope, path)
// Get the index for the source
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s is not available", source)
}
// Check access control
if store.Access != nil {
if !store.Access.Permitted(idx.Path, scopePath, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to file")
}
}
// Get real file path
realPath, _, err := idx.GetRealPath(scopePath)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
// Verify it's a file, not a directory
info, err := os.Stat(realPath)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
if info.IsDir() {
return http.StatusBadRequest, fmt.Errorf("path is a directory, not a file")
}
// Check if file is a text file
isText, err := utils.IsTextFile(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
}
// Get MIME type from the index if available
mimeType := "application/octet-stream"
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
mimeType = reducedInfo.Type
}
response := fileWatchResponse{
IsText: isText,
}
if isText {
// Read the last N lines for text files
content, err := readLastNLines(realPath, lines)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error reading file: %v", err)
}
response.Contents = content
// Include metadata for text files too (for file size display)
response.Metadata = &fileWatchMetadata{
Name: info.Name(),
Size: info.Size(),
Type: mimeType,
Modified: info.ModTime(),
Path: path,
}
} else {
// For non-text files, return metadata
response.Metadata = &fileWatchMetadata{
Name: info.Name(),
Size: info.Size(),
Type: mimeType,
Modified: info.ModTime(),
Path: path,
}
}
w.Header().Set("Content-Type", "application/json")
return http.StatusOK, json.NewEncoder(w).Encode(response)
}
// fileWatchSSEEvent represents the SSE event payload for file watch updates
type fileWatchSSEEvent struct {
Contents string `json:"contents,omitempty"` // Text content for text files
IsText bool `json:"isText"` // Whether the file is a text file
Metadata *fileWatchMetadata `json:"metadata,omitempty"` // File metadata for non-text files
}
// fileWatchSSEHandler handles Server-Sent Events for file watching
// @Summary Watch a file via SSE
// @Description Establishes an SSE connection to receive periodic file updates
// @Tags Tools
// @Param path query string true "Path to the file"
// @Param source query string true "Source name"
// @Param lines query int false "Number of lines to read (default: 10, max: 50)"
// @Param interval query int false "Update interval in seconds (1, 2, 5, 10, 15, or 30, requires realtime permission for SSE)"
// @Success 200 "SSE stream"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 403 {object} map[string]string "Permission denied"
// @Failure 404 {object} map[string]string "File not found"
// @Router /api/tools/watch/sse [get]
func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Check realtime permissions
if !(d.user.Permissions.Realtime) {
return http.StatusForbidden, fmt.Errorf("realtime permission required for SSE file watching")
}
if !d.user.Permissions.Download {
return http.StatusForbidden, fmt.Errorf("user is not allowed to read file content")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
linesStr := r.URL.Query().Get("lines")
intervalStr := r.URL.Query().Get("interval")
var err error
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
var parsedLines int
parsedLines, err = strconv.Atoi(linesStr)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid lines parameter: %v", err)
}
if parsedLines < 1 || parsedLines > 50 {
return http.StatusBadRequest, fmt.Errorf("lines must be between 1 and 50")
}
lines = parsedLines
}
// Parse interval parameter (valid intervals: 1, 2, 5, 10, 15, 30 seconds)
interval := time.Second * 1 // default
if intervalStr != "" {
var parsedInterval int
parsedInterval, err = strconv.Atoi(intervalStr)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid interval parameter: %v", err)
}
validIntervals := map[int]bool{1: true, 2: true, 5: true, 10: true, 15: true, 30: true}
if !validIntervals[parsedInterval] {
return http.StatusBadRequest, fmt.Errorf("interval must be one of: 1, 2, 5, 10, 15, 30 seconds")
}
interval = time.Duration(parsedInterval) * time.Second
}
// Validate user has access to the source
userScope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
// Resolve the full path
scopePath := utils.JoinPathAsUnix(userScope, path)
// Get the index for the source
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s is not available", source)
}
// Check access control
if store.Access != nil {
if !store.Access.Permitted(idx.Path, scopePath, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to file")
}
}
// Get real file path
realPath, _, err := idx.GetRealPath(scopePath)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
// Verify it's a file, not a directory
info, err := os.Stat(realPath)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
if info.IsDir() {
return http.StatusBadRequest, fmt.Errorf("path is a directory, not a file")
}
// Set up SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("streaming not supported")
}
msgr := messenger{flusher: flusher, writer: w}
clientGone := r.Context().Done()
username := d.user.Username
// Initial ack
statusMsg, _ := json.Marshal(map[string]interface{}{"status": "connected"})
if err := msgr.sendEvent("fileWatch", string(statusMsg)); err != nil {
return http.StatusInternalServerError, fmt.Errorf("error sending initial message: %v", err)
}
// Register this client with the events system (like general SSE handler)
sendChan := events.Register(username, []string{source})
defer events.Unregister(username, sendChan)
// Get MIME type (we'll reuse this)
mimeType := "application/octet-stream"
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
mimeType = reducedInfo.Type
}
// Start background goroutine to periodically send file updates via events system
stopTicker := make(chan struct{})
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Send initial update
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
for {
select {
case <-stopTicker:
return
case <-ticker.C:
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
}
}
}()
// Main loop: listen for events from events system (like general SSE handler)
// Use server context if available, otherwise use request context
serverCtx := r.Context()
if d.ctx != nil {
serverCtx = d.ctx
}
for {
select {
case <-serverCtx.Done():
close(stopTicker)
_ = msgr.sendEvent("fileWatch", "\"server shutting down\"")
return http.StatusOK, nil
case <-clientGone:
close(stopTicker)
return http.StatusOK, nil
case msg, ok := <-sendChan:
if !ok {
close(stopTicker)
return http.StatusOK, nil
}
// Only process fileWatch events for this connection
if msg.EventType == "fileWatch" {
if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil {
close(stopTicker)
return http.StatusInternalServerError, fmt.Errorf("error sending event: %v", err)
}
}
}
}
}
// sendFileWatchUpdate reads the file and sends an update via the events system
func sendFileWatchUpdate(username, realPath, path string, lines int, mimeType string) {
// Re-check file (in case it was deleted or changed)
info, err := os.Stat(realPath)
if err != nil {
// File no longer exists
errorMsg, _ := json.Marshal(map[string]interface{}{"status": "error", "error": "file not found"})
events.SendToUsers("fileWatch", string(errorMsg), []string{username})
return
}
// Check if file is a text file
isText, err := utils.IsTextFile(realPath)
if err != nil {
// Error checking file, skip this update
return
}
// Build the SSE event
sseEvent := fileWatchSSEEvent{
IsText: isText,
}
if isText {
// Read the last N lines for text files
var content string
content, err = readLastNLines(realPath, lines)
if err != nil {
// Error reading, skip this update
return
}
sseEvent.Contents = content
sseEvent.Metadata = &fileWatchMetadata{
Name: info.Name(),
Size: info.Size(),
Type: mimeType,
Modified: info.ModTime(),
Path: path,
}
} else {
// For non-text files, send metadata
sseEvent.Metadata = &fileWatchMetadata{
Name: info.Name(),
Size: info.Size(),
Type: mimeType,
Modified: info.ModTime(),
Path: path,
}
}
// Serialize and send via events system
eventJSON, err := json.Marshal(sseEvent)
if err != nil {
return
}
// Send via events system to this user (like OnlyOffice does)
events.SendToUsers("fileWatch", string(eventJSON), []string{username})
}
+11 -2
View File
@@ -8,7 +8,6 @@ import (
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/events"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -56,9 +55,13 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
}
// Register this client with the events system
sendChan := events.Register(username, settings.GetSources(d.user))
sendChan := events.Register(username, d.user.GetSourceNames())
defer events.Unregister(username, sendChan)
// Heartbeat ticker - send every 30 seconds to keep connection alive
heartbeatTicker := time.NewTicker(30 * time.Second)
defer heartbeatTicker.Stop()
for {
select {
case <-d.ctx.Done():
@@ -68,6 +71,12 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
case <-clientGone:
return http.StatusOK, nil
case <-heartbeatTicker.C:
// Send minimal heartbeat message (ignored by frontend)
if err := msgr.sendEvent("heartbeat", "\"hb\""); err != nil {
return http.StatusInternalServerError, fmt.Errorf("error sending heartbeat: %v, user: %s", err, username)
}
case msg := <-events.BroadcastChan:
if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil {
return http.StatusInternalServerError, fmt.Errorf("error sending broadcast: %v, user: %s", err, username)
+93 -38
View File
@@ -7,12 +7,16 @@ import (
"encoding/json"
"fmt"
"io/fs"
"net"
"net/http"
"text/template"
"time"
_ "net/http/pprof"
"github.com/coreos/go-systemd/v22/activation"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/events"
"github.com/gtsteffaniak/go-logger/logger"
@@ -24,14 +28,9 @@ import (
//go:embed embed/*
var assets embed.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)
// GetEmbeddedAssets returns the embedded assets filesystem
func GetEmbeddedAssets() embed.FS {
return assets
}
var (
@@ -44,20 +43,19 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
store = storage
config = &settings.Config
var err error
// Determine filesystem mode and set asset paths
if settings.Env.EmbeddedFs {
// Embedded mode: Serve files from the embedded assets
assetFs, err = fs.Sub(assets, "embed")
if err != nil {
logger.Fatalf("fs.Sub failed: %v", err)
}
entries, err := fs.ReadDir(assetFs, ".")
if err != nil || len(entries) == 0 {
logger.Fatalf("Could not embed frontend. Does dist exist? %v", err)
}
} else {
// Dev mode: Serve files from http/dist directory
assetFs = dirFS{Dir: http.Dir("http/dist")}
// Start pprof server in a separate goroutine
if settings.Env.IsDevMode {
go func() {
if err = http.ListenAndServe("localhost:6060", nil); err != nil {
logger.Fatalf("pprof server error: %v", err)
}
}()
}
// Get the asset filesystem from fileutils
assetFs = fileutils.GetAssetFS()
if assetFs == nil {
logger.Fatal("Asset filesystem not initialized. Call fileutils.InitAssetFS first.")
}
// In development mode, we want to reload the templates on each request.
@@ -76,6 +74,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
devMode: settings.Env.IsDevMode,
}
// Initialize cached PWA manifest
InitializePWAManifest()
router := http.NewServeMux()
// API group routing
api := http.NewServeMux()
@@ -87,6 +88,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("POST /users", withSelfOrAdmin(usersPostHandler))
api.HandleFunc("PUT /users", withUser(userPutHandler))
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
api.HandleFunc("GET /health", healthHandler)
// Auth routes
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
@@ -108,10 +110,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("POST /resources", withUser(resourcePostHandler))
api.HandleFunc("PUT /resources", withUser(resourcePutHandler))
api.HandleFunc("PATCH /resources", withUser(resourcePatchHandler))
api.HandleFunc("POST /resources/bulk/delete", withUser(resourceBulkDeleteHandler))
api.HandleFunc("GET /raw", withUser(rawHandler))
api.HandleFunc("GET /preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
if version.Version == "testing" || version.Version == "untracked" {
if settings.Env.IsDevMode {
api.HandleFunc("GET /inspectIndex", inspectIndex)
api.HandleFunc("GET /mockData", mockData)
}
@@ -146,8 +149,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicAPI.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
publicAPI.HandleFunc("PUT /resources", withHashFile(publicPutHandler))
publicAPI.HandleFunc("DELETE /resources", withHashFile(publicDeleteHandler))
publicAPI.HandleFunc("POST /resources/bulk/delete", withHashFile(publicBulkDeleteHandler))
publicAPI.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
publicAPI.HandleFunc("GET /shareinfo", withOrWithoutUser(shareInfoHandler))
publicAPI.HandleFunc("GET /share/image", withHashFile(getShareImage))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
@@ -167,14 +172,23 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("GET /search", withUser(searchHandler))
api.HandleFunc("GET /duplicates", withUser(duplicatesHandler))
api.HandleFunc("GET /tools/watch", withUser(fileWatchHandler))
api.HandleFunc("GET /tools/watch/sse", withUser(fileWatchSSEHandler))
// Mount the public API sub-router
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicAPI))
// Mount the route groups
apiPath := config.Server.BaseURL + "api"
publicPath := config.Server.BaseURL + "public"
webDavPath := config.Server.BaseURL + "dav"
router.Handle(apiPath+"/", http.StripPrefix(apiPath, api))
router.Handle(publicPath+"/", http.StripPrefix(publicPath, publicRoutes))
// WebDav resources (catch-all)
// similar to NextCloud and others
// (reddec) do not trim /dav prefix here - webdav library for some reason does not like it.
router.Handle(webDavPath+"/{scope}/{path...}", wrapHandlerOpts(withUserHelper(createWebDAVHandler(webDavPath)), wrapperOpts{
requestBasicAuth: true,
}))
// Frontend share route redirect (DEPRECATED - maintain for backwards compatibility)
// Playwright tests need updating to remove this redirect.
@@ -184,14 +198,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
// Static and index file handlers
staticPrefix := config.Server.BaseURL + "static/"
router.Handle(staticPrefix, http.HandlerFunc(staticAssetHandler))
publicRoutes.Handle("GET /static/", http.HandlerFunc(staticAssetHandler))
// Standard browser favicon and manifest routes
router.HandleFunc("GET /favicon.ico", http.HandlerFunc(staticAssetHandler))
router.HandleFunc("GET /site.webmanifest", http.HandlerFunc(staticAssetHandler))
router.HandleFunc("GET /manifest.json", http.HandlerFunc(staticAssetHandler))
// Standard browser favicon route
router.HandleFunc("GET /favicon.svg", http.HandlerFunc(staticAssetHandler))
router.HandleFunc(config.Server.BaseURL, withOrWithoutUser(indexHandler))
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
@@ -211,9 +221,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
Handler: muxWithMiddleware(router),
}
listenAddress := config.Server.ListenAddress
if listenAddress == "0.0.0.0" {
listenAddress = "localhost"
}
go func() {
// Determine whether to use HTTPS (TLS) or HTTP
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
@@ -239,11 +246,27 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
logger.Infof("Running at : %s", fullURL)
// Create a TLS listener and serve
listener, err := tls.Listen("tcp", srv.Addr, tlsConfig)
if err != nil {
logger.Fatalf("Could not start TLS server: %v", err)
// Attempt to get listener from socket activation with TLS configuration
var listener net.Listener
socketActivationListeners, err := activation.TLSListeners(tlsConfig)
if err == nil && len(socketActivationListeners) > 0 {
listener = socketActivationListeners[0]
logger.Debug("Socket activation detected. Listening address is being controlled by systemd.")
} else if err != nil {
// if Socket Activation fails we can just fall back to create our own sockets as normal.
// so is only interesting to those who are debugging the app.
logger.Debugf("Socket activation failed: %v", err)
}
// Create our own TLS listener if socket activation is not available.
if listener == nil {
listener, err = tls.Listen("tcp", srv.Addr, tlsConfig)
if err != nil {
logger.Fatalf("Could not start TLS server: %v", err)
}
}
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatalf("Server error: %v", err)
}
@@ -258,8 +281,35 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
logger.Infof("Running at : %s", fullURL)
var listener net.Listener
// Attempt to get the listener from socket activation
socketActivationListeners, err := activation.Listeners()
if err == nil && len(socketActivationListeners) > 0 {
listener = socketActivationListeners[0]
logger.Debug("Socket activation detected. Listening address is being controlled by systemd.")
} else if err != nil {
// if Socket Activation fails we can just fall back to create our own sockets as normal.
// so is only interesting to those who are debugging the app.
logger.Debugf("Socket activation failed: %v", err)
}
// Create our own listener if socket activation is not available.
if listener == nil {
// Replicate the behaviour of ListenAndServe
addr := srv.Addr
if addr == "" {
addr = ":http"
}
listener, err = net.Listen("tcp", addr)
if err != nil {
logger.Fatalf("Server error: %v", err)
}
}
// Start HTTP server
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatalf("Server error: %v", err)
}
}
@@ -284,6 +334,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
logger.Errorf("Failed to flush access storage: %v", err)
}
}
if store.Indexing != nil {
if err := store.Indexing.Flush(); err != nil {
logger.Errorf("Failed to flush indexing storage: %v", err)
}
}
}
// Graceful shutdown with a timeout - 30 seconds, in case downloads are happening
+2 -3
View File
@@ -3,7 +3,6 @@ package http
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -20,10 +19,10 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/jobs/{action}/{target} [get]
func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sources := settings.GetSources(d.user)
sources := d.user.GetSourceNames()
reducedIndexes := map[string]indexing.ReducedIndex{}
for _, source := range sources {
reducedIndex, err := indexing.GetIndexInfo(source)
reducedIndex, err := indexing.GetIndexInfo(source, false)
if err != nil {
logger.Debugf("error getting index info: %v", err)
continue
+3 -14
View File
@@ -4,12 +4,10 @@ import (
"bytes"
"fmt"
"net/http"
"net/url"
"path/filepath"
"strconv"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
@@ -30,7 +28,7 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/media/subtitles [get]
func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
indexParam := r.URL.Query().Get("index")
@@ -38,16 +36,7 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
indexParam = "0" // default to first subtitle stream
}
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
@@ -60,7 +49,7 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
metadata, exists := idx.GetMetadataInfo(userscope, true)
metadata, exists := idx.GetMetadataInfo(userscope, true, false)
if !exists {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
+91 -36
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"runtime"
"slices"
"strings"
@@ -26,12 +25,15 @@ import (
type requestContext struct {
user *users.User
shareUser *users.User
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
shareValid bool
ctx context.Context
MaxBandwidth int
Data interface{}
IndexPath string
}
type HttpResponse struct {
@@ -49,12 +51,7 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
func withHashFileHelper(fn handleFunc) handleFunc {
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
path := r.URL.Query().Get("path")
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
@@ -96,23 +93,47 @@ func withHashFileHelper(fn handleFunc) handleFunc {
}
// Get file information with options
getContent := r.URL.Query().Get("content") == "true"
getMetadata := r.URL.Query().Get("metadata") == "true"
reachedDownloadsLimit := link.Downloads >= link.DownloadsLimit && link.DownloadsLimit > 0
if link.DisableFileViewer || reachedDownloadsLimit {
getContent = false
}
data.shareUser, err = store.Users.Get(link.UserID)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
}
// get user scope path from share
userScope, err := data.shareUser.GetScopeForSourceName(source.Name)
if err != nil {
return http.StatusForbidden, err
}
// so trim user scope from link.Path
pathWithoutUserScope := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
if !strings.HasSuffix(pathWithoutUserScope, "/") {
pathWithoutUserScope = pathWithoutUserScope + "/"
}
data.IndexPath = pathWithoutUserScope
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") {
// no need to fetch file info for uploads
return fn(w, r, data)
}
file, err := FileInfoFasterFunc(utils.FileOptions{
Path: utils.JoinPathAsUnix(link.Path, path),
Source: link.Source,
Path: pathWithoutUserScope,
Source: source.Name,
Expand: true,
Content: getContent,
Metadata: getMetadata,
AlbumArt: strings.Contains(r.URL.Path, "/preview"),
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
}, nil)
ShowHidden: link.ShowHidden,
FollowSymlinks: true,
}, store.Access, data.shareUser)
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
}
file.Token = link.Token
file.Source = ""
file.Source = link.Hash
file.Hash = link.Hash
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
file.OnlyOfficeId = ""
@@ -126,7 +147,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
link.IncrementUserDownload(data.user.Username)
}
}
file.Path = "/" + strings.TrimPrefix(strings.TrimPrefix(file.Path, link.Path), "/")
file.Path = utils.AddTrailingSlashIfNotExists(path)
// Set the file info in the `data` object
data.fileInfo = *file
// Call the next handler with the data
@@ -352,26 +373,43 @@ func withUserHelper(fn handleFunc) handleFunc {
if isProxyUser {
return getProxyUser(w, r, data, fn, proxyUser)
}
// JWT library automatically validates expiration - if expired, it returns an error
return http.StatusUnauthorized, fmt.Errorf("error processing token, %v", err)
}
if !token.Valid {
return http.StatusUnauthorized, fmt.Errorf("invalid token")
}
if auth.IsRevokedApiKey(data.token) || tk.Expires < time.Now().Unix() {
if auth.IsRevokedApiKey(data.token) {
if isProxyUser {
return getProxyUser(w, r, data, fn, proxyUser)
}
return http.StatusUnauthorized, fmt.Errorf("token expired or revoked")
return http.StatusUnauthorized, fmt.Errorf("token revoked")
}
// Check if the token is about to expire and send a header to renew it
if tk.Expires < time.Now().Add(time.Minute*30).Unix() {
// ExpiresAt should always be set in valid tokens created by our system
// If it's nil, the token is invalid
if tk.ExpiresAt == nil {
return http.StatusUnauthorized, fmt.Errorf("invalid token: missing expiration")
}
// Check if token is about to expire for renewal header
if tk.ExpiresAt.Unix() < time.Now().Add(time.Minute*30).Unix() {
w.Header().Add("X-Renew-Token", "true")
} // Retrieve the user from the store and store it in the context
}
// Check if token is minimal/stateful (no BelongsTo in claim)
if tk.BelongsTo == 0 {
tk.BelongsTo, err = getUserFromApiToken(data.token)
if err != nil {
return http.StatusUnauthorized, err
}
}
data.user, err = store.Users.Get(tk.BelongsTo)
if err != nil {
logger.Errorf("Failed to get user with ID %v: %v", tk.BelongsTo, err)
return http.StatusInternalServerError, err
}
// Set cookie. Some clients like gvfs relies on it for concurrent uploads
if expire := tk.ExpiresAt; expire != nil {
setSessionCookie(w, r, tokenString, expire.Time)
}
setUserInResponseWriter(w, data.user)
if data.user.Username == "" {
return http.StatusForbidden, errors.ErrUnauthorized
@@ -395,6 +433,16 @@ func getProxyUser(w http.ResponseWriter, r *http.Request, data *requestContext,
if data.user.Username == "" {
return http.StatusForbidden, errors.ErrUnauthorized
}
// Generate a token for proxy users if they don't have one
if data.token == "" {
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
if err != nil {
logger.Errorf("Failed to generate token for proxy user %s: %v", proxyUser, err)
return http.StatusInternalServerError, fmt.Errorf("failed to generate token")
}
data.token = signed.Key
}
// Call the handler function, passing in the context (or return OK if no handler)
if fn == nil {
return http.StatusOK, nil
@@ -415,6 +463,14 @@ func withSelfOrAdminHelper(fn handleFunc) handleFunc {
}
func wrapHandler(fn handleFunc) http.HandlerFunc {
return wrapHandlerOpts(fn, wrapperOpts{})
}
type wrapperOpts struct {
requestBasicAuth bool
}
func wrapHandlerOpts(fn handleFunc, opts wrapperOpts) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := &requestContext{}
@@ -428,6 +484,10 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
Message: err.Error(),
}
if status == http.StatusUnauthorized && opts.requestBasicAuth {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
}
// Set the content type to JSON and status code
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
@@ -587,8 +647,6 @@ func LoggingMiddleware(next http.Handler) http.Handler {
// DEFER RECOVERY FUNCTION
defer func() {
if rcv := recover(); rcv != nil {
// Log detailed information about the panic
// Extract as much context as possible for logging
method := r.Method
url := r.URL.String()
remoteAddr := r.RemoteAddr
@@ -598,12 +656,6 @@ func LoggingMiddleware(next http.Handler) http.Handler {
if ww, ok := w.(*ResponseWriterWrapper); ok && ww.User != "" {
username = ww.User
}
// Or try to get it from request context if your other middleware populates it
// This depends on your context setup; example:
// if dataCtx, ok := r.Context().Value("requestData").(*requestContext); ok && dataCtx.user != nil {
// username = dataCtx.user.Username
// }
// Get Go-level stack trace
buf := make([]byte, 16384) // Increased buffer size for potentially long CGo traces
n := runtime.Stack(buf, false) // false for current goroutine only
@@ -623,16 +675,11 @@ func LoggingMiddleware(next http.Handler) http.Handler {
_, _ = renderJSON(w, r, &HttpResponse{
Status: 500,
Message: "A critical internal error occurred. Please try again later.",
})
}, http.StatusInternalServerError)
}
// IMPORTANT: After a SIGSEGV from C code, the process might be unstable.
// Even if Go recovers, continuing to run the process is risky.
// Consider a strategy to gracefully shut down or signal an external supervisor
// to restart the process after logging. For now, this will allow other requests
// to proceed if the process doesn't die, but be wary.
}
}() // End of deferred recovery function
}()
start := time.Now()
wrappedWriter := &ResponseWriterWrapper{ResponseWriter: w, StatusCode: http.StatusOK}
@@ -665,18 +712,26 @@ func LoggingMiddleware(next http.Handler) http.Handler {
})
}
func renderJSON(w http.ResponseWriter, r *http.Request, data interface{}) (int, error) {
func renderJSON(w http.ResponseWriter, r *http.Request, data interface{}, statusCode ...int) (int, error) {
// Default to 200 if status code not provided
code := http.StatusOK
if len(statusCode) > 0 && statusCode[0] != 0 {
code = statusCode[0]
}
marsh, err := json.Marshal(data)
if err != nil {
return http.StatusInternalServerError, err
}
// Calculate size in KB
payloadSizeKB := len(marsh) / 1024
// Set headers before writing
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// Check if the client accepts gzip encoding and hasn't explicitly disabled it
if acceptsGzip(r) && payloadSizeKB > 10 {
// Enable gzip compression
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Encoding", "gzip")
w.WriteHeader(code)
gz := gzip.NewWriter(w)
defer gz.Close()
@@ -685,13 +740,13 @@ func renderJSON(w http.ResponseWriter, r *http.Request, data interface{}) (int,
}
} else {
// Normal response without compression
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if _, err := w.Write(marsh); err != nil {
return http.StatusInternalServerError, err
}
}
return 0, nil
return code, nil
}
func acceptsGzip(r *http.Request) bool {
+28 -6
View File
@@ -34,6 +34,14 @@ func setupTestEnv(t *testing.T) {
Name: "srv",
},
}
config.Server.NameToSource = map[string]*settings.Source{
"srv": &settings.Source{
Path: "/srv",
Name: "srv",
},
}
// Initialize user resolvers so users package can resolve source names
settings.InitializeUserResolvers()
mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test
}
@@ -44,7 +52,7 @@ func mockFileInfoFaster(t *testing.T) {
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
// Mock the function to skip execution
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
return &iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
Path: opts.Path,
@@ -101,7 +109,7 @@ func TestWithAdminHelper(t *testing.T) {
data := &requestContext{
user: tc.user,
}
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm)
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm, false)
if err != nil {
t.Fatalf("Error making token for request: %v", err)
}
@@ -140,6 +148,19 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" // bcrypt hashed password
// Create and save a dummy user with ID 1 (all shares use UserID: 1)
dummyUser := &users.User{
ID: 1,
Username: "testuser",
Permissions: users.Permissions{Admin: false},
Scopes: []users.SourceScope{
{Name: "srv", Scope: "/"}, // Root scope on srv source
},
}
if err := store.Users.Save(dummyUser, true, true); err != nil {
t.Fatal("failed to save dummy user:", err)
}
testCases := []struct {
name string
share *share.Link
@@ -151,12 +172,13 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
{
name: "Public share, no auth required",
share: &share.Link{
Hash: "public_hash",
Hash: "public_hash",
UserID: 1,
CommonShare: share.CommonShare{
Source: "/srv",
},
},
expectedStatusCode: 0, // zero means 200 on helpers
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
},
{
name: "Private share, valid password when token exists",
@@ -172,7 +194,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
extraHeaders: map[string]string{
"X-SHARE-PASSWORD": "password",
},
expectedStatusCode: 0, // zero means 200 on helpers
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
},
{
name: "Private share, no auth provided",
@@ -196,7 +218,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
},
},
token: "123",
expectedStatusCode: 0, // zero means 200 on helpers
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
},
{
name: "Private share, invalid password",
+1 -1
View File
@@ -301,7 +301,7 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
// Generate a signed token for the user
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions)
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
if err2 != nil {
// Handle potential errors during token generation
if strings.Contains(err2.Error(), "key already exists with same name") {
+79 -35
View File
@@ -100,20 +100,14 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
path := providedPath
if hash == "" {
// Build file info based on whether this is a share or regular request
// Regular user request - need to resolve scope
userScope, scopeErr := settings.GetScopeFromSourceName(d.user.Scopes, source)
if scopeErr != nil {
logger.Errorf("OnlyOffice: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
return http.StatusForbidden, fmt.Errorf("source %s is not available", source)
}
path = utils.JoinPathAsUnix(userScope, path)
logger.Debugf("OnlyOffice user request: resolved path=%s", path)
// Regular user request
logger.Debugf("OnlyOffice user request: request path=%s", path)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: path,
Source: source,
Expand: false,
}, store.Access)
Path: path,
Source: source,
Expand: false,
FollowSymlinks: true,
}, store.Access, d.user)
if err != nil {
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, path, err)
return errToStatus(err), err
@@ -179,10 +173,10 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
sendOnlyOfficeLogEvent(logContext, "INFO", "config", fmt.Sprintf("OnlyOffice session started for document: %s ", path))
// Build download URL that OnlyOffice server will use
downloadURL := buildOnlyOfficeDownloadURL(source, providedPath, d.fileInfo.Hash, d.token)
downloadURL := buildOnlyOfficeDownloadURL(r, source, providedPath, d.fileInfo.Hash, d.token)
// Build callback URL for OnlyOffice to notify us of changes
callbackURL := buildOnlyOfficeCallbackURL(source, providedPath, d.fileInfo.Hash, d.token)
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.fileInfo.Hash, d.token)
// Build OnlyOffice client configuration
clientConfig := map[string]interface{}{
@@ -228,40 +222,83 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
}
// buildOnlyOfficeDownloadURL constructs the download URL that OnlyOffice server will use to fetch the file
func buildOnlyOfficeDownloadURL(source, path, hash, token string) string {
func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
baseURL := settings.Config.Server.BaseURL
var baseURL string
if settings.Config.Server.InternalUrl != "" {
// Ensure proper URL joining without double slashes
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
baseURL = internalURL + "/" + baseURLPath
if baseURLPath != "" {
baseURL = internalURL + "/" + baseURLPath
} else {
baseURL = internalURL
}
} else {
// Extract scheme and host from request (respecting X-Forwarded-* headers)
var host string
var scheme string
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
scheme = forwardedProto
} else {
scheme = "https"
}
} else {
// Fallback to simple approach
host = r.Host
scheme = getScheme(r)
}
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
}
var downloadURL string
escapedPath := url.QueryEscape(path)
downloadURL := fmt.Sprintf("%s/api/raw?file=%s&auth=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token)
if hash != "" {
// Share download URL - don't expose source name, just use the path relative to share
filesParam := url.QueryEscape(path)
downloadURL = fmt.Sprintf("%s/public/api/raw?files=%s&hash=%s&token=%s&auth=%s",
strings.TrimSuffix(baseURL, "/"), filesParam, hash, token, token)
downloadURL = downloadURL + "&hash=" + hash
} else {
// Regular download URL - include source for non-share requests
filesParam := url.QueryEscape(source + "::" + path)
downloadURL = fmt.Sprintf("%s/api/raw?files=%s&auth=%s",
strings.TrimSuffix(baseURL, "/"), filesParam, token)
downloadURL = downloadURL + "&source=" + url.QueryEscape(source)
}
return downloadURL
}
// buildOnlyOfficeCallbackURL constructs the callback URL that OnlyOffice server will use to notify us of changes
func buildOnlyOfficeCallbackURL(source, path, hash, token string) string {
baseURL := settings.Config.Server.BaseURL
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
var baseURL string
if settings.Config.Server.InternalUrl != "" {
// Ensure proper URL joining without double slashes
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
baseURL = internalURL + "/" + baseURLPath
if baseURLPath != "" {
baseURL = internalURL + "/" + baseURLPath
} else {
baseURL = internalURL
}
} else {
// Extract scheme and host from request (respecting X-Forwarded-* headers)
var host string
var scheme string
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
scheme = forwardedProto
} else {
scheme = "https"
}
} else {
// Fallback to simple approach
host = r.Host
scheme = getScheme(r)
}
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
}
var callbackURL string
@@ -317,7 +354,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
if d.fileInfo.Hash == "" {
// Regular user request - need to resolve scope
userScope, scopeErr := settings.GetScopeFromSourceName(d.user.Scopes, source)
userScope, scopeErr := d.user.GetScopeForSourceName(source)
if scopeErr != nil {
logger.Errorf("OnlyOffice callback: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
return returnOnlyOfficeError(w, r, 403, "source not available")
@@ -473,7 +510,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
_, err = files.FileInfoFaster(utils.FileOptions{
Source: source,
Path: path,
}, nil)
}, store.Access, d.user)
if err != nil {
logger.Errorf("OnlyOffice callback: original file no longer exists at path=%s: %v",
path, err)
@@ -487,7 +524,14 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
return returnOnlyOfficeError(w, r, 404, "original file no longer exists - it may have been renamed or moved")
}
writeErr := files.WriteFile(source, path, doc.Body)
// Get user scope to resolve full index path for write operation
userScope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return returnOnlyOfficeError(w, r, 403, "user scope not found")
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
writeErr := files.WriteFile(source, fullIndexPath, doc.Body)
if writeErr != nil {
logger.Errorf("OnlyOffice callback: failed to write updated document to path=%s: %v",
path, writeErr)
+21 -40
View File
@@ -16,7 +16,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"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/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
@@ -62,30 +61,29 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
var err error
// decode url encoded source name
source, err = url.PathUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
if source == "" {
return http.StatusBadRequest, fmt.Errorf("source is required")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Path: path,
Source: source,
AlbumArt: true, // Extract album art for audio previews
}, store.Access)
Expand: true,
}, store.Access, d.user)
if err != nil {
logger.Errorf("error getting file info: %v", err)
return errToStatus(err), err
}
d.fileInfo = *fileInfo
return previewHelperFunc(w, r, d)
status, err := previewHelperFunc(w, r, d)
if err != nil {
// Error already logged in previewHelperFunc or its callees
return errToStatus(err), err
}
return status, nil
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
@@ -93,8 +91,7 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
}
realPath, _, _ := idx.GetRealPath(file.Path)
fd, err := os.Open(realPath)
fd, err := os.Open(file.RealPath)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -108,48 +105,37 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
}
// getDirectoryPreview finds a valid preview file within a directory.
// It iterates through files, checking if they should bubble up to folder previews,
// and returns the first valid, non-corrupted file suitable for preview.
func getDirectoryPreview(r *http.Request, d *requestContext, accessStore *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.ExtendedFileInfo, error) {
var lastErr error
for _, item := range d.fileInfo.Files {
// Only use files that should bubble up to folder previews (images, videos, audio)
// Exclude text files, office documents, and PDFs
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
continue
}
source := d.fileInfo.Source
path := utils.JoinPathAsUnix(d.fileInfo.Path, item.Name)
if d.share != nil {
// Get the actual source name from the share's source mapping
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
return nil, fmt.Errorf("source not found for share")
}
source = sourceInfo.Name
path = utils.JoinPathAsUnix(d.share.Path, path)
path = d.IndexPath + item.Name
}
fileInfo, err := files.FileInfoFaster(
utils.FileOptions{
Username: d.user.Username,
Path: path,
Source: source,
AlbumArt: true, // Extract album art for audio previews
}, accessStore)
Metadata: true,
}, store.Access, d.user)
if err != nil {
lastErr = err
continue // Try next file if this one fails
}
// Try to generate preview to verify the file is not corrupted
tempCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
cancel()
if previewErr != nil {
// Skip context errors (timeout or cancellation) - they're not corruption issues
if !errors.Is(previewErr, context.Canceled) && !errors.Is(previewErr, context.DeadlineExceeded) {
@@ -164,11 +150,8 @@ func getDirectoryPreview(r *http.Request, d *requestContext, accessStore *access
lastErr = previewErr
continue
}
// Success! Use this file for the directory preview
return fileInfo, nil
}
// If we exhausted all files and none worked, return the last error
if lastErr != nil {
return nil, fmt.Errorf("no valid preview files found in directory: %w", lastErr)
@@ -185,14 +168,12 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
if !d.fileInfo.HasPreview {
return http.StatusBadRequest, fmt.Errorf("this item does not have a preview")
}
accessStore := store.Access
if d.share != nil {
accessStore = nil
}
if d.fileInfo.Type == "directory" {
// Get extended file info of first previewable item in directory
fileInfo, err := getDirectoryPreview(r, d, accessStore)
fileInfo, err := getDirectoryPreview(r, d)
if err != nil {
logger.Errorf("error getting directory preview: %v", err)
return http.StatusInternalServerError, err
}
d.fileInfo = *fileInfo
@@ -221,7 +202,7 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
officeUrl := ""
if d.fileInfo.OnlyOfficeId != "" {
pathUrl := fmt.Sprintf("/api/raw?files=%s::%s", d.fileInfo.Source, d.fileInfo.Path)
pathUrl := fmt.Sprintf("/api/raw?file=%s&source=%s", url.QueryEscape(d.fileInfo.Path), url.QueryEscape(d.fileInfo.Source))
pathUrl = pathUrl + "&auth=" + d.token
if settings.Config.Server.InternalUrl != "" {
officeUrl = config.Server.InternalUrl + pathUrl
+206 -146
View File
@@ -5,14 +5,13 @@ import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"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/indexing"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
@@ -22,11 +21,15 @@ import (
// publicRawHandler serves the raw content of a file, multiple files, or directory via a public share.
// @Summary Download files from a public share
// @Description Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.
// @Description
// @Description **Multiple Files:**
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
// @Description - This supports filenames containing commas and special characters
// @Tags Public Shares
// @Accept json
// @Produce octet-stream
// @Param hash query string true "Share hash for authentication"
// @Param files query string true "Files to download in format: 'source::path||source::path'. Example: '/file1||/folder/file2'"
// @Param file query []string true "File path (can be repeated for multiple files)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
@@ -71,12 +74,11 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if d.share.PerUserDownloadLimit {
d.share.IncrementUserDownload(d.user.Username)
}
encodedFiles := r.URL.Query().Get("files")
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
f, err := url.PathUnescape(encodedFiles)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
// Get all "file" parameter values (supports repeated params)
encodedFiles := r.URL.Query()["file"]
if len(encodedFiles) == 0 {
return http.StatusBadRequest, fmt.Errorf("no files specified")
}
// Get the actual source name from the share's source mapping
@@ -86,37 +88,26 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
actualSourceName := sourceInfo.Name
// Decode and process each file path
fileList := []string{}
for _, file := range strings.Split(f, "||") {
// Check if file already contains source prefix (source::path format)
if strings.Contains(file, "::") {
splitFile := strings.SplitN(file, "::", 2)
if len(splitFile) == 2 {
source := splitFile[0]
path := splitFile[1]
// Join the share path with the requested path
filePath := utils.JoinPathAsUnix(d.share.Path, path)
fileList = append(fileList, source+"::"+filePath)
} else {
// Fallback: treat as plain path
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, actualSourceName+"::"+filePath)
}
} else {
// Plain path without source prefix - use the actual source name from share
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, actualSourceName+"::"+filePath)
for _, encodedFile := range encodedFiles {
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
file, decodeErr := url.PathUnescape(encodedFile)
if decodeErr != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", decodeErr)
}
// Join the share path with the requested path
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, filePath)
}
var status int
status, err = rawFilesHandler(w, r, d, fileList)
status, err := rawFilesHandler(w, r, d, actualSourceName, fileList)
if err != nil {
if err == errors.ErrDownloadNotAllowed {
return http.StatusForbidden, errors.ErrDownloadNotAllowed
}
logger.Errorf("public share handler: error processing filelist: '%v' with error %v", f, err)
return status, fmt.Errorf("error processing filelist: %v", f)
logger.Errorf("public share handler: error processing filelist: %v with error %v", encodedFiles, err)
return status, fmt.Errorf("error processing filelist: %v", encodedFiles)
}
return status, nil
}
@@ -129,6 +120,8 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param path query string false "Path within the share to retrieve information for. Defaults to share root."
// @Param content query string false "Include file content if true"
// @Param metadata query string false "Extract audio/video metadata if true"
// @Success 200 {object} iteminfo.FileInfo "File or directory metadata"
// @Failure 403 {object} map[string]string "Share unavailable or access denied"
// @Failure 404 {object} map[string]string "Share not found or file not found"
@@ -149,7 +142,7 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *request
// @Accept multipart/form-data
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param targetPath query string true "Target path within the share to upload to. Must be relative to share root."
// @Param path query string true "path within the share to upload to. Must be relative to share root."
// @Param override query bool false "If true, overwrite existing files/folders. Defaults to false."
// @Param action query string false "Upload action: 'override' to replace files, 'rename' to auto-rename"
// @Param file formData file true "File to upload"
@@ -165,28 +158,22 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if d.share.ShareType != "upload" && !d.share.AllowCreate {
return http.StatusForbidden, fmt.Errorf("uploading is disabled for this share")
}
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
}
path, err := url.QueryUnescape(r.URL.Query().Get("targetPath"))
if err != nil {
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fullPath := filepath.Join(d.share.Path, path)
// Go automatically decodes query params
source := config.Server.SourceMap[d.share.Source].Name
// adjust query params to match resourcePostHandler
q := r.URL.Query()
q.Set("source", source)
q.Set("path", fullPath)
q.Set("path", d.IndexPath)
r.URL.RawQuery = q.Encode()
status, err := resourcePostHandler(w, r, d)
if err != nil {
logger.Errorf("public upload handler: error uploading with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("upload failure occured on backend")
}
return status, err
return status, nil
}
// health godoc
@@ -226,28 +213,12 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if config.Server.DisablePreviews || d.share.DisableThumbnails {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
if d.share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled for upload shares")
}
// Restore source name from share for preview generation
// The middleware clears file.Source for security, but we need it for index lookups
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
fileInfo, err := FileInfoFasterFunc(utils.FileOptions{
Path: utils.JoinPathAsUnix(d.share.Path, d.fileInfo.Path),
Source: source,
Metadata: true,
}, nil)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
d.fileInfo = *fileInfo
status, err := previewHelperFunc(w, r, d)
if err != nil {
logger.Errorf("public preview handler: error getting preview with error %v", err)
// Obfuscate errors for shares to prevent information leakage
return http.StatusNotFound, fmt.Errorf("preview not available for this item")
}
@@ -282,128 +253,217 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if !d.share.AllowModify {
return http.StatusForbidden, fmt.Errorf("edit permission not allowed for this share")
}
// Go automatically decodes query params
path := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err = url.QueryUnescape(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
err = files.WriteFile(source, resolvedPath, r.Body)
return errToStatus(err), err
// hide the error
if err != nil {
logger.Errorf("public put handler: error updating resource with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occurred while updating the resource")
}
return http.StatusOK, nil
}
// deprecated -- see publicBulkDeleteHandler
func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.share.AllowDelete {
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
}
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
indexPath := utils.JoinPathAsUnix(d.share.Path, path)
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: indexPath,
Source: source,
Expand: false,
}, nil)
FollowSymlinks: true,
Path: d.IndexPath,
Source: d.share.Source,
}, store.Access, d.shareUser)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath), fileInfo.Type == "directory")
err = files.DeleteFiles(d.share.Source, fileInfo.RealPath, fileInfo.Type == "directory")
if err != nil {
logger.Errorf("public delete handler: error deleting resource with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
}
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
return http.StatusOK, nil
}
// publicBulkDeleteHandler deletes multiple resources from a public share in a single request.
// @Summary Bulk delete resources from public share
// @Description Deletes multiple resources specified in the request body. Returns a list of succeeded and failed deletions.
// @Tags Public Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param items body []BulkDeleteItem true "Array of items to delete, each with source and path"
// @Success 200 {object} BulkDeleteResponse "All resources deleted successfully"
// @Success 207 {object} BulkDeleteResponse "Partial success - some resources deleted, some failed"
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or empty items array"
// @Failure 403 {object} map[string]string "Forbidden - delete not allowed for this share"
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
// @Router /public/api/resources/bulk/delete [post]
func publicBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.share.AllowDelete {
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
}
// hide the error
status, err := resourceBulkDeleteHandler(w, r, d)
if err != nil {
logger.Errorf("public bulk delete handler: error deleting resources with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occurred while processing the request")
}
return status, nil
}
// publicPatchHandler performs a patch operation (e.g., move, copy, rename) on resources in a public share.
// @Summary Move, copy, or rename resources in a public share
// @Description Performs move, copy, or rename operations on multiple resources within a public share. All operations are performed atomically.
// @Tags Public Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param request body MoveCopyRequest true "Move/copy request with items and action"
// @Success 200 {object} MoveCopyResponse "All operations completed successfully"
// @Success 207 {object} MoveCopyResponse "Partial success - some operations succeeded, some failed"
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or parameters"
// @Failure 403 {object} map[string]string "Forbidden - modify not allowed for this share"
// @Failure 404 {object} map[string]string "Share or resource not found"
// @Failure 500 {object} MoveCopyResponse "Internal server error"
// @Router /public/api/resources [patch]
func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.share.AllowModify {
return http.StatusForbidden, fmt.Errorf("edit permission not allowed for this share")
}
// The middleware clears file.Source for security, but we need it for index lookups
// Get the source from the share
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
action := r.URL.Query().Get("action")
encodedFrom := r.URL.Query().Get("from")
// Decode the URL-encoded path
src, err := url.QueryUnescape(encodedFrom)
// Replace user with the share creator's user for proper permission checking
shareCreatedByUser, err := store.Users.Get(d.share.UserID)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
}
dst := r.URL.Query().Get("destination")
dst, err = url.QueryUnescape(dst)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
d.user = shareCreatedByUser
// Parse the request body
var req MoveCopyRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
}
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source for share not found")
if req.Action == "" {
return http.StatusBadRequest, fmt.Errorf("action is required (copy, move, or rename)")
}
// get full paths for both locations
srcFullPath := utils.JoinPathAsUnix(d.share.Path, src)
if srcFullPath == "/" {
return http.StatusForbidden, fmt.Errorf("an error occured accessing the share")
// Transform the request: prepend share path and add source to each item
// This normalizes the request to look like a regular user request
// Note: Share paths are absolute, so we don't strip user scope here
// resourcePatchHandler will skip adding scope for shares
for i := range req.Items {
req.Items[i].FromSource = source
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].FromPath)
req.Items[i].ToSource = source
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].ToPath)
}
dstFullPath := utils.JoinPathAsUnix(d.share.Path, dst)
if dstFullPath == "/" {
return http.StatusForbidden, fmt.Errorf("an error occured accessing the share")
d.Data = req
// Call the regular handler (will treat this like a normal user request now)
status, err := resourcePatchHandler(w, r, d)
// For shares, we need to sanitize the response to hide internal details
// The response has already been written by resourcePatchHandler, but we can still return error
if err != nil {
logger.Errorf("public patch handler: error processing patch with error %v", err)
// Obfuscate errors for security
return http.StatusInternalServerError, fmt.Errorf("an error occurred while processing the request")
}
// get real source and isDir
realSrc, isSrcDir, err := idx.GetRealPath(srcFullPath)
if err != nil {
return http.StatusNotFound, err
}
// get real destination and isDir parent
parentRealDest, _, err := idx.GetRealPath(filepath.Dir(dstFullPath))
if err != nil {
return http.StatusNotFound, err
}
dstFullPath = parentRealDest + "/" + filepath.Base(dst)
rename := r.URL.Query().Get("rename") == "true"
if rename {
dstFullPath = addVersionSuffix(dstFullPath)
}
// Validate move/rename operation to prevent circular references
if action == "rename" || action == "move" {
err = validateMoveOperation(realSrc, dstFullPath, isSrcDir)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid move or rename operation")
}
}
err = patchAction(r.Context(), patchActionParams{
action: action,
srcIndex: source,
dstIndex: source,
src: realSrc,
dst: dstFullPath,
d: d,
isSrcDir: isSrcDir,
})
if err != nil {
logger.Debugf("Could not run patch action. src=%v dst=%v err=%v", realSrc, dstFullPath, err)
return http.StatusInternalServerError, fmt.Errorf("an error occured while processing the request")
}
return http.StatusOK, nil
return status, err
}
// getShareImage serves banner or favicon files for shares
// @Summary Get share image (banner or favicon)
// @Description Serves the banner or favicon file for a share
// @Tags Public Shares
// @Produce octet-stream
// @Param hash query string true "Share hash"
// @Param banner query bool false "Request banner file"
// @Param favicon query bool false "Request favicon file"
// @Success 200 {file} binary "Image file content"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 403 {object} map[string]string "Permission denied"
// @Failure 404 {object} map[string]string "Asset not found"
// @Router /public/api/share/image [get]
func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Determine which asset is being requested
isBanner := r.URL.Query().Get("banner") == "true"
isFavicon := r.URL.Query().Get("favicon") == "true"
if !isBanner && !isFavicon {
return http.StatusBadRequest, fmt.Errorf("either banner or favicon parameter must be true")
}
shareCreatedByUser, err := store.Users.Get(d.share.UserID)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
}
sourceName, assetPath, err := getShareImagePartsHelper(d.share, isBanner)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid asset configuration: %v", err)
}
// Get file info
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: assetPath,
Source: sourceName,
Expand: false,
Content: false,
Metadata: false,
ShowHidden: false,
FollowSymlinks: true,
}, store.Access, shareCreatedByUser)
if err != nil {
logger.Errorf("error accessing share asset: source=%v path=%v error=%v", sourceName, assetPath, err)
return http.StatusNotFound, fmt.Errorf("asset file not found or not accessible")
}
// Ensure it's an image file
if !strings.HasPrefix(fileInfo.Type, "image/") {
return http.StatusBadRequest, fmt.Errorf("invalid file type, must be image")
}
// Serve the file
http.ServeFile(w, r, fileInfo.RealPath)
return 0, nil
}
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
// Get the asset query string from share
var assetQueryString string
if isBanner {
assetQueryString = share.Banner
} else {
assetQueryString = share.Favicon
}
if assetQueryString == "" {
return "", "", fmt.Errorf("asset not configured for this share")
}
// Parse the query string to extract source and path
assetParams, err := url.ParseQuery(assetQueryString)
if err != nil {
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
}
sourceName := assetParams.Get("source")
assetPath := assetParams.Get("path")
return sourceName, assetPath, nil
}
+149 -88
View File
@@ -16,7 +16,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -58,22 +57,51 @@ func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.rs.Seek(offset, whence)
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline; filename*=utf-8''"+url.PathEscape(fileName))
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName))
// toASCIIFilename converts a filename to ASCII-safe format by replacing non-ASCII characters with underscores
func toASCIIFilename(fileName string) string {
var result strings.Builder
for _, r := range fileName {
if r > 127 {
// Replace non-ASCII characters with underscore
result.WriteRune('_')
} else {
result.WriteRune(r)
}
}
return result.String()
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
dispositionType := "attachment"
if r.URL.Query().Get("inline") == "true" {
dispositionType = "inline"
}
// standard: ASCII-only safe fallback
asciiFileName := toASCIIFilename(fileName)
// RFC 5987: UTF-8 encoded
encodedFileName := url.PathEscape(fileName)
// Always set both filename (ASCII) and filename* (UTF-8) for maximum compatibility (RFC 6266)
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q; filename*=utf-8''%s", dispositionType, asciiFileName, encodedFileName))
}
// rawHandler serves the raw content of a file, multiple files, or directory in various formats.
// @Summary Get raw content of a file, multiple files, or directory
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
// @Description
// @Description **Filename Encoding:**
// @Description - The Content-Disposition header will always include both:
// @Description 1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
// @Description 2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
// @Description
// @Description **Multiple Files:**
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
// @Description - This supports filenames containing commas and special characters
// @Tags Resources
// @Accept json
// @Produce json
// @Param files query string true "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)"
// @Param source query string true "Source name for the files (required)"
// @Param file query []string true "File path (can be repeated for multiple files)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
@@ -83,48 +111,35 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/raw [get]
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
files := r.URL.Query().Get("files")
fileList := strings.Split(files, "||")
return rawFilesHandler(w, r, d, fileList)
source := r.URL.Query().Get("source")
fileList := r.URL.Query()["file"]
return rawFilesHandler(w, r, d, source, fileList)
}
func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
splitFile := strings.Split(path, "::")
if len(splitFile) != 2 {
return fmt.Errorf("invalid file in files requested: %v", splitFile)
}
source := splitFile[0]
path = splitFile[1]
var err error
if d.share == nil {
var userScope string
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
path = utils.JoinPathAsUnix(userScope, path)
}
// For shares, the path is already correctly resolved by publicRawHandler
func addFile(source string, path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
// Path handling is now done inside FileInfoFaster
// For shares, path comes from share context
// For regular users, FileInfoFaster will apply user scope
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("source %s is not available", source)
}
if d.share != nil {
_, err = files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
}, nil)
} else {
_, err = files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: path,
Source: source,
Expand: false,
}, store.Access)
// 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)
if err != nil {
return err
}
@@ -158,6 +173,20 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
return nil
}
// Check access control for each file/folder during walk
if d.share == nil {
indexRelPath := utils.JoinPathAsUnix(path, relPath)
indexRelPath = filepath.ToSlash(indexRelPath) // Normalize separators
if !store.Access.Permitted(idx.Path, indexRelPath, d.user.Username) {
// Skip this file/folder silently
if fileInfo.IsDir() {
// Skip the entire directory by returning filepath.SkipDir
return filepath.SkipDir
}
return nil
}
}
// Prepend base folder name unless flatten is true
if !flatten {
relPath = filepath.Join(baseName, relPath)
@@ -240,18 +269,16 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
return nil
}
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, fileList []string) (int, error) {
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
if !d.user.Permissions.Download && d.share == nil {
return http.StatusForbidden, fmt.Errorf("user is not allowed to download")
}
splitFile := strings.Split(fileList[0], "::")
if len(splitFile) != 2 {
return http.StatusBadRequest, fmt.Errorf("invalid file in files request: %v", fileList[0])
if len(fileList) == 0 {
return http.StatusBadRequest, fmt.Errorf("no files specified")
}
firstFileSource := splitFile[0]
firstFilePath := splitFile[1]
// decode url encoded source name
firstFilePath := fileList[0]
var err error
var userscope string
fileName := filepath.Base(firstFilePath)
@@ -262,12 +289,12 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
var logContext *OnlyOfficeLogContext
if d.share == nil {
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, firstFileSource)
userscope, err = d.user.GetScopeForSourceName(source)
if err != nil {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
// Try to get document ID for error logging
idx := indexing.GetIndex(firstFileSource)
idx := indexing.GetIndex(source)
if idx != nil {
tempPath := utils.JoinPathAsUnix(userscope, firstFilePath)
if realPath, _, realErr := idx.GetRealPath(tempPath); realErr == nil {
@@ -285,14 +312,14 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
}
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(firstFileSource)
idx := indexing.GetIndex(source)
if idx == nil {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - source index not available: %s", firstFileSource))
fmt.Sprintf("OnlyOffice download failed - source index not available: %s", source))
}
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
realPath, isDir, err := idx.GetRealPath(firstFilePath)
if err != nil {
@@ -308,7 +335,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
return http.StatusInternalServerError, err
}
// Compute estimated download size
estimatedSize, err := computeArchiveSize(fileList, d)
estimatedSize, err := computeArchiveSize(source, fileList, d)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -322,6 +349,19 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
}
// Verify access control before opening the file (direct rule check)
if d.share == nil && store.Access != nil {
if !store.Access.Permitted(idx.Path, firstFilePath, d.user.Username) {
logger.Debugf("user %s denied access to path %s", d.user.Username, firstFilePath)
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - access denied by rule: %s", firstFilePath))
}
return http.StatusForbidden, fmt.Errorf("access denied to path %s", firstFilePath)
}
}
fd, err2 := os.Open(realPath)
if err2 != nil {
// Send OnlyOffice error log if this was an OnlyOffice download
@@ -365,6 +405,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
// serve content allows for range requests.
// video scrubbing, etc.
// Note: http.ServeContent will respect our already-set Content-Disposition header
var reader io.ReadSeeker = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
@@ -402,15 +443,16 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
if len(fileList) == 1 && isDir {
baseDirName = filepath.Base(realPath)
}
fileName = url.PathEscape(baseDirName + extension)
// Store original filename before any encoding
originalFileName := baseDirName + extension
archiveData := filepath.Join(config.Server.CacheDir, utils.InsecureRandomIdentifier(10))
if extension == ".zip" {
archiveData = archiveData + ".zip"
err = createZip(d, archiveData, fileList...)
err = createZip(d, source, archiveData, fileList...)
} else {
archiveData = archiveData + ".tar.gz"
err = createTarGz(d, archiveData, fileList...)
err = createTarGz(d, source, archiveData, fileList...)
}
if err != nil {
return http.StatusInternalServerError, err
@@ -432,11 +474,12 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
sizeInMB := fileInfo.Size() / 1024 / 1024
if sizeInMB > 500 {
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName)
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, originalFileName)
}
// Set headers AFTER computing actual archive size
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+fileName)
// Use the same setContentDisposition logic for archives
setContentDisposition(w, r, originalFileName)
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
w.Header().Set("Content-Type", "application/octet-stream")
@@ -459,37 +502,44 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
return 0, nil
}
func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
func computeArchiveSize(source string, fileList []string, d *requestContext) (int64, error) {
var estimatedSize int64
for _, fname := range fileList {
splitFile := strings.Split(fname, "::")
if len(splitFile) != 2 {
return http.StatusBadRequest, fmt.Errorf("invalid file in files request: %v", fileList[0])
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)
}
source := splitFile[0]
path := splitFile[1]
var err error
idx := indexing.GetIndex(source)
if idx == nil {
return 0, fmt.Errorf("source %s is not available", source)
}
var userScope string
}
for _, path := range fileList {
var fullPath string
if d.share == nil {
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
fullPath = utils.JoinPathAsUnix(userScope, path)
// Check access control for each file in the archive
// Silently skip if access is denied (as if the file doesn't exist)
if store.Access != nil && !store.Access.Permitted(idx.Path, fullPath, d.user.Username) {
continue // Skip this file and continue with the next one
}
path = utils.JoinPathAsUnix(userScope, path)
} else {
fullPath = path
}
// For shares, the path is already correctly resolved by publicRawHandler
realPath, isDir, err := idx.GetRealPath(path)
realPath, isDir, err := idx.GetRealPath(fullPath)
if err != nil {
return http.StatusInternalServerError, err
}
indexPath := idx.MakeIndexPath(realPath)
indexPath := idx.MakeIndexPath(realPath, isDir)
info, ok := idx.GetReducedMetadata(indexPath, isDir)
if !ok {
info, err = idx.GetFsDirInfo(indexPath)
info, err = idx.GetFsInfo(indexPath, false, true)
if err != nil {
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
}
@@ -499,7 +549,7 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
return estimatedSize, nil
}
func createZip(d *requestContext, tmpDirPath string, filenames ...string) error {
func createZip(d *requestContext, source string, tmpDirPath string, filenames ...string) error {
file, err := os.Create(tmpDirPath)
if err != nil {
return err
@@ -507,20 +557,25 @@ func createZip(d *requestContext, tmpDirPath string, filenames ...string) error
defer file.Close()
zipWriter := zip.NewWriter(file)
defer zipWriter.Close()
for _, fname := range filenames {
err := addFile(fname, d, nil, zipWriter, false)
err := addFile(source, fname, d, nil, zipWriter, false)
if err != nil {
// Access control failures return nil, so any error here is a real error
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
return err
}
}
// Close the ZIP writer and check for errors
if err := zipWriter.Close(); err != nil {
return fmt.Errorf("failed to finalize ZIP archive: %w", err)
}
return nil
}
func createTarGz(d *requestContext, tmpDirPath string, filenames ...string) error {
func createTarGz(d *requestContext, source string, tmpDirPath string, filenames ...string) error {
file, err := os.Create(tmpDirPath)
if err != nil {
return err
@@ -528,18 +583,24 @@ func createTarGz(d *requestContext, tmpDirPath string, filenames ...string) erro
defer file.Close()
gzWriter := gzip.NewWriter(file)
defer gzWriter.Close()
tarWriter := tar.NewWriter(gzWriter)
defer tarWriter.Close()
for _, fname := range filenames {
err := addFile(fname, d, tarWriter, nil, false)
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
}
}
// Close writers in reverse order and check for errors
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
}
+536 -245
View File
@@ -4,10 +4,10 @@ import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
@@ -63,61 +63,38 @@ func validateMoveOperation(src, dst string, isSrcDir bool) error {
// @Param path query string true "Path to the resource"
// @Param source query string true "Source name for the desired source, default is used if not provided"
// @Param content query string false "Include file content if true"
// @Param metadata query string false "Extract audio/video metadata if true"
// @Param checksum query string false "Optional checksum validation"
// @Success 200 {object} iteminfo.FileInfo "Resource metadata"
// @Failure 404 {object} map[string]string "Resource not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [get]
func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
scopePath := utils.JoinPathAsUnix(userscope, path)
getContent := r.URL.Query().Get("content") == "true"
getMetadata := r.URL.Query().Get("metadata") == "true"
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: scopePath,
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: getContent,
Metadata: true,
Metadata: getMetadata,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
}, store.Access)
ShowHidden: d.user.ShowHidden,
}, store.Access, d.user)
if err != nil {
return errToStatus(err), err
}
if !d.user.Permissions.Download && fileInfo.Content != "" {
return http.StatusForbidden, fmt.Errorf("user is not allowed to get content, requires download permission")
}
if userscope != "/" {
fileInfo.Path = strings.TrimPrefix(fileInfo.Path, userscope)
}
if fileInfo.Path == "" {
fileInfo.Path = "/"
}
if fileInfo.Type == "directory" {
return renderJSON(w, r, fileInfo)
}
if algo := r.URL.Query().Get("checksum"); algo != "" {
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(userscope, path)
checksum, err := utils.GetChecksum(realPath, algo)
checksum, err := utils.GetChecksum(fileInfo.RealPath, algo)
if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil
} else if err != nil {
@@ -149,34 +126,18 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete")
}
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if path == "/" {
return http.StatusForbidden, nil
return http.StatusForbidden, fmt.Errorf("cannot delete your user's root directory")
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Expand: false,
}, store.Access)
Path: path,
Source: source,
Expand: false,
ShowHidden: d.user.ShowHidden,
}, store.Access, d.user)
if err != nil {
return errToStatus(err), err
}
@@ -184,7 +145,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath), fileInfo.Type == "directory")
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
if err != nil {
return errToStatus(err), err
}
@@ -192,13 +153,223 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
}
// BulkDeleteItem represents a single item in a bulk delete request
type BulkDeleteItem struct {
Source string `json:"source"`
Path string `json:"path"`
Message string `json:"message,omitempty"`
}
// BulkDeleteResponse represents the response from a bulk delete operation
type BulkDeleteResponse struct {
Succeeded []BulkDeleteItem `json:"succeeded"`
Failed []BulkDeleteItem `json:"failed"`
}
// MoveCopyItem represents a single item in a move/copy request
type MoveCopyItem struct {
FromSource string `json:"fromSource,omitempty"`
FromPath string `json:"fromPath,omitempty"`
ToSource string `json:"toSource,omitempty"`
ToPath string `json:"toPath,omitempty"`
Message string `json:"message,omitempty"`
}
// MoveCopyRequest represents a move/copy operation request
type MoveCopyRequest struct {
Items []MoveCopyItem `json:"items"`
Action string `json:"action"` // "copy", "move", or "rename"
Overwrite bool `json:"overwrite"` // Overwrite if destination exists
Rename bool `json:"rename"` // Auto-rename if destination exists
}
// MoveCopyResponse represents the response from a move/copy operation
type MoveCopyResponse struct {
Succeeded []MoveCopyItem `json:"succeeded"`
Failed []MoveCopyItem `json:"failed"`
}
// resourceBulkDeleteHandler deletes multiple resources in a single request.
// @Summary Bulk delete resources
// @Description Deletes multiple resources specified in the request body. Returns a list of succeeded and failed deletions.
// @Tags Resources
// @Accept json
// @Produce json
// @Param items body []BulkDeleteItem true "Array of items to delete, each with source and path"
// @Success 200 {object} BulkDeleteResponse "All resources deleted successfully"
// @Success 207 {object} BulkDeleteResponse "Partial success - some resources deleted, some failed"
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or empty items array"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
// @Router /api/resources/bulk/delete [post]
func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
filePermUser := d.user
if d.share != nil {
filePermUser = d.shareUser
}
// Check permissions - either user delete permission or share delete permission
if d.share == nil {
if !d.user.Permissions.Delete {
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete")
}
}
// Parse request body
var items []BulkDeleteItem
if err := json.NewDecoder(r.Body).Decode(&items); err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
}
if len(items) == 0 {
return http.StatusBadRequest, fmt.Errorf("items array cannot be empty")
}
response := BulkDeleteResponse{
Succeeded: make([]BulkDeleteItem, 0),
Failed: make([]BulkDeleteItem, 0),
}
// Process each item one at a time
for _, item := range items {
// Validate item
if item.Path == "" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: "path was empty",
})
continue
}
// Prevent deletion of root
if item.Path == "/" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: "cannot delete root directory",
})
continue
}
if d.share != nil {
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
// get user scope path from share
userScope, err := d.shareUser.GetScopeForSourceName(source)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: "user does not have access",
})
continue
}
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: indexPath,
Source: source,
ShowHidden: true,
}, store.Access, filePermUser)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
// Delete the file/directory
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
if err != nil {
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: "error deleting file/directory, admin must check the logs",
})
continue
}
// Delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
} else {
// Regular user context - validate source and check user scope
if item.Source == "" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: "source was empty, source is required",
})
continue
}
// Check user scope for this source
_, err := filePermUser.GetScopeForSourceName(item.Source)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: fmt.Sprintf("user does not have access: %v", err),
})
continue
}
idx := indexing.GetIndex(item.Source)
if idx == nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: "source not found",
})
continue
}
// Get file info
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: idx.MakeIndexPath(item.Path, false),
Source: item.Source,
ShowHidden: true,
}, store.Access, filePermUser)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: err.Error(),
})
continue
}
err = files.DeleteFiles(item.Source, fileInfo.RealPath, fileInfo.Type == "directory")
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: err.Error(),
})
continue
}
preview.DelThumbs(r.Context(), *fileInfo)
}
// Success
response.Succeeded = append(response.Succeeded, item)
}
// Determine status code based on results
statusCode := http.StatusOK
if len(response.Failed) > 0 {
statusCode = http.StatusMultiStatus
}
return renderJSON(w, r, response, statusCode)
}
// resourcePostHandler creates or uploads a new resource.
// @Summary Create or upload a resource
// @Description Creates a new resource or uploads a file at the specified path. Supports file uploads and directory creation.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory"
// @Param path query string true "url encoded destination path where to place the files inside the destination source"
// @Param source query string true "Name for the desired filebrowser destination source name, default is used if not provided"
// @Param override query bool false "Override existing file if true"
// @Param isDir query bool false "Explicitly specify if the resource is a directory"
@@ -210,58 +381,41 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// @Router /api/resources [post]
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
unescapedPath := path
source := r.URL.Query().Get("source")
var err error
accessStore := store.Access
// if share is not nil, then set accessStore to nil
if d.share != nil {
accessStore = nil
} else {
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
logger.Debugf("invalid source encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
unescapedPath, err = url.QueryUnescape(path)
if err != nil {
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
userscope := ""
// Determine if this is a directory or file based on trailing slash
// Strip trailing slash from userscope to prevent double slashes
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
path = utils.JoinPathAsUnix(userscope, unescapedPath)
if d.share == nil && !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Determine if this is a directory based on isDir query param or trailing slash (for backwards compatibility)
isDirParam := r.URL.Query().Get("isDir")
isDir := isDirParam == "true" || strings.HasSuffix(unescapedPath, "/")
filePermUser := d.user
if d.share != nil {
filePermUser = d.shareUser
}
isDir := r.URL.Query().Get("isDir") == "true"
fileOpts := utils.FileOptions{
Username: d.user.Username,
Path: path,
Source: source,
Expand: false,
Path: path,
Source: source,
Expand: false,
FollowSymlinks: true,
}
idx := indexing.GetIndex(source)
if idx == nil {
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(path)
userscope, err := filePermUser.GetScopeForSourceName(source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
// get scoped path
realPath, _, _ := idx.GetRealPath(fullIndexPath)
// Check access control for the target path
if accessStore != nil && !accessStore.Permitted(idx.Path, path, d.user.Username) {
if !store.Access.Permitted(idx.Path, path, filePermUser.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
@@ -280,11 +434,16 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Directories creation on POST.
if isDir {
err = files.WriteDirectory(fileOpts)
// Create a new FileOptions with the full index path
dirOpts := fileOpts
dirOpts.Path = fullIndexPath
err = files.WriteDirectory(dirOpts)
if err != nil {
logger.Debugf("error writing directory: %v", err)
return errToStatus(err), err
}
return http.StatusOK, nil
}
@@ -320,7 +479,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore)
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -370,22 +529,26 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// close file before moving
outFile.Close()
// Move the completed file from the temp location to the final destination
err = fileutils.MoveFile(tempFilePath, realPath)
err = files.MoveResource(false, source, source, tempFilePath, realPath, store.Share, store.Access)
if err != nil {
logger.Debugf("could not move file from %v to %v: %v", tempFilePath, realPath, err)
return http.StatusInternalServerError, fmt.Errorf("could not move file from chunked folder to destination: %v", err)
}
go files.RefreshIndex(source, realPath, false, false) //nolint:errcheck
}
return http.StatusOK, nil
}
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore)
if err != nil {
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
return http.StatusConflict, nil
}
// If overriding, delete existing thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
}
err = files.WriteFile(fileOpts.Source, fileOpts.Path, r.Body)
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
if err != nil {
logger.Debugf("error writing file: %v", err)
return errToStatus(err), err
@@ -409,166 +572,291 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Router /api/resources [put]
func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Only allow PUT for files.
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
}
// Strip trailing slash from userscope to prevent double slashes
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
// Get user scope to resolve full index path for write operation
userScope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
// Check access control for the target path
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
logger.Debugf("user %s denied access to path %s", d.user.Username, path)
if store.Access != nil && !store.Access.Permitted(idx.Path, fullIndexPath, d.user.Username) {
logger.Debugf("user %s denied access to path %s", d.user.Username, fullIndexPath)
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
err = files.WriteFile(source, utils.JoinPathAsUnix(userscope, path), r.Body)
err = files.WriteFile(source, utils.JoinPathAsUnix(userScope, path), r.Body)
return errToStatus(err), err
}
// resourcePatchHandler performs a patch operation (e.g., move, rename) on a resource.
// @Summary Patch resource (move/rename)
// @Description Moves or renames a resource to a new destination.
// resourcePatchHandler performs a patch operation (e.g., move, copy, rename) on resources.
// @Summary Move, copy, or rename resources
// @Description Performs move, copy, or rename operations on multiple resources. All operations are performed atomically.
// @Tags Resources
// @Accept json
// @Produce json
// @Param from query string true "Path from resource in <source_name>::<index_path> format"
// @Param destination query string true "Destination path for the resource"
// @Param action query string true "Action to perform (copy, rename)"
// @Param overwrite query bool false "Overwrite if destination exists"
// @Param rename query bool false "Rename if destination exists"
// @Success 200 "Resource moved/renamed successfully"
// @Param request body MoveCopyRequest true "Move/copy request with items and action"
// @Success 200 {object} MoveCopyResponse "All operations completed successfully"
// @Success 207 {object} MoveCopyResponse "Partial success - some operations succeeded, some failed"
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or parameters"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
// @Failure 409 {object} map[string]string "Conflict - Destination exists"
// @Failure 500 {object} map[string]string "Internal server error"
// @Failure 500 {object} MoveCopyResponse "All operations failed"
// @Router /api/resources [patch]
func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
action := r.URL.Query().Get("action")
if !d.user.Permissions.Modify && d.share == nil {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
encodedFrom := r.URL.Query().Get("from")
// Decode the URL-encoded path
src, err := url.QueryUnescape(encodedFrom)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
dst := r.URL.Query().Get("destination")
dst, err = url.QueryUnescape(dst)
if err != nil {
return errToStatus(err), err
}
splitSrc := strings.Split(src, "::")
if len(splitSrc) <= 1 {
return http.StatusBadRequest, fmt.Errorf("invalid source path: %v", src)
}
srcIndex := splitSrc[0]
src = splitSrc[1]
splitDst := strings.Split(dst, "::")
if len(splitDst) <= 1 {
return http.StatusBadRequest, fmt.Errorf("invalid destination path: %v", dst)
}
dstIndex := splitDst[0]
dst = splitDst[1]
if dst == "/" || src == "/" {
return http.StatusForbidden, fmt.Errorf("forbidden: source or destination is attempting to modify root")
}
userscopeDst, err := settings.GetScopeFromSourceName(d.user.Scopes, dstIndex)
if err != nil {
return http.StatusForbidden, err
}
userscopeDst = strings.TrimRight(userscopeDst, "/")
userscopeSrc, err := settings.GetScopeFromSourceName(d.user.Scopes, srcIndex)
if err != nil {
return http.StatusForbidden, err
}
userscopeSrc = strings.TrimRight(userscopeSrc, "/")
idx := indexing.GetIndex(dstIndex)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", dstIndex)
}
// check target dir exists
parentDir, _, err := idx.GetRealPath(userscopeDst, filepath.Dir(dst))
if err != nil {
logger.Debugf("Could not get real path for parent dir: %v %v %v", userscopeDst, filepath.Dir(dst), err)
return http.StatusNotFound, err
}
realDest := parentDir + "/" + filepath.Base(dst)
idx2 := indexing.GetIndex(srcIndex)
if idx2 == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", srcIndex)
}
realSrc, isSrcDir, err := idx2.GetRealPath(userscopeSrc, src)
if err != nil {
return http.StatusNotFound, err
}
// Check access control for both source and destination paths
if store.Access != nil {
if !store.Access.Permitted(idx2.Path, src, d.user.Username) {
logger.Debugf("user %s denied access to source path %s", d.user.Username, src)
return http.StatusForbidden, fmt.Errorf("access denied to source path %s", src)
}
if !store.Access.Permitted(idx.Path, dst, d.user.Username) {
logger.Debugf("user %s denied access to destination path %s", d.user.Username, dst)
return http.StatusForbidden, fmt.Errorf("access denied to destination path %s", dst)
}
}
rename := r.URL.Query().Get("rename") == "true"
if rename {
realDest = addVersionSuffix(realDest)
}
// Validate move/rename operation to prevent circular references
if action == "rename" || action == "move" {
if err = validateMoveOperation(realSrc, realDest, isSrcDir); err != nil {
return http.StatusBadRequest, err
req, ok := d.Data.(MoveCopyRequest)
if req.Action == "" || !ok {
// Parse request body
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
}
}
err = patchAction(r.Context(), patchActionParams{
action: action,
srcIndex: srcIndex,
dstIndex: dstIndex,
src: realSrc,
dst: realDest,
d: d,
isSrcDir: isSrcDir,
})
if err != nil {
logger.Debugf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err)
if len(req.Items) == 0 {
return http.StatusBadRequest, fmt.Errorf("items array cannot be empty")
}
return errToStatus(err), err
if req.Action == "" {
return http.StatusBadRequest, fmt.Errorf("action is required (copy, move, or rename)")
}
response := MoveCopyResponse{
Succeeded: make([]MoveCopyItem, 0),
Failed: make([]MoveCopyItem, 0),
}
// Process each item
for _, item := range req.Items {
// Validate all fields are provided
if item.FromSource == "" || item.FromPath == "" || item.ToSource == "" || item.ToPath == "" {
item.Message = "fromSource, fromPath, toSource, and toPath are required"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
// Check for root path modifications
if item.ToPath == "/" || item.FromPath == "/" {
item.Message = "cannot modify root directory"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
// Get user scopes for both sources
// For shares, paths are already absolute, so use empty scope
userscopeSrc := ""
userscopeDst := ""
if d.share == nil {
var err error
userscopeSrc, err = d.user.GetScopeForSourceName(item.FromSource)
if err != nil {
item.Message = "source not available"
response.Failed = append(response.Failed, item)
continue
}
userscopeDst, err = d.user.GetScopeForSourceName(item.ToSource)
if err != nil {
item.Message = "destination source not available"
response.Failed = append(response.Failed, item)
continue
}
}
// Get source index
srcIdx := indexing.GetIndex(item.FromSource)
if srcIdx == nil {
item.Message = "source not found"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
// Get destination index
dstIdx := indexing.GetIndex(item.ToSource)
if dstIdx == nil {
item.Message = "destination source not found"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
// Build full index paths for access control
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
// Check access control for both source and destination paths
if !store.Access.Permitted(srcIdx.Path, fullSrcIndexPath, d.user.Username) {
item.Message = "access denied to source path"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
if !store.Access.Permitted(dstIdx.Path, fullDstIndexPath, d.user.Username) {
item.Message = "access denied to destination path"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
// Get real paths
// Combine user scope with item paths BEFORE calling GetRealPath to avoid double scope application
fullSrcPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcPath)
if err != nil {
logger.Errorf("could not resolve source path: %v, item.FromPath: %v", err, item.FromPath)
item.Message = "could not resolve source path"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
// Check destination parent directory exists
dstParentPath := filepath.Dir(item.ToPath)
fullDstParentPath := utils.JoinPathAsUnix(userscopeDst, dstParentPath)
parentDir, _, err := dstIdx.GetRealPath(fullDstParentPath)
if err != nil {
item.Message = "destination directory does not exist"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
realDest := parentDir + "/" + filepath.Base(item.ToPath)
// Auto-rename if requested
if req.Rename {
realDest = addVersionSuffix(realDest)
}
// Validate move/rename operation to prevent circular references
if req.Action == "rename" || req.Action == "move" {
if err = validateMoveOperation(realSrc, realDest, isSrcDir); err != nil {
item.Message = "invalid move operation, circular reference"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
}
// Perform the action
err = patchAction(r.Context(), patchActionParams{
action: req.Action,
srcIndex: item.FromSource,
dstIndex: item.ToSource,
src: realSrc,
dst: realDest,
d: d,
isSrcDir: isSrcDir,
})
if err != nil {
logger.Errorf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err)
if d.share != nil {
item.Message = "could not run patch action"
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
item.Message = err.Error()
response.Failed = append(response.Failed, item)
continue
}
// Success
response.Succeeded = append(response.Succeeded, item)
}
if len(response.Failed) == 0 && len(response.Succeeded) == 0 {
response.Failed = append(response.Failed, MoveCopyItem{
Message: "no operations performed",
})
}
// For shares, sanitize the response to only include messages (hide paths)
if d.share != nil {
sanitizedFailed := make([]MoveCopyItem, len(response.Failed))
for i, item := range response.Failed {
sanitizedFailed[i] = MoveCopyItem{
Message: item.Message,
}
}
response.Failed = sanitizedFailed
// Clear succeeded items details for shares (only keep count implicitly via array length)
sanitizedSucceeded := make([]MoveCopyItem, len(response.Succeeded))
response.Succeeded = sanitizedSucceeded
}
// Determine status code based on results
statusCode := http.StatusOK
if len(response.Failed) > 0 && len(response.Succeeded) == 0 {
// All operations failed - return 500 error
statusCode = http.StatusInternalServerError
} else if len(response.Failed) > 0 && len(response.Succeeded) > 0 {
// Some succeeded, some failed - return 207 multi-status
statusCode = http.StatusMultiStatus
}
// If all succeeded, statusCode remains 200 OK
return renderJSON(w, r, response, statusCode)
}
func addVersionSuffix(source string) string {
@@ -604,16 +892,21 @@ func patchAction(ctx context.Context, params patchActionParams) error {
return err
case "rename", "move":
idx := indexing.GetIndex(params.srcIndex)
srcPath := idx.MakeIndexPath(params.src)
if !params.isSrcDir {
srcPath = strings.TrimSuffix(srcPath, "/")
srcPath := idx.MakeIndexPath(params.src, params.isSrcDir)
userScope := ""
userScope, _ = params.d.user.GetScopeForSourceName(params.srcIndex)
if userScope != "" && userScope != "/" {
// Strip the user scope from srcPath so FileInfoFaster doesn't double it
srcPath = strings.TrimPrefix(srcPath, userScope)
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Username: params.d.user.Username,
Path: srcPath,
Source: params.srcIndex,
IsDir: params.isSrcDir,
}, store.Access)
FollowSymlinks: true,
Path: srcPath,
Source: params.srcIndex,
IsDir: params.isSrcDir,
ShowHidden: params.d.user.ShowHidden,
}, store.Access, params.d.user)
if err != nil {
return err
@@ -628,10 +921,8 @@ func patchAction(ctx context.Context, params patchActionParams) error {
}
func inspectIndex(w http.ResponseWriter, r *http.Request) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
index := indexing.GetIndex(source)
if index == nil {
+93 -39
View File
@@ -3,19 +3,19 @@ package http
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
type searchOptions struct {
query string
source string
sources []string
searchScope string
combinedPath string
combinedPath map[string]string // source -> combinedPath
sessionId string
largest bool
}
@@ -27,42 +27,54 @@ type searchOptions struct {
// against the file index, which is built from the root directory specified in
// the server's configuration. The results are filtered based on the user's scope.
//
// The handler supports searching a single source (using the 'source' parameter)
// or multiple sources (using the 'sources' parameter). When multiple sources
// are specified, the scope is always set to the user's scope for each source.
//
// The handler expects the following headers in the request:
// - SessionId: A unique identifier for the user's session.
// - UserScope: The scope of the user, which influences the search context.
//
// The request URL should include a query parameter named `query` that specifies
// the search terms to use. The response will include an array of searchResponse objects
// containing the path, type, and dir status.
// The request URL should include query parameters:
// - query: The search terms to use (required)
// - source: Source name (deprecated, use 'sources' instead)
// - sources: Comma-separated list of source names (e.g., "source1,source2")
// - scope: Optional path within user scope to search
//
// Example request:
// Example request (single source):
//
// GET api/search?query=myfile
// GET api/search?query=myfile&source=mysource
//
// Example request (multiple sources):
//
// GET api/search?query=myfile&sources=source1,source2
//
// Example response:
// [
//
// {
// "path": "/path/to/myfile.txt",
// "type": "text"
// "type": "text",
// "source": "mysource"
// },
// {
// "path": "/path/to/mydir/",
// "type": "directory"
// "type": "directory",
// "source": "mysource"
// }
//
// ]
//
// @Summary Search Files
// @Description Searches for files matching the provided query. Returns file paths and metadata based on the user's session and scope.
// @Description Searches for files matching the provided query. Returns file paths and metadata based on the user's session and scope. Supports searching across multiple sources when using the 'sources' parameter.
// @Tags Search
// @Accept json
// @Produce json
// @Param query query string true "Search query"
// @Param source query string true "Source name for the desired source"
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only"
// @Param source query string false "Source name for the desired source (deprecated, use 'sources' instead)"
// @Param sources query string false "Comma-separated list of source names to search across multiple sources. When multiple sources are specified, scope is always the user's scope for each source."
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only. Ignored when multiple sources are specified."
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
// @Success 200 {array} indexing.SearchResult "List of search results"
// @Success 200 {array} indexing.SearchResult "List of search results with source field populated"
// @Failure 400 {object} map[string]string "Bad Request"
// @Router /api/search [get]
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
@@ -70,52 +82,94 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
if err != nil {
return http.StatusBadRequest, err
}
index := indexing.GetIndex(searchOptions.source)
if index == nil {
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.source)
var response []*indexing.SearchResult
if len(searchOptions.sources) == 1 {
// Single source - use the existing Search method for backward compatibility
index := indexing.GetIndex(searchOptions.sources[0])
if index == nil {
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.sources[0])
}
combinedPath := searchOptions.combinedPath[searchOptions.sources[0]]
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
} else {
// Multiple sources - use the new SearchMultiSources function
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
}
// Perform the search using the provided query and user scope
response := index.Search(searchOptions.query, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
// Remove the user scope from the path (modifying in place is safe - these are fresh allocations)
// Filter out items that are not permitted according to access rules and trim user scope from paths
filteredResponse := make([]*indexing.SearchResult, 0, len(response))
for _, result := range response {
result.Path = strings.TrimPrefix(result.Path, searchOptions.combinedPath)
index := indexing.GetIndex(result.Source)
combinedPath := searchOptions.combinedPath[result.Source]
indexPath := utils.JoinPathAsUnix(combinedPath, result.Path)
if store.Access != nil && !store.Access.Permitted(index.Path, indexPath, d.user.Username) {
continue // Silently skip this file/folder
}
// Remove the user scope from the path (modifying in place is safe - these are fresh allocations)
result.Path = strings.TrimPrefix(result.Path, combinedPath)
if result.Path == "" {
result.Path = "/"
}
filteredResponse = append(filteredResponse, result)
}
return renderJSON(w, r, response)
return renderJSON(w, r, filteredResponse)
}
func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, error) {
query := r.URL.Query().Get("query")
source := r.URL.Query().Get("source")
scope := r.URL.Query().Get("scope")
sourcesParam := r.URL.Query().Get("sources")
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
largest := r.URL.Query().Get("largest") == "true"
unencodedScope, err := url.PathUnescape(scope)
if err != nil {
return nil, fmt.Errorf("invalid path encoding: %v", err)
var sources []string
if sourcesParam != "" {
sources = strings.Split(sourcesParam, ",")
} else if sourceParam != "" {
sources = []string{sourceParam}
} else {
return nil, fmt.Errorf("either 'source' or 'sources' query parameter is required")
}
// Validate all sources exist
for _, source := range sources {
index := indexing.GetIndex(source)
if index == nil {
return nil, fmt.Errorf("index not found for source %s", source)
}
}
if len(query) < settings.Config.Server.MinSearchLength && !largest {
return nil, fmt.Errorf("query is too short, minimum length is %d", settings.Config.Server.MinSearchLength)
}
searchScope := strings.TrimPrefix(unencodedScope, ".")
searchScope := strings.TrimPrefix(scope, ".")
// If multiple sources, always use user scope (ignore searchScope)
if len(sources) > 1 {
searchScope = ""
}
// Retrieve the User-Agent and X-Auth headers from the request
sessionId := r.Header.Get("SessionId")
index := indexing.GetIndex(source)
if index == nil {
return nil, fmt.Errorf("index not found for source %s", source)
// Build combinedPath map for each source
combinedPathMap := make(map[string]string)
for _, source := range sources {
index := indexing.GetIndex(source)
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return nil, err
}
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope), true) // searchScope is a directory
combinedPathMap[source] = combinedPath
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return nil, err
}
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope))
return &searchOptions{
query: query,
source: source,
sources: sources,
searchScope: searchScope,
combinedPath: combinedPath,
combinedPath: combinedPathMap,
sessionId: sessionId,
largest: largest,
}, nil
+83 -45
View File
@@ -1,7 +1,9 @@
package http
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
@@ -15,7 +17,6 @@ import (
"golang.org/x/crypto/bcrypt"
"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/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
@@ -26,10 +27,9 @@ import (
// ShareResponse represents a share with computed username field and download URL
type ShareResponse struct {
*share.Link
Source string `json:"source"` // Override embedded field to show source name
Username string `json:"username,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
PathExists bool `json:"pathExists"`
Source string `json:"source"` // Override embedded field to show source name
Username string `json:"username,omitempty"`
PathExists bool `json:"pathExists"`
}
// convertToFrontendShareResponse converts shares to response format with usernames
@@ -63,14 +63,14 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
pathExists := utils.CheckPathExists(filepath.Join(sourceInfo.Path, s.Path))
s.CommonShare.HasPassword = s.HasPassword()
s.DownloadURL = getShareURL(r, s.Hash, true, s.Token)
s.ShareURL = getShareURL(r, s.Hash, false, s.Token)
// Create response with source name (overrides the embedded Link's source field)
responses = append(responses, &ShareResponse{
Link: s,
Source: sourceInfo.Name, // Override to show source name instead of backend path
Username: username,
DownloadURL: getDownloadURL(r, s.Hash),
PathExists: pathExists,
Link: s,
Source: sourceInfo.Name, // Override to show source name instead of backend path
Username: username,
PathExists: pathExists,
})
}
return responses, nil
@@ -127,7 +127,7 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", sourceName)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, sourceName)
userscope, err := d.user.GetScopeForSourceName(sourceName)
if err != nil {
return http.StatusForbidden, err
}
@@ -283,11 +283,21 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
stringHash := ""
var token string
if len(hash) > 0 {
tokenBuffer := make([]byte, 24)
if _, err = rand.Read(tokenBuffer); err != nil {
// Generate a cryptographically secure token similar to JWT
// Create a random payload
payloadBuffer := make([]byte, 24)
if _, err = rand.Read(payloadBuffer); err != nil {
return http.StatusInternalServerError, err
}
token = base64.URLEncoding.EncodeToString(tokenBuffer)
payload := base64.URLEncoding.EncodeToString(payloadBuffer)
// Sign the payload with HMAC-SHA256 using the same secret key as JWT tokens
mac := hmac.New(sha256.New, []byte(config.Auth.Key))
mac.Write([]byte(payload))
signature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
// Combine payload and signature: payload.signature (similar to JWT format)
token = payload + "." + signature
stringHash = string(hash)
}
if s != nil {
@@ -348,22 +358,19 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if idx == nil {
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", body.Source)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source.Name)
userscope, err := d.user.GetScopeForSourceName(source.Name)
if err != nil {
return http.StatusForbidden, err
}
scopePath := utils.JoinPathAsUnix(userscope, body.Path)
scopePath = utils.AddTrailingSlashIfNotExists(scopePath)
body.Path = scopePath
providedPath := body.Path
body.Path = utils.JoinPathAsUnix(userscope, providedPath)
body.Path = utils.AddTrailingSlashIfNotExists(body.Path)
// validate path exists as file or folder
_, exists := idx.GetReducedMetadata(body.Path, true) // true to check if it exists
if !exists {
// could be a file instead
_, exists := idx.GetReducedMetadata(utils.GetParentDirectoryPath(body.Path), true)
if !exists {
return http.StatusForbidden, fmt.Errorf("path not found: %s", body.Path)
}
_, _, err = idx.GetRealPath(body.Path)
if err != nil {
return http.StatusForbidden, fmt.Errorf("path not found: %s", providedPath)
}
if body.ShareType == "upload" && !body.AllowCreate {
body.AllowCreate = true
}
@@ -392,6 +399,7 @@ type DirectDownloadResponse struct {
Status string `json:"status"`
Hash string `json:"hash"`
DownloadURL string `json:"url"`
ShareURL string `json:"shareUrl"`
}
// shareDirectDownloadHandler creates a direct download link for files only.
@@ -412,23 +420,17 @@ type DirectDownloadResponse struct {
// @Router /api/share/direct [get]
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Extract query parameters
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
duration := r.URL.Query().Get("duration")
downloadCountStr := r.URL.Query().Get("count")
downloadSpeedStr := r.URL.Query().Get("speed")
// Validate required parameters
if encodedPath == "" || source == "" {
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Validate source exists
sourceInfo, ok := config.Server.NameToSource[source]
if !ok {
@@ -436,7 +438,7 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
}
// Get user scope for this source
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
@@ -509,7 +511,8 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
response := DirectDownloadResponse{
Status: "201",
Hash: existing.Hash,
DownloadURL: getDownloadURL(r, existing.Hash),
DownloadURL: getShareURL(r, existing.Hash, true, existing.Token),
ShareURL: getShareURL(r, existing.Hash, false, existing.Token),
}
return renderJSON(w, r, response)
}
@@ -540,21 +543,31 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
response := DirectDownloadResponse{
Status: "200",
Hash: secureHash,
DownloadURL: getDownloadURL(r, secureHash),
DownloadURL: getShareURL(r, secureHash, true, shareLink.Token),
ShareURL: getShareURL(r, secureHash, false, shareLink.Token),
}
return renderJSON(w, r, response)
}
func getDownloadURL(r *http.Request, hash string) string {
var downloadURL string
func getShareURL(r *http.Request, hash string, isDirectDownload bool, token string) string {
var shareURL string
tokenParam := ""
if token != "" && isDirectDownload {
tokenParam = fmt.Sprintf("&token=%s", url.QueryEscape(token))
}
if config.Server.ExternalUrl != "" {
downloadURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
if isDirectDownload {
shareURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s%s", config.Server.ExternalUrl, config.Server.BaseURL, hash, tokenParam)
} else {
shareURL = fmt.Sprintf("%s%spublic/share/%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
}
} else {
// Prefer X-Forwarded-Host for proxy support
var host string
var scheme string
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
@@ -568,9 +581,13 @@ func getDownloadURL(r *http.Request, hash string) string {
host = r.Host
scheme = getScheme(r)
}
downloadURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s", scheme, host, config.Server.BaseURL, hash)
if isDirectDownload {
shareURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s%s", scheme, host, config.Server.BaseURL, hash, tokenParam)
} else {
shareURL = fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, config.Server.BaseURL, hash)
}
}
return downloadURL
return shareURL
}
// shareInfoHandler retrieves share information by hash.
@@ -585,11 +602,32 @@ func getDownloadURL(r *http.Request, hash string) string {
// @Router /public/api/shareinfo [get]
func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
// Get the file link by hash
commonShare, err := store.Share.GetCommonShareByHash(hash)
// Get the file link by hash (need full Link to get Token)
shareLink, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
commonShare := shareLink.CommonShare
commonShare.DownloadURL = getShareURL(r, hash, true, shareLink.Token)
commonShare.ShareURL = getShareURL(r, hash, false, shareLink.Token)
_, _, err = getShareImagePartsHelper(shareLink, true)
if err == nil {
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
}
_, _, err = getShareImagePartsHelper(shareLink, false)
if err == nil {
commonShare.FaviconUrl = fmt.Sprintf("%spublic/api/share/image?favicon=true&hash=%s", config.Server.BaseURL, hash)
}
commonShare.Source = ""
commonShare.Path = ""
commonShare.SidebarLinks = []users.SidebarLink{}
for _, link := range shareLink.SidebarLinks {
if link.Category == "download" && shareLink.ShareType == "upload" {
continue
} else {
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
}
}
return renderJSON(w, r, commonShare)
}
+232 -27
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"text/template"
@@ -15,6 +16,7 @@ import (
)
var templateRenderer *TemplateRenderer
var cachedManifest PWAManifest // generated at startup
type TemplateRenderer struct {
templates *template.Template
@@ -73,9 +75,17 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
staticURL := config.Server.BaseURL + "public/static"
description := config.Frontend.Description
title := config.Frontend.Name
banner := staticURL + "/pwa-icon-512.png" // Use largest generated icon for best quality
disableSidebar := false
// Use custom favicon if configured and validated, otherwise fall back to default
favicon := staticURL + "/favicon"
// Determine the correct favicon extension based on type
faviconExt := ".svg" // Default to SVG
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
faviconExt = ".png"
}
favicon := staticURL + "/favicon" + faviconExt
shareHash := ""
data := make(map[string]interface{})
disableNavButtons := settings.Config.Frontend.DisableNavButtons
if d.share != nil && d.shareValid {
@@ -98,29 +108,98 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
userSelectedTheme = theme.CssRaw
}
}
if d.share.ShareTheme != "" {
theme, ok := config.Frontend.Styling.CustomThemeOptions[d.share.ShareTheme]
if ok {
userSelectedTheme = theme.CssRaw
if d.share.DisableSidebar {
disableSidebar = true
}
if strings.HasPrefix(d.share.Banner, "http") {
banner = d.share.Banner
} else {
_, _, err := getShareImagePartsHelper(d.share, true)
if err == nil {
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
}
}
if strings.HasPrefix(d.share.Favicon, "http") {
favicon = d.share.Favicon
} else {
_, _, err := getShareImagePartsHelper(d.share, false)
if err == nil {
favicon = fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
}
}
shareHash = d.share.Hash
}
// Set login icon URL
loginIcon := staticURL + "/loginIcon"
// Load loading spinners CSS from static files
loadingSpinnersCSS := ""
cssPath := "css/loadingSpinners.css"
cssContent, err := fs.ReadFile(assetFs, cssPath)
if err == nil {
loadingSpinnersCSS = string(cssContent)
}
// Determine OpenGraph image: use banner if set, otherwise use largest available icon (512x512)
ogImage := banner
if banner == staticURL+"/pwa-icon-512.png" {
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) but square works fine
ogImage = staticURL + "/pwa-icon-512.png"
}
// Construct the full URL for the current request
var fullURL string
if config.Server.ExternalUrl != "" {
// ExternalUrl already includes schema (e.g., http://mydomain.com)
fullURL = strings.TrimSuffix(config.Server.ExternalUrl, "/") + r.URL.Path
} else {
// Build URL from request
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
fullURL = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path)
}
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
// If custom favicon is set and it's SVG, use the favicon directly
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
// For custom PNG/ICO favicons, use generated PWA icons
pwaIcon192 = staticURL + "/pwa-icon-192.png"
pwaIcon256 = staticURL + "/pwa-icon-256.png"
pwaIcon512 = staticURL + "/pwa-icon-512.png"
}
data["htmlVars"] = map[string]interface{}{
"title": title,
"customCSS": config.Frontend.Styling.CustomCSSRaw,
"userSelectedTheme": userSelectedTheme,
"lightBackground": config.Frontend.Styling.LightBackground,
"darkBackground": config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
"baseURL": config.Server.BaseURL,
"favicon": favicon,
"loginIcon": loginIcon,
"color": defaultThemeColor,
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
"appIcon": staticURL + "/img/icons/android-chrome-256x256.png",
"description": description,
"title": title,
"customCSS": config.Frontend.Styling.CustomCSSRaw,
"userSelectedTheme": userSelectedTheme,
"lightBackground": config.Frontend.Styling.LightBackground,
"darkBackground": config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
"baseURL": config.Server.BaseURL,
"favicon": favicon,
"loginIcon": loginIcon,
"color": defaultThemeColor,
"winIcon": staticURL + "/mstile-256x256.png",
"appIcon": staticURL + "/apple-touch-icon.png",
"description": description,
"loadingSpinnersCSS": loadingSpinnersCSS,
"banner": banner,
"image": ogImage,
"url": fullURL,
"pwaIcon192": pwaIcon192,
"pwaIcon256": pwaIcon256,
"pwaIcon512": pwaIcon512,
"manifestURL": staticURL + "/site.webmanifest",
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
@@ -145,9 +224,12 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"updateAvailable": utils.GetUpdateAvailableUrl(),
"disableNavButtons": disableNavButtons,
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
"enableHeicConversion": config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] && settings.MediaEnabled(),
"enableHeicConversion": settings.CanConvertImage("heic"),
"eventBasedThemes": !config.Frontend.Styling.DisableEventBasedThemes,
"loginIcon": loginIcon,
"disableSidebar": disableSidebar,
"shareHash": shareHash,
"oidcLoginButtonText": config.Frontend.OIDCLoginButtonText,
}
// Marshal each variable to JSON strings for direct template usage
@@ -195,6 +277,16 @@ func setContentType(w http.ResponseWriter, path string) {
}
}
// manifestHandler serves the cached PWA manifest
func manifestHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
if err := json.NewEncoder(w).Encode(cachedManifest); err != nil {
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
}
}
// staticAssetHandler serves static assets exactly as the frontend build produces them
func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
const maxAge = 86400 // 1 day
@@ -210,18 +302,47 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
// Handle special routes that need path mapping
var assetPath string
switch path {
case "favicon.ico", "favicon":
// Handle custom favicon from filesystem
if settings.Env.FaviconIsCustom {
case "site.webmanifest":
manifestHandler(w, r)
return
case "favicon.svg":
// For custom SVG favicons, serve the SVG directly
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
http.ServeFile(w, r, settings.Env.FaviconPath)
return
}
// Use embedded default
// Use embedded default SVG
assetPath = settings.Env.FaviconEmbeddedPath
case "manifest.json":
assetPath = "img/icons/manifest.json"
case "site.webmanifest":
assetPath = "img/icons/site.webmanifest"
case "favicon.png":
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
}
// Fall back to embedded default favicon.png
assetPath = "img/icons/favicon.png"
case "favicon-32x32.png",
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
"apple-touch-icon.png":
// Serve generated icons from cache directory
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "mstile-256x256.png":
// Windows tile - redirect to pwa-icon-256.png (they're identical)
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.png")
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "loginIcon":
// Handle custom login icon from filesystem
if settings.Env.LoginIconIsCustom {
@@ -274,3 +395,87 @@ func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
}
return handleWithStaticData(w, r, d, "index.html", "text/html")
}
// PWAManifest represents the web app manifest structure
type PWAManifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Icons []PWAIcon `json:"icons"`
StartURL string `json:"start_url"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Description string `json:"description"`
}
// PWAIcon represents an icon in the web app manifest
type PWAIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
}
// generatePWAManifest creates the PWA manifest structure
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
shortName := name
if len(name) > 12 {
shortName = name[:12]
}
return PWAManifest{
Name: name,
ShortName: shortName,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
ThemeColor: themeColor,
Description: description,
Icons: []PWAIcon{
{
Src: pwaIcon192,
Sizes: "192x192",
Type: "image/png",
Purpose: "any",
},
{
Src: pwaIcon256,
Sizes: "256x256",
Type: "image/png",
Purpose: "any maskable",
},
{
Src: pwaIcon512,
Sizes: "512x512",
Type: "image/png",
Purpose: "any",
},
},
}
}
// InitializePWAManifest generates and caches the PWA manifest at startup
func InitializePWAManifest() {
staticURL := config.Server.BaseURL + "public/static"
title := config.Frontend.Name
description := config.Frontend.Description
defaultThemeColor := "#455a64"
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
favicon := staticURL + "/favicon"
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
pwaIcon192 = staticURL + "/pwa-icon-192.png"
pwaIcon256 = staticURL + "/pwa-icon-256.png"
pwaIcon512 = staticURL + "/pwa-icon-512.png"
}
cachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
}
+34 -6
View File
@@ -10,17 +10,29 @@ import (
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
// @Summary Generate OTP
// @Description Generates a new TOTP secret and QR code for the authenticated user.
// @Description Generates a new TOTP secret and QR code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.
// @Tags OTP
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "Username"
// @Param X-Password header string true "URL-encoded password"
// @Success 200 {object} map[string]string "OTP secret generated successfully."
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/otp/generate [post]
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
logger.Debug("Generating OTP for user:", d.user.Username)
url, err := auth.GenerateOtpForUser(d.user, store.Users)
username := r.URL.Query().Get("username")
if username == "" {
username = d.user.Username
}
if username != d.user.Username && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not authorized to generate OTP for this user")
}
targetUser, err := store.Users.Get(username)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
}
url, err := auth.GenerateOtpForUser(targetUser, store.Users)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
}
@@ -33,12 +45,14 @@ func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// verifyOTPHandler handles the verification of a TOTP code.
// @Summary Verify OTP
// @Description Verifies the provided TOTP code for the authenticated user.
// @Description Verifies the provided TOTP code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.
// @Tags OTP
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param code query string true "TOTP code to verify"
// @Param username query string true "Username"
// @Param X-Password header string true "URL-encoded password"
// @Param X-Secret header string true "TOTP code to verify"
// @Success 200 {object} HttpResponse "OTP token is valid."
// @Failure 401 {object} map[string]string "Unauthorized - invalid TOTP token"
// @Router /api/auth/otp/verify [post]
@@ -47,7 +61,21 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if code == "" {
return http.StatusUnauthorized, fmt.Errorf("code is required")
}
err := auth.VerifyTotpCode(d.user, code, store.Users)
targetUser := d.user
username := r.URL.Query().Get("username")
// If admin is verifying for another user
if username != "" && username != d.user.Username && d.user.Permissions.Admin {
var err error
targetUser, err = store.Users.Get(username)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
}
logger.Debugf("Admin %s verifying OTP for user: %s", d.user.Username, targetUser.Username)
}
err := auth.VerifyTotpCode(targetUser, code, store.Users)
if err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
}
+80 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http"
"sort"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
@@ -90,8 +90,85 @@ func prepForFrontend(u *users.User) {
u.OtpEnabled = u.TOTPSecret != ""
u.TOTPSecret = ""
u.TOTPNonce = ""
u.Scopes = settings.ConvertToFrontendScopes(u.Scopes)
u.SidebarLinks = settings.ConvertToFrontendSidebarLinks(u.SidebarLinks)
u.Scopes = u.GetFrontendScopes()
u.SidebarLinks = u.GetFrontendSidebarLinks()
u.Locale = normalizeLocale(u.Locale)
}
// normalizeLocale converts various locale formats (xx_xx, xx-xx, xxxx) to camelCase format (xxXX)
// Frontend expects camelCase for compound locales (zhCN, ptBR, etc.) as shown in select option values
func normalizeLocale(locale string) string {
if locale == "" {
return locale
}
// Convert to lowercase for processing
lower := strings.ToLower(locale)
// Special case mappings (standard locale codes to frontend camelCase format)
specialCases := map[string]string{
"cs": "cz", // Czech
"uk": "ua", // Ukrainian
"zh-cn": "zhCN",
"zh_cn": "zhCN",
"zhcn": "zhCN",
"zh-tw": "zhTW",
"zh_tw": "zhTW",
"zhtw": "zhTW",
"pt-br": "ptBR",
"pt_br": "ptBR",
"ptbr": "ptBR",
"sv-se": "svSE",
"sv_se": "svSE",
"svse": "svSE",
"nl-be": "nlBE",
"nl_be": "nlBE",
"nlbe": "nlBE",
}
// Check special cases first
if normalized, ok := specialCases[lower]; ok {
return normalized
}
// If already in camelCase format (4+ chars, has uppercase), return as-is
if len(locale) >= 4 {
// Check if it's a known camelCase locale
knownCamelCase := map[string]bool{
"zhCN": true, "zhTW": true, "ptBR": true, "svSE": true, "nlBE": true,
}
if knownCamelCase[locale] {
return locale
}
}
// Handle xx_xx or xx-xx format: convert to xxXX (camelCase)
parts := strings.FieldsFunc(lower, func(r rune) bool {
return r == '_' || r == '-'
})
if len(parts) == 2 {
// Convert to camelCase: first part lowercase, second part capitalized
first := parts[0]
second := parts[1]
if len(second) > 0 {
second = strings.ToUpper(second[:1]) + second[1:]
}
normalized := first + second
// Check if this matches a known compound locale
knownCompound := map[string]string{
"zhcn": "zhCN", "zhtw": "zhTW", "ptbr": "ptBR",
"svse": "svSE", "nlbe": "nlBE",
}
if normalizedVal, ok := knownCompound[normalized]; ok {
return normalizedVal
}
return normalized
}
// Single part locale (en, fr, de, etc.) - return as-is (lowercase is fine)
return lower
}
// userDeleteHandler deletes a user by ID.
+81
View File
@@ -0,0 +1,81 @@
package http
import (
"fmt"
"net/http"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/net/webdav"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func createWebDAVHandler(prefix string) handleFunc {
var locks = webdav.NewMemLS()
// webdavHandler serves the webdav requests.
return func(writer http.ResponseWriter, request *http.Request, d *requestContext) (int, error) {
fullPath := request.PathValue("path")
source := request.PathValue("scope")
if !userHasReadWriteAccess(d) {
// (reddec): we're currently allowing WebDAV access only for users with R/W access for simplicity.
// in the follow-ups, we need to add mapping of webdav operations and permissions (which is trickier than it looks like).
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
virtualPath := utils.JoinPathAsUnix(userscope, fullPath)
idx := indexing.GetIndex(source)
if idx == nil {
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(virtualPath)
scopePath, _, _ := idx.GetRealPath(userscope)
// checking only base (scope) path, as per-item permission is currently impossible to implement.
// however, when we are granting an access to the user, we are granting it per-folder, so everything should be fine.
// TODO: properly handle symlink and hidden files in requests
info, err := idx.GetFsInfo(userscope, false, true)
if err != nil {
logger.Debugf("error getting dir info: %v", err)
return http.StatusNotFound, err
}
err = store.Access.CheckChildItemAccess(&iteminfo.ExtendedFileInfo{
FileInfo: *info,
Source: source,
RealPath: scopePath,
}, idx, d.user.Username)
if err != nil {
logger.Debugf("error checking child item access: %v", err)
return http.StatusForbidden, err
}
logger.Debugf("webdav: method=%s, request=%s, source=%s, path=%s, virtual_path=%s, real_path=%s, scope_path=%s", request.Method, request.URL.Path, source, fullPath, virtualPath, realPath, scopePath)
wd := &webdav.Handler{
Prefix: prefix + "/" + source,
FileSystem: webdav.Dir(scopePath),
LockSystem: locks,
Logger: func(request *http.Request, err error) {
if err != nil {
logger.Errorf("webdav handler failed on path %s: %s", request.URL.Path, err)
}
},
}
wd.ServeHTTP(writer, request)
return 0, nil // errors and responses (XML-formatted) are handled by webdav handler
}
}
func userHasReadWriteAccess(d *requestContext) bool {
return d.user.Permissions.Create && d.user.Permissions.Delete && d.user.Permissions.Modify && d.user.Permissions.Download
}
+249
View File
@@ -0,0 +1,249 @@
package icons
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
// sourceType represents the type of favicon source
type sourceType int
const (
sourceCustomSVG sourceType = iota
sourceCustomRaster
sourceDefault
)
// iconSize defines an icon size to generate
type iconSize struct {
size int
name string
env *string
}
// determineSourceType determines which type of favicon source we have
func determineSourceType(faviconPath string) sourceType {
if !settings.Env.FaviconIsCustom {
return sourceDefault
}
ext := strings.ToLower(filepath.Ext(faviconPath))
switch ext {
case ".svg":
return sourceCustomSVG
default:
return sourceCustomRaster
}
}
// loadDefaultFavicon loads the embedded default favicon PNG
func loadDefaultFavicon() ([]byte, error) {
assetFs := fileutils.GetAssetFS()
if assetFs == nil {
return nil, errors.New("asset filesystem not initialized")
}
pngPath := "img/icons/favicon.png"
sourceData, err := fs.ReadFile(assetFs, pngPath)
if err != nil {
return nil, fmt.Errorf("failed to read default favicon PNG: %w", err)
}
logger.Debug("Loaded default embedded favicon")
return sourceData, nil
}
// findRasterCompanion looks for a raster companion file for an SVG favicon
// Returns the companion data and path, or error if not found
func findRasterCompanion(svgPath string) ([]byte, string, error) {
basePath := svgPath[:len(svgPath)-len(filepath.Ext(svgPath))]
rasterExts := []string{".png", ".jpg", ".jpeg", ".webp", ".gif"}
for _, ext := range rasterExts {
companionPath := basePath + ext
if _, err := os.Stat(companionPath); err == nil {
data, readErr := os.ReadFile(companionPath)
if readErr != nil {
continue
}
return data, companionPath, nil
}
}
return nil, "", fmt.Errorf("no raster companion found at %s.{png,jpg,webp,gif}", basePath)
}
// convertRasterToPNG decodes any raster format and re-encodes as PNG
func convertRasterToPNG(rasterData []byte, sourceExt string) ([]byte, error) {
img, err := imaging.Decode(bytes.NewReader(rasterData))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
var buf bytes.Buffer
if err := imaging.Encode(&buf, img, imaging.PNG); err != nil {
return nil, fmt.Errorf("failed to convert to PNG: %w", err)
}
logger.Debugf("Converted %s to PNG for icon generation", sourceExt)
return buf.Bytes(), nil
}
// loadFaviconSource loads and prepares the favicon source data as PNG
// Returns PNG data ready for resizing into icon sizes
func loadFaviconSource() ([]byte, error) {
sourceType := determineSourceType(settings.Env.FaviconPath)
switch sourceType {
case sourceCustomSVG:
logger.Debugf("Processing custom SVG favicon: %s", settings.Env.FaviconPath)
return handleSVGFavicon(settings.Env.FaviconPath)
case sourceCustomRaster:
logger.Debugf("Processing custom favicon: %s", settings.Env.FaviconPath)
return handleRasterFavicon(settings.Env.FaviconPath)
case sourceDefault:
logger.Debug("Using default embedded favicon")
return loadDefaultFavicon()
default:
return nil, errors.New("unknown icon source type")
}
}
// handleSVGFavicon processes an SVG favicon by finding its raster companion
func handleSVGFavicon(svgPath string) ([]byte, error) {
logger.Debug("Looking for companion for SVG favicon")
companionData, companionPath, err := findRasterCompanion(svgPath)
if err != nil {
logger.Warningf("SVG favicon has no companion: %v", err)
logger.Warning("Falling back to default embedded favicon")
logger.Warning("For best Apple/iOS compatibility, provide a PNG/JPG companion alongside your SVG")
return loadDefaultFavicon()
}
logger.Debugf("Found companion: %s", companionPath)
return convertRasterToPNG(companionData, filepath.Ext(companionPath))
}
// handleRasterFavicon processes a raster favicon (PNG, JPEG, GIF, WebP, etc.)
func handleRasterFavicon(faviconPath string) ([]byte, error) {
sourceData, err := os.ReadFile(faviconPath)
if err != nil {
return nil, fmt.Errorf("failed to read favicon: %w", err)
}
return convertRasterToPNG(sourceData, filepath.Ext(faviconPath))
}
// getIconSizesToGenerate returns all icon sizes that need to be generated
func getIconSizesToGenerate() []iconSize {
return []iconSize{
// Browser favicon (32x32 PNG for Safari/older browsers)
{32, "favicon-32x32.png", nil},
// PWA icons (PNG required for Apple PWA compatibility)
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
}
}
// generateIconSizes generates all required icon sizes from source PNG data
func generateIconSizes(sourceData []byte) error {
previewService := preview.GetService()
if previewService == nil {
return errors.New("preview service not initialized")
}
iconSizes := getIconSizesToGenerate()
allSuccess := true
generatedCount := 0
for _, icon := range iconSizes {
if err := generateSingleIcon(previewService, sourceData, icon); err != nil {
logger.Warningf("Failed to generate %s: %v", icon.name, err)
allSuccess = false
continue
}
// Update environment variable if specified
if icon.env != nil {
*icon.env = filepath.Join("icons", icon.name)
}
generatedCount++
}
if allSuccess {
logger.Debugf("Successfully generated %d icon sizes", generatedCount)
} else {
logger.Warningf("Generated %d/%d icon sizes (some failed)", generatedCount, len(iconSizes))
}
return nil
}
// generateSingleIcon generates a single icon file at the specified size
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
// Create output file
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer outFile.Close()
// Resize image using the preview service's Resize method
err = previewService.Resize(
bytes.NewReader(sourceData),
outFile,
preview.ResizeOptions{
Width: icon.size,
Height: icon.size,
ResizeMode: preview.ResizeModeFill,
Quality: preview.QualityHigh,
Format: preview.FormatPng,
},
)
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("resize failed: %w", err)
}
return nil
}
// GeneratePWAIcons generates all PWA and platform icon sizes from favicon source
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
func GeneratePWAIcons() error {
logger.Info("Generating PWA icons...")
// Load and prepare source data as PNG
sourceData, err := loadFaviconSource()
if err != nil {
return fmt.Errorf("failed to load favicon source: %w", err)
}
// Generate all icon sizes
if err := generateIconSizes(sourceData); err != nil {
return fmt.Errorf("failed to generate icon sizes: %w", err)
}
return nil
}
+133 -84
View File
@@ -2,6 +2,7 @@ package indexing
import (
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
@@ -18,51 +19,81 @@ func setupConditionalTestIndex(conditionals settings.ConditionalFilter) *Index {
}
// Build the conditional maps (this is normally done during config loading)
source.Config.ResolvedConditionals = &settings.ResolvedConditionalsConfig{
FileNames: make(map[string]settings.ConditionalIndexConfig),
FolderNames: make(map[string]settings.ConditionalIndexConfig),
FilePaths: make(map[string]settings.ConditionalIndexConfig),
FolderPaths: make(map[string]settings.ConditionalIndexConfig),
NeverWatchPaths: make(map[string]struct{}),
IncludeRootItems: make(map[string]struct{}),
source.Config.ResolvedRules = settings.ResolvedRulesConfig{
FileNames: make(map[string]settings.ConditionalRule),
FolderNames: make(map[string]settings.ConditionalRule),
FilePaths: make(map[string]settings.ConditionalRule),
FolderPaths: make(map[string]settings.ConditionalRule),
NeverWatchPaths: make(map[string]struct{}),
IncludeRootItems: make(map[string]struct{}),
IgnoreAllHidden: false,
IgnoreAllZeroSizeFolders: false,
IgnoreAllSymlinks: false,
IndexingDisabled: false,
}
// Backwards compatibility: if old format fields are set, treat as global rules
if conditionals.IgnoreHidden {
source.Config.ResolvedRules.IgnoreAllHidden = true
}
if conditionals.ZeroSizeFolders {
source.Config.ResolvedRules.IgnoreAllZeroSizeFolders = true
}
// Build maps from ItemRules - match the real setConditionals implementation
resolved := source.Config.ResolvedConditionals
for _, rule := range conditionals.ItemRules {
// 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 {
source.Config.ResolvedRules.IgnoreAllHidden = true
}
if rule.IgnoreSymlinks {
source.Config.ResolvedRules.IgnoreAllSymlinks = true
}
if rule.IgnoreZeroSizeFolders {
source.Config.ResolvedRules.IgnoreAllZeroSizeFolders = true
}
if rule.Viewable {
source.Config.ResolvedRules.IndexingDisabled = true
}
}
// Note: FileNames and FolderNames are NOT populated from rules in the real implementation
// They remain empty maps (unused in current implementation)
// Handle exact path matches
if rule.FilePath != "" {
resolved.FilePaths[rule.FilePath] = rule
source.Config.ResolvedRules.FilePaths[rule.FilePath] = rule
}
if rule.FolderPath != "" {
resolved.FolderPaths[rule.FolderPath] = rule
source.Config.ResolvedRules.FolderPaths[rule.FolderPath] = rule
}
// Handle StartsWith/EndsWith (stored in slices)
if rule.FileEndsWith != "" {
resolved.FileEndsWith = append(resolved.FileEndsWith, rule)
source.Config.ResolvedRules.FileEndsWith = append(source.Config.ResolvedRules.FileEndsWith, rule)
}
if rule.FolderEndsWith != "" {
resolved.FolderEndsWith = append(resolved.FolderEndsWith, rule)
source.Config.ResolvedRules.FolderEndsWith = append(source.Config.ResolvedRules.FolderEndsWith, rule)
}
if rule.FileStartsWith != "" {
resolved.FileStartsWith = append(resolved.FileStartsWith, rule)
source.Config.ResolvedRules.FileStartsWith = append(source.Config.ResolvedRules.FileStartsWith, rule)
}
if rule.FolderStartsWith != "" {
resolved.FolderStartsWith = append(resolved.FolderStartsWith, rule)
source.Config.ResolvedRules.FolderStartsWith = append(source.Config.ResolvedRules.FolderStartsWith, rule)
}
// Handle NeverWatchPath
if rule.NeverWatchPath != "" {
resolved.NeverWatchPaths[rule.NeverWatchPath] = struct{}{}
source.Config.ResolvedRules.NeverWatchPaths[rule.NeverWatchPath] = struct{}{}
}
// Handle IncludeRootItem
if rule.IncludeRootItem != "" {
resolved.IncludeRootItems[rule.IncludeRootItem] = struct{}{}
source.Config.ResolvedRules.IncludeRootItems[rule.IncludeRootItem] = struct{}{}
}
}
@@ -126,12 +157,12 @@ func TestShouldSkip_FolderStartsWith(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{
ItemRules: []settings.ConditionalIndexConfig{
ItemRules: []settings.ConditionalRule{
{FolderStartsWith: tt.ruleValue},
},
})
result := idx.shouldSkip(true, false, tt.fullPath, tt.baseName, actionConfig{})
result := idx.ShouldSkip(true, tt.fullPath, false, false, false)
if result != tt.shouldSkip {
t.Errorf("%s: expected shouldSkip=%v, got %v (fullPath=%s, baseName=%s, rule=%s)",
tt.description, tt.shouldSkip, result, tt.fullPath, tt.baseName, tt.ruleValue)
@@ -174,15 +205,15 @@ func TestShouldSkip_FolderNames(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{
ItemRules: []settings.ConditionalIndexConfig{
ItemRules: []settings.ConditionalRule{
{FolderNames: tt.ruleValue},
},
})
// Manually populate FolderNames map since setConditionals doesn't do it
idx.Config.ResolvedConditionals.FolderNames[tt.ruleValue] = settings.ConditionalIndexConfig{FolderNames: tt.ruleValue}
idx.Config.ResolvedRules.FolderNames[tt.ruleValue] = settings.ConditionalRule{FolderNames: tt.ruleValue}
result := idx.shouldSkip(true, false, "/"+tt.baseName+"/", tt.baseName, actionConfig{})
result := idx.ShouldSkip(true, "/"+tt.baseName+"/", false, false, false)
if result != tt.shouldSkip {
t.Errorf("%s: expected shouldSkip=%v, got %v (baseName=%s, rule=%s)",
tt.description, tt.shouldSkip, result, tt.baseName, tt.ruleValue)
@@ -237,12 +268,12 @@ func TestShouldSkip_FolderPaths(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{
ItemRules: []settings.ConditionalIndexConfig{
ItemRules: []settings.ConditionalRule{
{FolderPath: tt.ruleValue},
},
})
result := idx.shouldSkip(true, false, tt.fullPath, tt.baseName, actionConfig{})
result := idx.ShouldSkip(true, tt.fullPath, false, false, false)
if result != tt.shouldSkip {
t.Errorf("%s: expected shouldSkip=%v, got %v (fullPath=%s, baseName=%s, rule=%s)",
tt.description, tt.shouldSkip, result, tt.fullPath, tt.baseName, tt.ruleValue)
@@ -285,12 +316,12 @@ func TestShouldSkip_FileStartsWith(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{
ItemRules: []settings.ConditionalIndexConfig{
ItemRules: []settings.ConditionalRule{
{FileStartsWith: tt.ruleValue},
},
})
result := idx.shouldSkip(false, false, "/"+tt.baseName, tt.baseName, actionConfig{})
result := idx.ShouldSkip(false, "/"+tt.baseName, false, false, false)
if result != tt.shouldSkip {
t.Errorf("%s: expected shouldSkip=%v, got %v (baseName=%s, rule=%s)",
tt.description, tt.shouldSkip, result, tt.baseName, tt.ruleValue)
@@ -333,12 +364,12 @@ func TestShouldSkip_FileEndsWith(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{
ItemRules: []settings.ConditionalIndexConfig{
ItemRules: []settings.ConditionalRule{
{FileEndsWith: tt.ruleValue},
},
})
result := idx.shouldSkip(false, false, "/"+tt.baseName, tt.baseName, actionConfig{})
result := idx.ShouldSkip(false, "/"+tt.baseName, false, false, false)
if result != tt.shouldSkip {
t.Errorf("%s: expected shouldSkip=%v, got %v (baseName=%s, rule=%s)",
tt.description, tt.shouldSkip, result, tt.baseName, tt.ruleValue)
@@ -385,12 +416,12 @@ func TestShouldSkip_FilePaths(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{
ItemRules: []settings.ConditionalIndexConfig{
ItemRules: []settings.ConditionalRule{
{FilePath: tt.ruleValue},
},
})
result := idx.shouldSkip(false, false, tt.fullPath, tt.baseName, actionConfig{})
result := idx.ShouldSkip(false, tt.fullPath, false, false, false)
if result != tt.shouldSkip {
t.Errorf("%s: expected shouldSkip=%v, got %v (fullPath=%s, rule=%s)",
tt.description, tt.shouldSkip, result, tt.fullPath, tt.ruleValue)
@@ -403,7 +434,7 @@ func TestShouldSkip_FileInExcludedFolderPath(t *testing.T) {
// Test that files in excluded folder paths are also skipped
// Uses FilePaths rule which applies to both the folder and its files via prefix matching
idx := setupConditionalTestIndex(settings.ConditionalFilter{
ItemRules: []settings.ConditionalIndexConfig{
ItemRules: []settings.ConditionalRule{
{FilePath: "/graham/"},
},
})
@@ -420,7 +451,7 @@ func TestShouldSkip_FileInExcludedFolderPath(t *testing.T) {
}
for _, tt := range tests {
result := idx.shouldSkip(false, false, tt.fullPath, tt.baseName, actionConfig{})
result := idx.ShouldSkip(false, tt.fullPath, false, false, false)
if result != tt.shouldSkip {
t.Errorf("File %s (%s): expected shouldSkip=%v, got %v",
tt.fullPath, tt.reason, tt.shouldSkip, result)
@@ -431,7 +462,7 @@ func TestShouldSkip_FileInExcludedFolderPath(t *testing.T) {
func TestShouldSkip_MultipleRules(t *testing.T) {
// Test that multiple rules work together
idx := setupConditionalTestIndex(settings.ConditionalFilter{
ItemRules: []settings.ConditionalIndexConfig{
ItemRules: []settings.ConditionalRule{
{FolderStartsWith: "temp"},
{FolderStartsWith: "tmp"},
{FolderNames: "node_modules"},
@@ -443,8 +474,8 @@ func TestShouldSkip_MultipleRules(t *testing.T) {
})
// Manually populate FolderNames map
idx.Config.ResolvedConditionals.FolderNames["node_modules"] = settings.ConditionalIndexConfig{FolderNames: "node_modules"}
idx.Config.ResolvedConditionals.FolderNames["@eaDir"] = settings.ConditionalIndexConfig{FolderNames: "@eaDir"}
idx.Config.ResolvedRules.FolderNames["node_modules"] = settings.ConditionalRule{FolderNames: "node_modules"}
idx.Config.ResolvedRules.FolderNames["@eaDir"] = settings.ConditionalRule{FolderNames: "@eaDir"}
tests := []struct {
isDir bool
@@ -468,7 +499,7 @@ func TestShouldSkip_MultipleRules(t *testing.T) {
}
for _, tt := range tests {
result := idx.shouldSkip(tt.isDir, false, tt.fullPath, tt.baseName, actionConfig{})
result := idx.ShouldSkip(tt.isDir, tt.fullPath, false, false, false)
if result != tt.shouldSkip {
t.Errorf("%s (%s): expected shouldSkip=%v, got %v",
tt.baseName, tt.reason, tt.shouldSkip, result)
@@ -480,13 +511,13 @@ func TestShouldSkip_ViewableStillSkips(t *testing.T) {
// Test that Viewable:true doesn't affect shouldSkip behavior
// The Viewable field is checked separately in IsViewable(), not in shouldSkip()
idx := setupConditionalTestIndex(settings.ConditionalFilter{
ItemRules: []settings.ConditionalIndexConfig{
ItemRules: []settings.ConditionalRule{
{FolderStartsWith: "important", Viewable: true}, // Viewable but still skipped from indexing
},
})
// shouldSkip still returns true even with Viewable:true (it's checked separately)
result := idx.shouldSkip(true, false, "/important/", "important", actionConfig{})
// ShouldSkip still returns true even with Viewable:true (it's checked separately)
result := idx.ShouldSkip(true, "/important/", false, false, false)
if result != true {
t.Errorf("Folder should be skipped (Viewable is checked separately), got shouldSkip=%v", result)
}
@@ -498,79 +529,97 @@ func TestShouldSkip_HiddenFiles(t *testing.T) {
})
tests := []struct {
isHidden bool
path string
shouldSkip bool
}{
{true, true},
{false, false},
{"/.hiddenfile", true}, // Hidden file (starts with .)
{"/file.txt", false}, // Regular file
{"/.hidden/file.txt", false}, // File in hidden directory (but file itself isn't hidden)
}
for _, tt := range tests {
result := idx.shouldSkip(false, tt.isHidden, "/file.txt", "file.txt", actionConfig{})
// For hidden file test, extract if the path starts with "/."
isHiddenPath := IsHidden(tt.path)
result := idx.ShouldSkip(false, tt.path, isHiddenPath, false, false)
if result != tt.shouldSkip {
t.Errorf("isHidden=%v: expected shouldSkip=%v, got %v",
tt.isHidden, tt.shouldSkip, result)
t.Errorf("path=%s: expected shouldSkip=%v, got %v",
tt.path, tt.shouldSkip, result)
}
}
}
func TestShouldSkip_NeverWatch(t *testing.T) {
// Test NeverWatch functionality - folders should be skipped during routine scans
func TestIsNeverWatchPath(t *testing.T) {
// Test NeverWatchPath functionality - paths indexed once, then never re-indexed
idx := setupConditionalTestIndex(settings.ConditionalFilter{
ItemRules: []settings.ConditionalIndexConfig{
{NeverWatchPath: "/cache/"}, // Must match the fullPath exactly
{NeverWatchPath: "/logs/"}, // Must match the fullPath exactly
ItemRules: []settings.ConditionalRule{
{NeverWatchPath: "/temp/"},
{NeverWatchPath: "/cache/"},
},
})
// Simulate a routine scan (wasIndexed=true, IsRoutineScan=true)
idx.wasIndexed = true
config := actionConfig{IsRoutineScan: true}
tests := []struct {
name string
fullPath string
baseName string
shouldSkip bool
description string
name string
path string
hasBeenIndexed bool
expectedSkipped bool
description string
}{
{
name: "Skip NeverWatch path during routine scan",
fullPath: "/cache/",
baseName: "cache",
shouldSkip: true,
description: "Folder with neverWatch should be skipped in routine scan",
name: "Initial scan - don't skip NeverWatch",
path: "/temp/",
hasBeenIndexed: false,
expectedSkipped: false,
description: "NeverWatch paths should NOT be skipped during initial scan",
},
{
name: "Skip NeverWatch name during routine scan",
fullPath: "/logs/",
baseName: "logs",
shouldSkip: true,
description: "Folder with neverWatch name should be skipped in routine scan",
name: "Routine scan - skip NeverWatch",
path: "/temp/",
hasBeenIndexed: true,
expectedSkipped: true,
description: "NeverWatch paths SHOULD be skipped after initial scan",
},
{
name: "Don't skip regular folder",
fullPath: "/regular/",
baseName: "regular",
shouldSkip: false,
description: "Regular folder should not be skipped",
name: "Initial scan - regular path",
path: "/regular/",
hasBeenIndexed: false,
expectedSkipped: false,
description: "Regular paths should not be skipped during initial scan",
},
{
name: "Routine scan - regular path",
path: "/regular/",
hasBeenIndexed: true,
expectedSkipped: false,
description: "Regular paths should not be skipped during routine scans",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := idx.shouldSkip(true, false, tt.fullPath, tt.baseName, config)
if result != tt.shouldSkip {
t.Errorf("%s: expected shouldSkip=%v, got %v (fullPath=%s, baseName=%s)",
tt.description, tt.shouldSkip, result, tt.fullPath, tt.baseName)
// Simulate whether index has been scanned before
if tt.hasBeenIndexed {
// Set a fake last indexed time to simulate completed scan
idx.mu.Lock()
if idx.scanners == nil {
idx.scanners = make(map[string]*Scanner)
}
scanner := &Scanner{
lastScanned: time.Now(),
}
idx.scanners["/"] = scanner
idx.mu.Unlock()
} else {
// Clear scanners to simulate initial state
idx.mu.Lock()
idx.scanners = make(map[string]*Scanner)
idx.mu.Unlock()
}
result := idx.IsNeverWatchPath(tt.path)
if result != tt.expectedSkipped {
t.Errorf("%s: expected IsNeverWatchPath=%v, got %v (path=%s, hasBeenIndexed=%v)",
tt.description, tt.expectedSkipped, result, tt.path, tt.hasBeenIndexed)
}
})
}
// Test initial scan (IsRoutineScan=false) - NeverWatch folders should NOT be skipped
config.IsRoutineScan = false
result := idx.shouldSkip(true, false, "/cache/", "cache", config)
if result != false {
t.Errorf("NeverWatch folder should NOT be skipped during initial scan, got shouldSkip=%v", result)
}
}
+160 -67
View File
@@ -1,9 +1,12 @@
package indexing
import (
"database/sql"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
@@ -11,6 +14,15 @@ import (
func setupTestIndexForDeletion(t *testing.T) *Index {
t.Helper()
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, _, err = dbsql.NewIndexDB("test_deletion", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
}
// Create index with mock data
idx := &Index{
Source: settings.Source{
@@ -20,60 +32,112 @@ func setupTestIndexForDeletion(t *testing.T) *Index {
DisableIndexing: false,
},
},
Directories: make(map[string]*iteminfo.FileInfo),
DirectoriesLedger: make(map[string]struct{}),
FoundHardLinks: make(map[string]uint64),
processedInodes: make(map[uint64]struct{}),
mock: true, // Enable mock mode
db: indexDB,
mock: true, // Enable mock mode
}
// Create mock directory structure with predictable sizes
idx.Directories["/"] = &iteminfo.FileInfo{
// Create mock directory structure with predictable sizes using database
now := time.Now()
// Root directory
rootDir := &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
Size: 1000, // Total: 100 + 200 + 700 (subdir)
Name: "/",
Type: "directory",
Size: 1000, // Total: 100 + 200 + 700 (subdir)
ModTime: now,
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Size: 100}},
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Size: 200}},
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Size: 100, ModTime: now}},
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Size: 200, ModTime: now}},
},
Folders: []iteminfo.ItemInfo{
{Name: "subdir", Type: "directory", Size: 700},
},
}
_ = idx.db.InsertItem("test_delete", "/", rootDir)
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
// File1
file1 := &iteminfo.FileInfo{
Path: "/file1.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "file1.txt",
Type: "file",
Size: 100,
ModTime: now,
},
}
_ = idx.db.InsertItem("test_delete", "/file1.txt", file1)
// File2
file2 := &iteminfo.FileInfo{
Path: "/file2.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "file2.txt",
Type: "file",
Size: 200,
ModTime: now,
},
}
_ = idx.db.InsertItem("test_delete", "/file2.txt", file2)
// Subdir
subdir := &iteminfo.FileInfo{
Path: "/subdir/",
ItemInfo: iteminfo.ItemInfo{
Name: "subdir",
Type: "directory",
Size: 700, // 300 + 400 (deepdir)
Name: "subdir",
Type: "directory",
Size: 700, // 300 + 400 (deepdir)
ModTime: now,
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "file3.txt", Size: 300}},
{ItemInfo: iteminfo.ItemInfo{Name: "file3.txt", Size: 300, ModTime: now}},
},
Folders: []iteminfo.ItemInfo{
{Name: "deepdir", Type: "directory", Size: 400},
},
}
_ = idx.db.InsertItem("test_delete", "/subdir/", subdir)
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
Path: "/subdir/deepdir/",
// File3
file3 := &iteminfo.FileInfo{
Path: "/subdir/file3.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "deepdir",
Type: "directory",
Size: 400,
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "file4.txt", Size: 400}},
Name: "file3.txt",
Type: "file",
Size: 300,
ModTime: now,
},
}
_ = idx.db.InsertItem("test_delete", "/subdir/file3.txt", file3)
idx.DirectoriesLedger["/"] = struct{}{}
idx.DirectoriesLedger["/subdir/"] = struct{}{}
idx.DirectoriesLedger["/subdir/deepdir/"] = struct{}{}
// Deepdir
deepdir := &iteminfo.FileInfo{
Path: "/subdir/deepdir/",
ItemInfo: iteminfo.ItemInfo{
Name: "deepdir",
Type: "directory",
Size: 400,
ModTime: now,
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "file4.txt", Size: 400, ModTime: now}},
},
}
_ = idx.db.InsertItem("test_delete", "/subdir/deepdir/", deepdir)
// File4
file4 := &iteminfo.FileInfo{
Path: "/subdir/deepdir/file4.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "file4.txt",
Type: "file",
Size: 400,
ModTime: now,
},
}
_ = idx.db.InsertItem("test_delete", "/subdir/deepdir/file4.txt", file4)
return idx
}
@@ -84,18 +148,30 @@ func TestDeleteMetadata_RecursiveDirectoryCleanup(t *testing.T) {
// Delete a directory recursively
idx.DeleteMetadata("/subdir/deepdir/", true, true)
// Verify it was removed from Directories
if _, exists := idx.Directories["/subdir/deepdir/"]; exists {
t.Error("Directory '/subdir/deepdir/' should have been removed from Directories")
// Verify it was removed from database
item, err := idx.db.GetItem("test_delete", "/subdir/deepdir/")
if err != nil && err != sql.ErrNoRows {
t.Fatalf("Error checking database: %v", err)
}
if item != nil {
t.Error("Directory '/subdir/deepdir/' should have been removed from database")
}
// Verify it was removed from DirectoriesLedger
if _, exists := idx.DirectoriesLedger["/subdir/deepdir/"]; exists {
t.Error("Directory '/subdir/deepdir/' should have been removed from DirectoriesLedger")
// Verify file4 was also removed
file4, err := idx.db.GetItem("test_delete", "/subdir/deepdir/file4.txt")
if err != nil && err != sql.ErrNoRows {
t.Fatalf("Error checking database: %v", err)
}
if file4 != nil {
t.Error("File '/subdir/deepdir/file4.txt' should have been removed from database")
}
// Verify parent still exists
if _, exists := idx.Directories["/subdir/"]; !exists {
parent, err := idx.db.GetItem("test_delete", "/subdir/")
if err != nil {
t.Fatalf("Error checking database: %v", err)
}
if parent == nil {
t.Error("Parent directory '/subdir/' should still exist")
}
}
@@ -104,33 +180,35 @@ func TestDeleteMetadata_RecursiveWithSubdirectories(t *testing.T) {
idx := setupTestIndexForDeletion(t)
// Add a deeper subdirectory
idx.Directories["/subdir/deepdir/verydeep/"] = &iteminfo.FileInfo{
verydeep := &iteminfo.FileInfo{
Path: "/subdir/deepdir/verydeep/",
ItemInfo: iteminfo.ItemInfo{
Name: "verydeep",
Type: "directory",
Size: 50,
Name: "verydeep",
Type: "directory",
Size: 50,
ModTime: time.Now(),
},
}
idx.DirectoriesLedger["/subdir/deepdir/verydeep/"] = struct{}{}
_ = idx.db.InsertItem("test_delete", "/subdir/deepdir/verydeep/", verydeep)
// Delete the parent recursively
idx.DeleteMetadata("/subdir/deepdir/", true, true)
// Verify all subdirectories were removed
if _, exists := idx.Directories["/subdir/deepdir/"]; exists {
deepdir, err := idx.db.GetItem("test_delete", "/subdir/deepdir/")
if err != nil && err != sql.ErrNoRows {
t.Fatalf("Error checking database: %v", err)
}
if deepdir != nil {
t.Error("Directory '/subdir/deepdir/' should have been removed")
}
if _, exists := idx.Directories["/subdir/deepdir/verydeep/"]; exists {
t.Error("Subdirectory '/subdir/deepdir/verydeep/' should have been removed")
}
// Verify ledger was cleaned
if _, exists := idx.DirectoriesLedger["/subdir/deepdir/"]; exists {
t.Error("Directory '/subdir/deepdir/' should have been removed from ledger")
verydeepItem, err := idx.db.GetItem("test_delete", "/subdir/deepdir/verydeep/")
if err != nil && err != sql.ErrNoRows {
t.Fatalf("Error checking database: %v", err)
}
if _, exists := idx.DirectoriesLedger["/subdir/deepdir/verydeep/"]; exists {
t.Error("Subdirectory '/subdir/deepdir/verydeep/' should have been removed from ledger")
if verydeepItem != nil {
t.Error("Subdirectory '/subdir/deepdir/verydeep/' should have been removed")
}
}
@@ -140,16 +218,22 @@ func TestDeleteMetadata_FileRemoval(t *testing.T) {
// Delete a file
idx.DeleteMetadata("/subdir/file3.txt", false, false)
// Verify file was removed from parent's Files slice
parentInfo, exists := idx.Directories["/subdir/"]
if !exists {
t.Fatal("Parent directory not found")
// Verify file was removed from database
file3, err := idx.db.GetItem("test_delete", "/subdir/file3.txt")
if err != nil && err != sql.ErrNoRows {
t.Fatalf("Error checking database: %v", err)
}
if file3 != nil {
t.Error("File '/subdir/file3.txt' should have been removed from database")
}
for _, file := range parentInfo.Files {
if file.Name == "file3.txt" {
t.Error("File 'file3.txt' should have been removed from parent's Files slice")
}
// Verify parent directory still exists
parent, err := idx.db.GetItem("test_delete", "/subdir/")
if err != nil {
t.Fatalf("Error checking database: %v", err)
}
if parent == nil {
t.Fatal("Parent directory not found")
}
}
@@ -159,21 +243,30 @@ func TestDeleteMetadata_DirectoryFromParentFolder(t *testing.T) {
// Delete a directory (non-recursive)
idx.DeleteMetadata("/subdir/deepdir/", true, false)
// Verify directory was removed from Directories
if _, exists := idx.Directories["/subdir/deepdir/"]; exists {
// Verify directory was removed from database
deepdir, err := idx.db.GetItem("test_delete", "/subdir/deepdir/")
if err != nil && err != sql.ErrNoRows {
t.Fatalf("Error checking database: %v", err)
}
if deepdir != nil {
t.Error("Directory '/subdir/deepdir/' should have been removed")
}
// Verify it was removed from parent's Folders slice
parentInfo, exists := idx.Directories["/subdir/"]
if !exists {
// Verify parent directory still exists
parent, err := idx.db.GetItem("test_delete", "/subdir/")
if err != nil {
t.Fatalf("Error checking database: %v", err)
}
if parent == nil {
t.Fatal("Parent directory not found")
}
for _, folder := range parentInfo.Folders {
if folder.Name == "deepdir" {
t.Error("Folder 'deepdir' should have been removed from parent's Folders slice")
}
// Verify child file still exists (non-recursive delete)
file4, err := idx.db.GetItem("test_delete", "/subdir/deepdir/file4.txt")
if err != nil {
t.Fatalf("Error checking database: %v", err)
}
if file4 == nil {
t.Error("File '/subdir/deepdir/file4.txt' should still exist (non-recursive delete)")
}
}
+325
View File
@@ -0,0 +1,325 @@
package indexing
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestFetchExtendedAttributes tests the fetchExtendedAttributes helper function
func TestFetchExtendedAttributes(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
tests := []struct {
name string
adjustedPath string
combinedPath string
files []os.FileInfo
opts Options
expectHasPreview bool
expectMapNotEmpty bool
}{
{
name: "SkipExtendedAttrs returns empty",
adjustedPath: "/test/",
combinedPath: "/test/",
files: []os.FileInfo{},
opts: Options{
SkipExtendedAttrs: true,
Recursive: false,
},
expectHasPreview: false,
expectMapNotEmpty: false,
},
{
name: "Recursive returns empty",
adjustedPath: "/test/",
combinedPath: "/test/",
files: []os.FileInfo{},
opts: Options{
SkipExtendedAttrs: false,
Recursive: true,
},
expectHasPreview: false,
expectMapNotEmpty: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasPreview, subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.files, tt.opts)
if hasPreview != tt.expectHasPreview {
t.Errorf("expected hasPreview=%v, got %v", tt.expectHasPreview, hasPreview)
}
if (len(subdirMap) > 0) != tt.expectMapNotEmpty {
t.Errorf("expected mapNotEmpty=%v, got %v", tt.expectMapNotEmpty, len(subdirMap) > 0)
}
})
}
}
// TestGetDirectoryName tests the directory name extraction logic
func TestGetDirectoryName(t *testing.T) {
tests := []struct {
name string
indexPath string
idxPath string
expected string
}{
{
name: "Normal directory path",
indexPath: "/documents/",
idxPath: "/home/user",
expected: "documents",
},
{
name: "Root path",
indexPath: "/",
idxPath: "/home/user",
expected: "user",
},
{
name: "Path with trailing slash",
indexPath: "/mydir/",
idxPath: "/base",
expected: "mydir",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Note: The actual getDirectoryName logic is now inline in GetDirInfoCore
// This test verifies the expected behavior using filepath.Base
baseName := filepath.Base(strings.TrimSuffix(tt.indexPath, "/"))
if tt.indexPath == "/" {
baseName = filepath.Base(tt.idxPath)
}
if baseName != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, baseName)
}
})
}
}
// TestProcessDirectoryItem tests the processDirectoryItem helper function
func TestProcessDirectoryItem(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Set up folder size in memory
idx.SetFolderSize("/test/subdir/", 500)
now := time.Now()
mockDir := &mockFileInfo{
name: "subdir",
size: 0,
mode: os.ModeDir,
modTime: now,
isDir: true,
}
mockDirNoSize := &mockFileInfo{
name: "nosize",
size: 0,
mode: os.ModeDir,
modTime: now,
isDir: true,
}
tests := []struct {
name string
file os.FileInfo
combinedPath string
realPath string
fullCombined string
subdirHasPreviewMap map[string]bool
opts Options
expectNil bool
expectSize int64
expectShouldCount bool
}{
{
name: "Non-recursive with cached size",
file: mockDir,
combinedPath: "/test/",
realPath: "/real/test",
fullCombined: "/test/subdir",
subdirHasPreviewMap: map[string]bool{
"/test/subdir/": true,
},
opts: Options{
Recursive: false,
SkipExtendedAttrs: false,
},
expectNil: false,
expectSize: 500,
expectShouldCount: true,
},
{
name: "Non-recursive without cached size",
file: mockDirNoSize,
combinedPath: "/test/",
realPath: "/real/test",
fullCombined: "/test/nosize",
subdirHasPreviewMap: map[string]bool{
"/test/nosize/": false,
},
opts: Options{
Recursive: false,
SkipExtendedAttrs: false,
},
expectNil: false,
expectSize: 0,
expectShouldCount: true,
},
{
name: "SkipExtendedAttrs",
file: mockDir,
combinedPath: "/test/",
realPath: "/real/test",
fullCombined: "/test/subdir",
subdirHasPreviewMap: map[string]bool{
"/test/subdir/": true,
},
opts: Options{
Recursive: false,
SkipExtendedAttrs: true,
},
expectNil: false,
expectSize: 500,
expectShouldCount: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
itemInfo, size, shouldCount := idx.processDirectoryItem(
tt.file, tt.combinedPath,
tt.subdirHasPreviewMap, tt.opts, nil,
)
if tt.expectNil {
if itemInfo != nil {
t.Errorf("expected nil itemInfo, got %v", itemInfo)
}
return
}
if itemInfo == nil {
t.Fatalf("expected non-nil itemInfo")
}
if size != tt.expectSize {
t.Errorf("expected size=%d, got %d", tt.expectSize, size)
}
if shouldCount != tt.expectShouldCount {
t.Errorf("expected shouldCount=%v, got %v", tt.expectShouldCount, shouldCount)
}
if itemInfo.Type != "directory" {
t.Errorf("expected type=directory, got %s", itemInfo.Type)
}
})
}
}
// TestProcessFileItem tests the processFileItem helper function
func TestProcessFileItem(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
now := time.Now()
mockFile := &mockFileInfo{
name: "testfile.txt",
size: 1024,
mode: 0,
modTime: now,
isDir: false,
}
tests := []struct {
name string
file os.FileInfo
realPath string
combinedPath string
fullCombined string
opts Options
scanner *Scanner
expectSize int64
expectShouldCount bool
}{
{
name: "API call (non-recursive, no scanner)",
file: mockFile,
realPath: "/real/test",
combinedPath: "/test/",
fullCombined: "/test/testfile.txt",
opts: Options{
Recursive: false,
SkipExtendedAttrs: false,
},
scanner: nil,
expectSize: 1024,
expectShouldCount: true,
},
{
name: "SkipExtendedAttrs",
file: mockFile,
realPath: "/real/test",
combinedPath: "/test/",
fullCombined: "/test/testfile.txt",
opts: Options{
Recursive: false,
SkipExtendedAttrs: true,
},
scanner: nil,
expectSize: 1024,
expectShouldCount: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
itemInfo, size, shouldCount, bubblesUp := idx.processFileItem(
tt.file, tt.combinedPath, tt.opts, tt.scanner,
)
if itemInfo == nil {
t.Fatalf("expected non-nil itemInfo")
}
if size != tt.expectSize {
t.Errorf("expected size=%d, got %d", tt.expectSize, size)
}
if shouldCount != tt.expectShouldCount {
t.Errorf("expected shouldCount=%v, got %v", tt.expectShouldCount, shouldCount)
}
// bubblesUp is a boolean, just verify it's set
_ = bubblesUp
})
}
}
// mockFileInfo is a simple mock implementation of os.FileInfo for testing
type mockFileInfo struct {
name string
size int64
mode os.FileMode
modTime time.Time
isDir bool
}
func (m *mockFileInfo) Name() string { return m.name }
func (m *mockFileInfo) Size() int64 { return m.size }
func (m *mockFileInfo) Mode() os.FileMode { return m.mode }
func (m *mockFileInfo) ModTime() time.Time { return m.modTime }
func (m *mockFileInfo) IsDir() bool { return m.isDir }
func (m *mockFileInfo) Sys() interface{} { return nil }
File diff suppressed because it is too large. Load diff
+148 -227
View File
@@ -2,8 +2,10 @@ package indexing
import (
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
@@ -11,67 +13,135 @@ import (
func setupTestIndex(t *testing.T) (*Index, string, func()) {
t.Helper()
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, _, err = dbsql.NewIndexDB("test_indexing", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
}
// Initialize index with mock data
idx := &Index{
ReducedIndex: ReducedIndex{},
Source: settings.Source{
Name: "test",
Path: "/mock/path",
Config: settings.SourceConfig{
DisableIndexing: false,
UseLogicalSize: true,
},
},
wasIndexed: true,
mock: true, // Enable mock mode
Directories: make(map[string]*iteminfo.FileInfo),
DirectoriesLedger: make(map[string]struct{}),
FoundHardLinks: make(map[string]uint64),
processedInodes: make(map[uint64]struct{}),
mock: true, // Enable mock mode
db: indexDB,
scanUpdatedPaths: make(map[string]bool),
folderSizes: make(map[string]uint64), // Initialize folder sizes map
folderSizesUnsynced: make(map[string]struct{}), // Initialize unsynced map
}
// Create mock directory structure with predictable sizes
// Using logical file sizes instead of disk allocation sizes
idx.Directories["/"] = &iteminfo.FileInfo{
// Create mock directory structure with predictable sizes using database
now := time.Now()
// Root directory
rootDir := &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
Size: 1000, // Total logical size: 100 + 200 + 300 + 400
Name: "/",
Type: "directory",
Size: 1000, // Total logical size: 100 + 200 + 300 + 400
ModTime: now,
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Size: 100}},
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Size: 200}},
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Size: 100, ModTime: now}},
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Size: 200, ModTime: now}},
},
Folders: []iteminfo.ItemInfo{
{Name: "subdir", Type: "directory", Size: 700}, // 300 + 400
},
}
_ = idx.db.InsertItem("test", "/", rootDir)
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
// File1
file1 := &iteminfo.FileInfo{
Path: "/file1.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "file1.txt",
Type: "file",
Size: 100,
ModTime: now,
},
}
_ = idx.db.InsertItem("test", "/file1.txt", file1)
// File2
file2 := &iteminfo.FileInfo{
Path: "/file2.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "file2.txt",
Type: "file",
Size: 200,
ModTime: now,
},
}
_ = idx.db.InsertItem("test", "/file2.txt", file2)
// Subdir
subdir := &iteminfo.FileInfo{
Path: "/subdir/",
ItemInfo: iteminfo.ItemInfo{
Name: "subdir",
Type: "directory",
Size: 700, // 300 + 400
Name: "subdir",
Type: "directory",
Size: 700, // 300 + 400
ModTime: now,
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "file3.txt", Size: 300}},
{ItemInfo: iteminfo.ItemInfo{Name: "file3.txt", Size: 300, ModTime: now}},
},
Folders: []iteminfo.ItemInfo{
{Name: "deepdir", Type: "directory", Size: 400},
},
}
_ = idx.db.InsertItem("test", "/subdir/", subdir)
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
Path: "/subdir/deepdir/",
// File3
file3 := &iteminfo.FileInfo{
Path: "/subdir/file3.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "deepdir",
Type: "directory",
Size: 400,
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "file4.txt", Size: 400}},
Name: "file3.txt",
Type: "file",
Size: 300,
ModTime: now,
},
}
_ = idx.db.InsertItem("test", "/subdir/file3.txt", file3)
// Deepdir
deepdir := &iteminfo.FileInfo{
Path: "/subdir/deepdir/",
ItemInfo: iteminfo.ItemInfo{
Name: "deepdir",
Type: "directory",
Size: 400,
ModTime: now,
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "file4.txt", Size: 400, ModTime: now}},
},
}
_ = idx.db.InsertItem("test", "/subdir/deepdir/", deepdir)
// File4
file4 := &iteminfo.FileInfo{
Path: "/subdir/deepdir/file4.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "file4.txt",
Type: "file",
Size: 400,
ModTime: now,
},
}
_ = idx.db.InsertItem("test", "/subdir/deepdir/file4.txt", file4)
// Cleanup function (no-op since we're not using filesystem)
cleanup := func() {
@@ -114,7 +184,7 @@ func TestFolderSizeCalculation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Get directory info directly from mock data
dirInfo, exists := idx.GetMetadataInfo(tt.path, true)
dirInfo, exists := idx.GetMetadataInfo(tt.path, true, false)
if !exists {
t.Fatalf("Directory %s not found in mock data", tt.path)
}
@@ -126,102 +196,35 @@ func TestFolderSizeCalculation(t *testing.T) {
}
}
func TestRecursiveSizeUpdate(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Verify initial sizes (using logical sizes, not disk allocation)
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
if rootInfo.Size != 1000 {
t.Errorf("Initial root size: got %d, want 1000", rootInfo.Size)
}
subdirInfo, exists := idx.GetMetadataInfo("/subdir/", true)
if !exists {
t.Fatal("Subdir metadata not found")
}
initialSubdirSize := subdirInfo.Size
if initialSubdirSize != 700 {
t.Errorf("Initial subdir size: got %d, want 700", initialSubdirSize)
}
// Simulate adding a new file to deepdir by updating the mock data
// Add file5.txt (500 bytes) to deepdir
deepdirInfo := idx.Directories["/subdir/deepdir/"]
deepdirInfo.Files = append(deepdirInfo.Files, iteminfo.ExtendedItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "file5.txt",
Size: 500,
},
})
// Update deepdir size
oldDeepdirSize := deepdirInfo.Size
deepdirInfo.Size = 900 // 400 + 500
// Simulate the recursive size update by calling the method directly
idx.RecursiveUpdateDirSizes(deepdirInfo, oldDeepdirSize)
// Check that deepdir size updated
deepdirInfo, exists = idx.GetMetadataInfo("/subdir/deepdir/", true)
if !exists {
t.Fatal("Deepdir metadata not found after update")
}
expectedDeepdirSize := int64(900) // 400 + 500
if deepdirInfo.Size != expectedDeepdirSize {
t.Errorf("Deepdir size after adding file: got %d, want %d", deepdirInfo.Size, expectedDeepdirSize)
}
// Check that subdir size updated (includes the new file)
subdirInfo, exists = idx.GetMetadataInfo("/subdir/", true)
if !exists {
t.Fatal("Subdir metadata not found after propagation")
}
// Subdir should now contain file3.txt + deepdir's new size
expectedSubdirSize := int64(1200) // 300 + 900
if subdirInfo.Size != expectedSubdirSize {
t.Errorf("Subdir size after propagation: got %d, want %d", subdirInfo.Size, expectedSubdirSize)
}
// Check that root size updated (includes changes propagated from subdir)
rootInfo, exists = idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found after propagation")
}
expectedRootSize := int64(1500) // 100 + 200 + 1200
if rootInfo.Size != expectedRootSize {
t.Errorf("Root size after propagation: got %d, want %d", rootInfo.Size, expectedRootSize)
}
}
func TestNonRecursiveMetadataUpdate(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Get initial root size
rootInfo, exists := idx.GetMetadataInfo("/", true)
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
if !exists {
t.Fatal("Root metadata not found")
}
initialRootSize := rootInfo.Size
// Simulate adding a new file directly to root by updating mock data
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
// Simulate adding a new file directly to root by updating the database
file3 := &iteminfo.FileInfo{
Path: "/file3.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "file3.txt",
Size: 150,
Name: "file3.txt",
Type: "file",
Size: 150,
ModTime: time.Now(),
},
})
}
_ = idx.db.InsertItem("test", "/file3.txt", file3)
// Update root size (non-recursive - only direct files)
rootInfo.Size = initialRootSize + 150
_ = idx.db.InsertItem("test", "/", rootInfo)
// Check that root size updated
rootInfo, exists = idx.GetMetadataInfo("/", true)
rootInfo, exists = idx.GetMetadataInfo("/", true, false)
if !exists {
t.Fatal("Root metadata not found after non-recursive update")
}
@@ -232,137 +235,47 @@ func TestNonRecursiveMetadataUpdate(t *testing.T) {
}
}
func TestRecursiveUpdateDirSizes(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Setup initial metadata manually
idx.Directories["/"] = &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Size: 1000,
},
}
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
Path: "/subdir/",
ItemInfo: iteminfo.ItemInfo{
Size: 700,
},
}
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
Path: "/subdir/deepdir/",
ItemInfo: iteminfo.ItemInfo{
Size: 400,
},
}
// Simulate updating deepdir from 400 to 900 (added 500 bytes)
deepdirInfo := idx.Directories["/subdir/deepdir/"]
previousSize := deepdirInfo.Size
deepdirInfo.Size = 900
// Call recursiveUpdateDirSizes
idx.RecursiveUpdateDirSizes(deepdirInfo, previousSize)
// Check that subdir size updated
subdirInfo := idx.Directories["/subdir/"]
expectedSubdirSize := int64(1200) // 700 + 500
if subdirInfo.Size != expectedSubdirSize {
t.Errorf("Subdir size after recursive update: got %d, want %d", subdirInfo.Size, expectedSubdirSize)
}
// Check that root size updated
rootInfo := idx.Directories["/"]
expectedRootSize := int64(1500) // 1000 + 500
if rootInfo.Size != expectedRootSize {
t.Errorf("Root size after recursive update: got %d, want %d", rootInfo.Size, expectedRootSize)
}
}
func TestSizeDecreasePropagate(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Setup initial metadata manually
idx.Directories["/"] = &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Size: 1000,
},
}
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
Path: "/subdir/",
ItemInfo: iteminfo.ItemInfo{
Size: 700,
},
}
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
Path: "/subdir/deepdir/",
ItemInfo: iteminfo.ItemInfo{
Size: 400,
},
}
// Simulate updating deepdir from 400 to 100 (removed 300 bytes)
deepdirInfo := idx.Directories["/subdir/deepdir/"]
previousSize := deepdirInfo.Size
deepdirInfo.Size = 100
// Call recursiveUpdateDirSizes
idx.RecursiveUpdateDirSizes(deepdirInfo, previousSize)
// Check that subdir size decreased
subdirInfo := idx.Directories["/subdir/"]
expectedSubdirSize := int64(400) // 700 - 300
if subdirInfo.Size != expectedSubdirSize {
t.Errorf("Subdir size after decrease: got %d, want %d", subdirInfo.Size, expectedSubdirSize)
}
// Check that root size decreased
rootInfo := idx.Directories["/"]
expectedRootSize := int64(700) // 1000 - 300
if rootInfo.Size != expectedRootSize {
t.Errorf("Root size after decrease: got %d, want %d", rootInfo.Size, expectedRootSize)
}
}
func TestPreviewDoesNotPropagateFromSubdirectories(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Add an image file to subdir in mock data (should have preview)
subdirInfo := idx.Directories["/subdir/"]
subdirInfo.Files = append(subdirInfo.Files, iteminfo.ExtendedItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "image.jpg",
Size: 100,
HasPreview: true, // Image files have preview
},
})
subdirInfo.HasPreview = true // Subdir now has preview
// Check that subdir has preview (due to image.jpg)
subdirInfo, exists := idx.GetMetadataInfo("/subdir/", true)
// Add an image file to subdir in database (should have preview)
subdirInfo, exists := idx.GetMetadataInfo("/subdir/", true, false)
if !exists {
t.Fatal("Subdir metadata not found")
}
if !subdirInfo.HasPreview {
imageFile := &iteminfo.FileInfo{
Path: "/subdir/image.jpg",
ItemInfo: iteminfo.ItemInfo{
Name: "image.jpg",
Type: "file",
Size: 100,
HasPreview: true, // Image files have preview
ModTime: time.Now(),
},
}
_ = idx.db.InsertItem("test", "/subdir/image.jpg", imageFile)
subdirInfo.HasPreview = true // Subdir now has preview
_ = idx.db.InsertItem("test", "/subdir/", subdirInfo)
// Check that subdir has preview (due to image.jpg)
subdirInfoCheck, exists := idx.GetMetadataInfo("/subdir/", true, false)
if !exists {
t.Fatal("Subdir metadata not found")
}
if !subdirInfoCheck.HasPreview {
t.Error("Subdir should have HasPreview=true due to image file")
}
// Check that root does NOT have preview propagated from subdir
rootInfo, exists := idx.GetMetadataInfo("/", true)
rootInfoCheck, exists := idx.GetMetadataInfo("/", true, false)
if !exists {
t.Fatal("Root metadata not found")
}
// Root should only have HasPreview if it has direct previewable files, not from subdirectories
// Since root only has text files, it should NOT have preview
if rootInfo.HasPreview {
if rootInfoCheck.HasPreview {
t.Error("Root should NOT have HasPreview=true from subdirectory preview")
}
}
@@ -371,24 +284,32 @@ func TestPreviewPropagatesFromFiles(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Add an image file to root in mock data (should propagate preview to root)
rootInfo := idx.Directories["/"]
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
// Add an image file to root in database (should propagate preview to root)
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
if !exists {
t.Fatal("Root metadata not found")
}
imageFile := &iteminfo.FileInfo{
Path: "/image.png",
ItemInfo: iteminfo.ItemInfo{
Name: "image.png",
Type: "file",
Size: 100,
HasPreview: true, // Image files have preview
ModTime: time.Now(),
},
})
}
_ = idx.db.InsertItem("test", "/image.png", imageFile)
rootInfo.HasPreview = true // Root now has preview
_ = idx.db.InsertItem("test", "/", rootInfo)
// Check that root HAS preview (due to direct image.png file)
rootInfo, exists := idx.GetMetadataInfo("/", true)
rootInfoCheck, exists := idx.GetMetadataInfo("/", true, false)
if !exists {
t.Fatal("Root metadata not found")
}
if !rootInfo.HasPreview {
if !rootInfoCheck.HasPreview {
t.Error("Root should have HasPreview=true due to direct image file")
}
}
+464 -213
View File
@@ -2,99 +2,47 @@ package indexing
import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
// Scanner represents an independent scanner for a specific directory path
// Each scanner has its own schedule and stats, but only one can run at a time (protected by Index.scanMutex)
type Scanner struct {
// Identity
scanPath string // "/" for root scanner, "/Documents/" for child scanners
scanPath string
// Per-scanner scheduling (not shared between scanners)
currentSchedule int
smartModifier time.Duration
complexity uint // 0-10 scale: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex
fullScanCounter int // every 5th scan is a full scan
complexity uint
fullScanCounter int
nextSleepTime time.Duration
filesChanged bool
lastScanned time.Time
quickScanTime int
fullScanTime int
scanStartTime int64
isScanning bool // True when scanner is actively scanning or waiting for mutex
// Per-scanner stats (not shared)
filesChanged bool
lastScanned time.Time
quickScanTime int
fullScanTime int
numDirs uint64 // Local count for this path
numFiles uint64 // Local count for this path
numDirs uint64
numFiles uint64
// Per-scanner state (not shared with other scanners)
processedInodes map[uint64]struct{} // Track inodes to detect hardlinks
foundHardLinks map[string]uint64 // Path -> size for hardlinks found
batchItems []*iteminfo.FileInfo // Accumulates items for bulk insert
// Reference back to parent index
idx *Index
// Control
stopChan chan struct{}
}
// calculateTimeScore returns a 1-10 score based on full scan time
func (s *Scanner) calculateTimeScore() uint {
if s.fullScanTime == 0 {
return 1 // No data yet, assume simple
}
// Time-based thresholds (in seconds)
switch {
case s.fullScanTime < 2:
return 1
case s.fullScanTime < 5:
return 2
case s.fullScanTime < 10:
return 3
case s.fullScanTime < 15:
return 4
case s.fullScanTime < 30:
return 5
case s.fullScanTime < 60:
return 6
case s.fullScanTime < 90:
return 7
case s.fullScanTime < 120:
return 8
case s.fullScanTime < 180:
return 9
default:
return 10
}
}
// calculateDirScore returns a 1-10 score based on directory count
func (s *Scanner) calculateDirScore() uint {
// Directory-based thresholds
switch {
case s.numDirs < 2500:
return 1
case s.numDirs < 5000:
return 2
case s.numDirs < 10000:
return 3
case s.numDirs < 25000:
return 4
case s.numDirs < 50000:
return 5
case s.numDirs < 100000:
return 6
case s.numDirs < 250000:
return 7
case s.numDirs < 500000:
return 8
case s.numDirs < 1000000:
return 9
default:
return 10
}
stopOnce sync.Once // Ensures stopChan is only closed once
}
// start begins the scanner's main loop
func (s *Scanner) start() {
// Do initial scan for all scanners
// Wait a bit to stagger child scanner initial scans (root goes first)
if s.scanPath != "/" {
time.Sleep(500 * time.Millisecond)
@@ -109,16 +57,18 @@ func (s *Scanner) start() {
return
}
// Calculate sleep based on this scanner's schedule
sleepTime := s.calculateSleepTime()
// Use the sleep time calculated by updateSchedule() (based on OLD schedule before increment)
sleepTime := s.nextSleepTime
if sleepTime == 0 {
// Fallback for first run or if not set
sleepTime = s.calculateSleepTime()
}
select {
case <-s.stopChan:
logger.Debugf("Scanner [%s] received stop signal", s.scanPath)
return
case <-time.After(sleepTime):
// Time to scan! But must acquire mutex first
s.tryAcquireAndScan()
}
}
@@ -126,133 +76,283 @@ func (s *Scanner) start() {
// tryAcquireAndScan attempts to acquire the global scan mutex and run a scan
func (s *Scanner) tryAcquireAndScan() {
// Child scanners must wait for root scanner to go first each round
if s.scanPath != "/" {
s.idx.mu.RLock()
lastRootScan := s.idx.lastRootScanTime
myLastScan := s.lastScanned
s.idx.mu.RUnlock()
// If we've scanned more recently than the root, skip this cycle
if !myLastScan.IsZero() && myLastScan.After(lastRootScan) {
return
// Ensure isScanning is cleared even if we panic
defer func() {
if r := recover(); r != nil {
logger.Errorf("[%s] Scanner panic recovered: %v", s.scanPath, r)
}
}
s.idx.mu.Lock()
s.isScanning = false
s.idx.mu.Unlock()
err := s.idx.PostScan()
if err != nil {
logger.Errorf("Failed to post scan: %v", err)
}
}()
s.idx.scanMutex.Lock()
// Mark which scanner is active (for status/logging)
// Set isScanning flag for all scanners
s.idx.mu.Lock()
s.idx.activeScannerPath = s.scanPath
s.isScanning = true
s.idx.mu.Unlock()
// Determine if quick or full scan
// First scan (fullScanCounter=0) is always full
// Scans 1-4 are quick, scan 5 is full, then repeat
// Root scanner can run independently since it's non-recursive
// Child scanners must acquire mutex to prevent concurrent child scans
if s.scanPath != "/" {
s.idx.childScanMutex.Lock()
defer s.idx.childScanMutex.Unlock()
}
quick := s.fullScanCounter > 0 && s.fullScanCounter < 5
s.fullScanCounter++
if s.fullScanCounter >= 5 {
s.fullScanCounter = 0
}
s.runIndexing(quick)
// Update this scanner's schedule based on results
s.updateSchedule()
// If this is the root scanner, update the last root scan time
if s.scanPath == "/" {
s.idx.mu.Lock()
s.idx.lastRootScanTime = time.Now()
s.idx.mu.Unlock()
}
// Clear active scanner
s.idx.mu.Lock()
s.idx.activeScannerPath = ""
s.idx.mu.Unlock()
s.idx.scanMutex.Unlock()
// Aggregate stats to Index level and update status (after releasing active scanner)
s.idx.aggregateStatsFromScanners()
}
// runIndexing performs the actual indexing work
func (s *Scanner) runIndexing(quick bool) {
if s.scanPath == "/" {
// ROOT SCANNER: Non-recursive, just scan root directory itself
s.runRootScan(quick)
} else {
// CHILD SCANNER: Recursive scan of assigned directory
s.runChildScan(quick)
}
}
func (s *Scanner) runRootScan(quick bool) {
startTime := time.Now()
if quick {
s.runQuickScanRoot()
s.quickScanTime = int(time.Since(startTime).Seconds())
} else {
s.runFullScanRoot()
s.fullScanTime = int(time.Since(startTime).Seconds())
s.updateComplexity()
}
s.lastScanned = time.Now()
s.checkForNewChildDirectories()
if err := s.idx.db.ShrinkMemory(); err != nil {
logger.Errorf("Failed to shrink memory: %v", err)
}
}
func (s *Scanner) runFullScanRoot() {
config := Options{
Recursive: false,
IsRoutineScan: true,
}
// Reset counters and initialize state for full scan
s.numDirs = 0
s.numFiles = 0
s.scanStartTime = time.Now().Unix()
s.idx.mu.Lock()
if s.idx.scanSessionStartTime == 0 {
s.idx.scanSessionStartTime = s.scanStartTime
}
s.idx.mu.Unlock()
s.processedInodes = make(map[uint64]struct{})
s.foundHardLinks = make(map[string]uint64)
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
s.filesChanged = false
_, _, err := s.idx.indexDirectory("/", config, s)
if err != nil {
logger.Errorf("Root scanner error: %v", err)
}
s.flushBatch()
s.purgeStaleEntries(true, false) // isRoot=true, isQuickScan=false
s.syncStatsWithDB()
if s.processedInodes != nil {
s.processedInodes = nil
s.foundHardLinks = nil
}
s.idx.mu.Lock()
s.idx.scanUpdatedPaths = make(map[string]bool)
s.idx.mu.Unlock()
}
func (s *Scanner) runQuickScanRoot() {
s.scanStartTime = time.Now().Unix()
s.filesChanged = false
// Get root-level folders from folderSizes map
s.idx.folderSizesMu.RLock()
var foldersToCheck []string
for path := range s.idx.folderSizes {
// Root scanner: only check direct children (one slash after root, like "/subdir/")
if strings.Count(path, "/") == 2 && path != "/" {
foldersToCheck = append(foldersToCheck, path)
}
}
s.idx.folderSizesMu.RUnlock()
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
// Track stats
unchangedCount := 0
changedCount := 0
errorCount := 0
// Check each folder's modtime
for _, folderPath := range foldersToCheck {
changed, err := s.checkFolderModtime(folderPath)
if err != nil {
errorCount++
} else if changed {
changedCount++
} else {
unchangedCount++
}
}
s.flushBatch()
s.purgeStaleEntries(true, true) // isRoot=true, isQuickScan=true
s.syncStatsWithDB()
}
func (s *Scanner) runChildScan(quick bool) {
startTime := time.Now()
if quick {
// Quick scan: iterate folderSizes, check modtimes only (257x faster!)
s.runQuickScanChild()
s.quickScanTime = int(time.Since(startTime).Seconds())
} else {
// Full scan: walk filesystem recursively, read all directory contents
s.runFullScanChild()
s.fullScanTime = int(time.Since(startTime).Seconds())
s.updateComplexity()
}
s.lastScanned = time.Now()
}
// runRootScan scans only the root directory (non-recursive) and checks for new child directories
func (s *Scanner) runRootScan(quick bool) {
config := actionConfig{
Quick: quick,
Recursive: false,
IsRoutineScan: true,
}
// Reset counters for full scan
if !quick {
s.numDirs = 0
s.numFiles = 0
}
s.filesChanged = false
startTime := time.Now()
err := s.idx.indexDirectory("/", config)
if err != nil {
logger.Errorf("Root scanner error: %v", err)
}
scanDuration := int(time.Since(startTime).Seconds())
if quick {
s.quickScanTime = scanDuration
} else {
s.fullScanTime = scanDuration
s.updateComplexity()
}
// Check for new top-level directories and create scanners for them
s.checkForNewChildDirectories()
}
// runChildScan scans a specific directory recursively
func (s *Scanner) runChildScan(quick bool) {
config := actionConfig{
Quick: quick,
func (s *Scanner) runFullScanChild() {
config := Options{
Recursive: true,
IsRoutineScan: true,
}
// Reset counters for full scan
if !quick {
s.numDirs = 0
s.numFiles = 0
// Reset counters and initialize state for full scan
s.numDirs = 0
s.numFiles = 0
s.scanStartTime = time.Now().Unix()
s.idx.mu.Lock()
if s.idx.scanSessionStartTime == 0 {
s.idx.scanSessionStartTime = s.scanStartTime
}
s.idx.mu.Unlock()
s.processedInodes = make(map[uint64]struct{})
s.foundHardLinks = make(map[string]uint64)
s.filesChanged = false
startTime := time.Now()
err := s.idx.indexDirectory(s.scanPath, config)
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
_, _, err := s.idx.indexDirectory(s.scanPath, config, s)
if err != nil {
logger.Errorf("Scanner [%s] error: %v", s.scanPath, err)
} else if config.Recursive && config.IsRoutineScan {
s.idx.mu.Lock()
s.idx.incrementScannerDirsUnlocked()
s.idx.mu.Unlock()
}
scanDuration := int(time.Since(startTime).Seconds())
if quick {
s.quickScanTime = scanDuration
} else {
s.fullScanTime = scanDuration
s.updateComplexity()
s.flushBatch()
s.purgeStaleEntries(false, false) // isRoot=false, isQuickScan=false
s.syncStatsWithDB()
if s.scanPath != "/" {
if err := s.idx.SyncFolderSizesToDB(); err != nil {
logger.Errorf("[%s] Failed to sync folder sizes: %v", s.scanPath, err)
}
}
if s.processedInodes != nil {
s.processedInodes = nil
s.foundHardLinks = nil
}
s.idx.mu.Lock()
for path := range s.idx.scanUpdatedPaths {
if strings.HasPrefix(path, s.scanPath) {
delete(s.idx.scanUpdatedPaths, path)
}
}
s.idx.mu.Unlock()
}
func (s *Scanner) runQuickScanChild() {
s.scanStartTime = time.Now().Unix()
s.filesChanged = false
// Get folders in this scanner's scope from folderSizes map
s.idx.folderSizesMu.RLock()
var foldersToCheck []string
for path := range s.idx.folderSizes {
// Child scanner: check all paths under its scope
if strings.HasPrefix(path, s.scanPath) && path != s.scanPath {
foldersToCheck = append(foldersToCheck, path)
}
}
s.idx.folderSizesMu.RUnlock()
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
// Update the scanner's own root folder to prevent it from being purged
// (child folders are updated via checkFolderModtime, but the scanner's root needs explicit update)
fullScanPath := filepath.Join(s.idx.Path, s.scanPath)
if info, err := os.Stat(fullScanPath); err == nil {
rootFolderInfo := &iteminfo.FileInfo{
Path: s.scanPath,
ItemInfo: iteminfo.ItemInfo{
Name: filepath.Base(strings.TrimSuffix(s.scanPath, "/")),
Size: 0, // Folder size managed in-memory via folderSizes map
ModTime: info.ModTime(),
Type: "directory",
Hidden: false,
HasPreview: false,
},
}
// Add to batch for bulk update
s.batchItems = append(s.batchItems, rootFolderInfo)
}
// Track stats
unchangedCount := 0
changedCount := 0
errorCount := 0
// Check each folder's modtime
for _, folderPath := range foldersToCheck {
changed, err := s.checkFolderModtime(folderPath)
if err != nil {
errorCount++
} else if changed {
changedCount++
} else {
unchangedCount++
}
}
s.flushBatch()
s.purgeStaleEntries(false, true) // isRoot=false, isQuickScan=true
s.syncStatsWithDB()
}
// checkForNewChildDirectories detects new top-level directories and creates scanners for them
@@ -279,11 +379,14 @@ func (s *Scanner) checkForNewChildDirectories() {
}
s.idx.mu.RUnlock()
// Check for deleted directories and stop their scanners
for path, scanner := range existingScanners {
if !currentDirsMap[path] {
logger.Debugf("Directory [%s] no longer exists, stopping scanner", path)
scanner.stop()
// Remove from map immediately to prevent stale scanner references
s.idx.mu.Lock()
delete(s.idx.scanners, path)
s.idx.mu.Unlock()
}
}
@@ -292,7 +395,7 @@ func (s *Scanner) checkForNewChildDirectories() {
_, exists := existingScanners[dirPath]
if !exists && dirPath != "/" {
logger.Debugf("Detected new directory, creating scanner: [%s]", dirPath)
newScanner := s.idx.createChildScanner(dirPath)
newScanner := s.idx.createScanner(dirPath)
s.idx.mu.Lock()
s.idx.scanners[dirPath] = newScanner
@@ -306,9 +409,7 @@ func (s *Scanner) checkForNewChildDirectories() {
// getTopLevelDirs returns a list of top-level directory paths in the root
func (s *Scanner) getTopLevelDirs() []string {
dirs := []string{}
realPath := strings.TrimRight(s.idx.Path, "/") + "/"
dir, err := os.Open(realPath)
dir, err := os.Open(s.idx.Path)
if err != nil {
logger.Errorf("Failed to open root directory: %v", err)
return dirs
@@ -323,36 +424,26 @@ func (s *Scanner) getTopLevelDirs() []string {
for _, file := range files {
baseName := file.Name()
// Skip files - only create scanners for directories
realPath := utils.JoinPathAsUnix(s.idx.Path, baseName)
indexPath := "/" + baseName + "/"
if !file.IsDir() {
// Note: includeRootItems may contain files, but we only create scanners for directories
continue
}
dirPath := "/" + baseName + "/"
// Skip directories in omit list
if omitList[baseName] {
logger.Debugf("Skipping scanner creation for omitted directory: %s", dirPath)
logger.Debugf("Skipping scanner creation for omitted directory: %s", realPath)
continue
}
// Check if we should include this directory (respects includeRootItems filter)
// When includeRootItems is set, ONLY those items (that are directories) get scanners
if !s.idx.shouldInclude(baseName) {
logger.Debugf("Skipping scanner creation for non-included directory: %s", dirPath)
logger.Debugf("Skipping scanner creation for non-included directory: %s", realPath)
continue
}
// Check if this directory should be excluded from indexing (respects exclusion rules)
hidden := isHidden(file, s.idx.Path+dirPath)
if s.idx.shouldSkip(true, hidden, dirPath, baseName, actionConfig{}) {
logger.Debugf("Skipping scanner creation for excluded directory: %s", dirPath)
hidden := IsHidden(realPath)
isSymlink := file.Mode()&os.ModeSymlink != 0
if s.idx.ShouldSkip(true, indexPath, hidden, isSymlink, true) {
logger.Debugf("Skipping scanner creation for excluded directory: %s", realPath)
continue
}
dirs = append(dirs, dirPath)
dirs = append(dirs, indexPath)
}
return dirs
@@ -360,10 +451,7 @@ func (s *Scanner) getTopLevelDirs() []string {
// calculateSleepTime determines how long to wait before the next scan
func (s *Scanner) calculateSleepTime() time.Duration {
// Get base schedule time and apply complexity modifier
sleepTime := scanSchedule[s.currentSchedule] + s.smartModifier
// Allow manual override via config
if s.idx.Config.IndexingInterval > 0 {
sleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
}
@@ -373,12 +461,19 @@ func (s *Scanner) calculateSleepTime() time.Duration {
// updateSchedule adjusts the scanner's schedule based on whether files changed
func (s *Scanner) updateSchedule() {
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
if s.idx.Config.IndexingInterval > 0 {
s.nextSleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
}
// Adjust schedule based on file changes
if s.filesChanged {
logger.Debugf("Scanner [%s] detected changes, adjusting schedule", s.scanPath)
// Move to at least the full-scan anchor or reduce interval
if s.currentSchedule > fullScanAnchor {
s.currentSchedule = fullScanAnchor
// Move to at least schedule index 3 or reduce interval
if s.currentSchedule > 3 {
s.currentSchedule = 3
} else if s.currentSchedule > 0 {
s.currentSchedule--
}
@@ -401,22 +496,17 @@ func (s *Scanner) updateSchedule() {
} else if s.currentSchedule >= len(scanSchedule) {
s.currentSchedule = len(scanSchedule) - 1
}
// Persist index after schedule update (happens after each scan)
if err := s.idx.Save(); err != nil {
logger.Errorf("Failed to save index after schedule update: %v", err)
}
}
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
// 0: unknown, 1: simple, 2-6: normal, 7-9: complex, 10: highlyComplex
// 0: unknown
func (s *Scanner) updateComplexity() {
// Calculate complexity based on both scan time and directory count
timeScore := s.calculateTimeScore()
dirScore := s.calculateDirScore()
// Use the higher score (more conservative approach)
complexity := timeScore
if dirScore > timeScore {
complexity = dirScore
}
s.complexity = complexity
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
// Set smartModifier based on complexity level
if modifier, ok := complexityModifier[s.complexity]; ok {
@@ -424,6 +514,10 @@ func (s *Scanner) updateComplexity() {
} else {
s.smartModifier = 0
}
// Persist index after complexity update (happens after full scans)
if err := s.idx.Save(); err != nil {
logger.Errorf("Failed to save index after complexity update: %v", err)
}
}
// directoryExists checks if the scanner's directory still exists
@@ -442,12 +536,169 @@ func (s *Scanner) removeSelf() {
delete(s.idx.scanners, s.scanPath)
logger.Infof("Removed scanner [%s] from active scanners", s.scanPath)
// Trigger stats aggregation to update overall index
go s.idx.aggregateStatsFromScanners()
}
// stop gracefully stops the scanner
// Uses sync.Once to ensure the channel is only closed once, preventing panic from "close of closed channel"
func (s *Scanner) stop() {
close(s.stopChan)
s.stopOnce.Do(func() {
close(s.stopChan)
})
}
func (s *Scanner) syncStatsWithDB() {
var dirs, files uint64
var err error
if s.scanPath == "/" {
// Root scanner never counts directories (those are handled by child scanners)
dirs = 0
files, err = s.idx.db.GetDirectFileCount(s.idx.Name, "/")
} else {
// Child scanner: count directories and files recursively
dirs, files, err = s.idx.db.GetRecursiveCount(s.idx.Name, s.scanPath)
}
if err != nil {
logger.Errorf("Failed to get count for %s: %v", s.scanPath, err)
return
}
s.numDirs = dirs
s.numFiles = files
}
// checkFolderModtime checks if a folder's modtime changed since last scan
// Returns (changed, error) where changed=true if modtime is after lastScanned, false otherwise
func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
realPath := strings.TrimRight(s.idx.Path, "/") + folderPath
realPath = strings.TrimSuffix(realPath, "/")
info, err := os.Stat(realPath)
if err != nil {
if os.IsNotExist(err) {
// Folder was deleted - will be caught by purge
return false, nil
}
logger.Errorf("[QUICK_SCAN] Error stating %s: %v", folderPath, err)
return false, err
}
// Compare folder modtime against when the last scan started
// If modtime is after last scan, the folder has been modified
modtimeChanged := info.ModTime().After(s.lastScanned)
if modtimeChanged {
// Modtime changed - read directory contents and update database
s.filesChanged = true
dir, err := os.Open(realPath)
if err != nil {
logger.Errorf("[QUICK_SCAN] Error opening %s: %v", folderPath, err)
return true, err
}
defer dir.Close()
opts := Options{
Recursive: false, // Don't recurse - we're checking each folder individually
IsRoutineScan: true,
}
dirInfo, err := s.idx.GetDirInfo(dir, info, folderPath, opts, s)
if err != nil {
logger.Errorf("[QUICK_SCAN] Error getting dir info for %s: %v", folderPath, err)
return true, err
}
// Update metadata (will be batched)
s.idx.UpdateMetadata(dirInfo, s)
// Track this directory as having been updated (for file purge logic)
s.idx.mu.Lock()
s.idx.scanUpdatedPaths[folderPath] = true
s.idx.mu.Unlock()
return true, nil
}
// Modtime unchanged - update last_updated to prevent folder from being purged
// Note: we DON'T add this folder to scanUpdatedPaths, so Phase 2 won't check its files
fileInfo := &iteminfo.FileInfo{
Path: folderPath,
ItemInfo: iteminfo.ItemInfo{
Name: filepath.Base(strings.TrimSuffix(folderPath, "/")),
Size: 0, // Folder size is managed in-memory via folderSizes map
ModTime: info.ModTime(),
Type: "directory",
Hidden: false,
HasPreview: false,
},
}
// Add to batch for bulk update
s.batchItems = append(s.batchItems, fileInfo)
if len(s.batchItems) >= s.idx.db.BatchSize {
s.flushBatch()
}
return false, nil
}
func (s *Scanner) purgeStaleEntries(isRoot bool, isQuickScan bool) {
if s.scanStartTime == 0 {
return
}
var deletedCount int
var err error
if isQuickScan {
// Quick scan cleanup (two-phase):
// Phase 1: Delete folders that weren't updated (they were deleted from filesystem)
deletedCount, err = s.idx.db.DeleteStaleFolders(s.idx.Name, s.scanPath, s.scanStartTime, isRoot)
if err != nil {
logger.Errorf("[DB_MAINTENANCE] Failed to purge stale folders for %s: %v", s.scanPath, err)
return
}
// Phase 2: Delete files in directories that had modtime changes
// Collect list of updated directories from this scan
var updatedDirs []string
s.idx.mu.RLock()
for path := range s.idx.scanUpdatedPaths {
// Only include paths within this scanner's scope
if s.scanPath == "/" {
// Root scanner: only direct children (no nested paths)
if strings.Count(path, "/") == 2 && strings.HasPrefix(path, "/") {
updatedDirs = append(updatedDirs, path)
}
} else {
// Child scanner: all paths under this scanner's path
if strings.HasPrefix(path, s.scanPath) {
updatedDirs = append(updatedDirs, path)
}
}
}
s.idx.mu.RUnlock()
if len(updatedDirs) > 0 {
deletedCount, err = s.idx.db.DeleteStaleFilesInDirs(s.idx.Name, updatedDirs, s.scanStartTime)
if err != nil {
logger.Errorf("[DB_MAINTENANCE] Failed to purge stale files in updated dirs for %s: %v", s.scanPath, err)
}
}
err = nil // Reset err since we handled errors separately above
} else {
// Full scan cleanup: delete all items not updated in scanner scope
deletedCount, err = s.idx.db.DeleteStaleEntries(s.idx.Name, s.scanPath, s.scanStartTime, isRoot)
}
if err != nil {
logger.Errorf("[DB_MAINTENANCE] Failed to purge stale entries for %s: %v", s.scanPath, err)
return
}
if deletedCount >= 10000 {
logger.Warningf("[DB_MAINTENANCE] high purge count (%d) for %s", deletedCount, s.scanPath)
}
}
+177 -151
View File
@@ -2,29 +2,29 @@ package indexing
import (
"encoding/json"
"strconv"
"time"
indexingdb "github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/events"
"github.com/gtsteffaniak/go-logger/logger"
)
// schedule in minutes
var scanSchedule = map[int]time.Duration{
0: 5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
0: 5 * time.Minute, // 5 minute scan
1: 10 * time.Minute,
2: 20 * time.Minute,
3: 40 * time.Minute, // reset anchor for full scan
3: 40 * time.Minute, // schedule index 3 for file changes
4: 1 * time.Hour,
5: 2 * time.Hour,
6: 3 * time.Hour, // [6]
7: 4 * time.Hour, // [7] 4 hours for quick scan & 20 hours for a full scan
6: 3 * time.Hour,
7: 4 * time.Hour,
8: 8 * time.Hour,
9: 12 * time.Hour,
}
// complexityModifier defines time adjustments based on complexity level (0-10)
// 0: unknown (not yet scanned), 1: simple, 2-6: normal, 7-9: complex, 10: highlyComplex
// Each level gets progressively more aggressive with scan timing adjustments
var complexityModifier = map[uint]time.Duration{
0: 0 * time.Minute, // unknown: no modifier
1: -4 * time.Minute, // simple: scan more frequently
@@ -39,14 +39,79 @@ var complexityModifier = map[uint]time.Duration{
10: 16 * time.Minute, // highlyComplex: scan less frequently
}
var fullScanAnchor = 3 // index of the schedule for a full scan
// calculateTimeScore returns a 1-10 score based on scan time
func calculateTimeScore(scanTime int) uint {
if scanTime == 0 {
return 1 // No data yet, assume simple
}
switch {
case scanTime < 2:
return 1
case scanTime < 5:
return 2
case scanTime < 15:
return 3
case scanTime < 30:
return 4
case scanTime < 60:
return 5
case scanTime < 90:
return 6
case scanTime < 120:
return 7
case scanTime < 180:
return 8
case scanTime < 300:
return 9
default:
return 10
}
}
// Removed: Old single-scanner implementation - replaced by multi-scanner system in indexingScanner.go
// calculateDirScore returns a 1-10 score based on directory count
func calculateDirScore(numDirs uint64) uint {
// Directory-based thresholds
switch {
case numDirs < 2500:
return 1
case numDirs < 5000:
return 2
case numDirs < 10000:
return 3
case numDirs < 25000:
return 4
case numDirs < 50000:
return 5
case numDirs < 100000:
return 6
case numDirs < 250000:
return 7
case numDirs < 500000:
return 8
case numDirs < 1000000:
return 9
default:
return 10
}
}
func calculateComplexity(scanTime int, numDirs uint64) uint {
timeScore := calculateTimeScore(scanTime)
dirScore := calculateDirScore(numDirs)
complexity := timeScore
if dirScore > timeScore {
complexity = dirScore
}
return complexity
}
// markFilesChanged marks that files have changed in the currently active scanner
func (idx *Index) markFilesChanged() {
activePath := idx.getActiveScannerPath()
if activePath == "" {
return
}
idx.mu.RLock()
activePath := idx.activeScannerPath
scanner, exists := idx.scanners[activePath]
idx.mu.RUnlock()
@@ -58,7 +123,12 @@ func (idx *Index) markFilesChanged() {
// incrementScannerDirs increments the directory counter for the active scanner
func (idx *Index) incrementScannerDirs() {
idx.mu.RLock()
activePath := idx.activeScannerPath
// Use unlocked version since we now hold the lock
activePath := idx.getActiveScannerPathUnlocked()
if activePath == "" {
idx.mu.RUnlock()
return
}
scanner, exists := idx.scanners[activePath]
idx.mu.RUnlock()
@@ -67,10 +137,27 @@ func (idx *Index) incrementScannerDirs() {
}
}
// incrementScannerDirsUnlocked increments the directory counter for the active scanner
func (idx *Index) incrementScannerDirsUnlocked() {
activePath := idx.getActiveScannerPathUnlocked()
if activePath == "" {
return
}
scanner, exists := idx.scanners[activePath]
if exists {
scanner.numDirs++
}
}
// incrementScannerFiles increments the file counter for the active scanner
func (idx *Index) incrementScannerFiles() {
idx.mu.RLock()
activePath := idx.activeScannerPath
// Use unlocked version since we now hold the lock
activePath := idx.getActiveScannerPathUnlocked()
if activePath == "" {
idx.mu.RUnlock()
return
}
scanner, exists := idx.scanners[activePath]
idx.mu.RUnlock()
@@ -84,39 +171,37 @@ func (idx *Index) PreScan() error {
}
func (idx *Index) PostScan() error {
idx.mu.Lock()
idx.garbageCollection()
idx.wasIndexed = true
idx.runningScannerCount--
idx.mu.Unlock()
if idx.runningScannerCount == 0 {
// Only do expensive operations when ALL scanners are done
if idx.getRunningScannerCount() == 0 {
// All scanners completed
err := idx.db.ShrinkMemory()
if err != nil {
logger.Errorf("Failed to shrink memory: %v", err)
}
// Persist index and scanner information (SSE event will be sent by Save())
if err := idx.Save(); err != nil {
logger.Errorf("Failed to save index persistence: %v", err)
}
// Clear scan session tracking when all scanners complete
idx.mu.Lock()
idx.scanSessionStartTime = 0
idx.scanUpdatedPaths = make(map[string]bool) // Clear tracking map
idx.mu.Unlock()
return idx.SetStatus(READY)
}
// Scanners still running - skip expensive operations
return nil
}
func (idx *Index) garbageCollection() {
for path := range idx.Directories {
_, ok := idx.DirectoriesLedger[path]
if !ok {
idx.Directories[path] = nil
delete(idx.Directories, path)
idx.NumDeleted++
}
}
// Reset the ledger for the next scan.
idx.DirectoriesLedger = make(map[string]struct{})
}
// Removed: UpdateSchedule - now handled per-scanner in Scanner.updateSchedule()
func (idx *Index) SendSourceUpdateEvent() error {
if idx.mock {
logger.Debug("Skipping source update event for mock index.")
return nil
}
reducedIndex, err := GetIndexInfo(idx.Name)
reducedIndex, err := GetIndexInfo(idx.Name, true)
if err != nil {
logger.Errorf("[%s] Error getting index info: %v", idx.Name, err)
return err
}
sourceAsMap := map[string]ReducedIndex{
@@ -124,27 +209,58 @@ func (idx *Index) SendSourceUpdateEvent() error {
}
message, err := json.Marshal(sourceAsMap)
if err != nil {
logger.Errorf("[%s] Error marshaling source update: %v", idx.Name, err)
return err
}
events.SendSourceUpdate(idx.Name, string(message))
quotedMessage := strconv.Quote(string(message))
events.SendSourceUpdate(idx.Name, quotedMessage)
return nil
}
// Removed: RunIndexing - replaced by multi-scanner system where each scanner handles its own indexing
// setupMultiScanner creates and starts the multi-scanner system
// Creates a root scanner (non-recursive) and child scanners for each top-level directory
func (idx *Index) setupMultiScanner() {
logger.Infof("Setting up multi-scanner system for [%v]", idx.Name)
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
func (idx *Index) setupMultiScanner(isNewDb bool) {
// Load persisted index and scanner information
if err := idx.Load(); err != nil {
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
}
// Load existing folder sizes from database to avoid marking everything as dirty on startup
existingSizes, err := idx.db.LoadFolderSizes(idx.Name)
if err != nil {
logger.Errorf("[INIT] Failed to load existing folder sizes for [%s]: %v", idx.Name, err)
} else {
idx.folderSizesMu.Lock()
idx.folderSizes = existingSizes
idx.folderSizesMu.Unlock()
}
idx.mu.Lock()
idx.scanners = make(map[string]*Scanner)
idx.initialScanStartTime = time.Now()
idx.hasLoggedInitialScan = false
idx.mu.Unlock()
// Load persisted scanner info if available
var persistedScanners map[string]*indexingdb.PersistedScannerInfo
if indexingStorage != nil && !isNewDb {
info, err := indexingStorage.GetByPath(idx.Path)
if err == nil && info != nil {
persistedScanners = info.Scanners
}
}
// Create and start root scanner
rootScanner := idx.createRootScanner()
rootScanner := idx.createScanner("/")
if persistedScanners != nil {
if rootInfo, ok := persistedScanners["/"]; ok {
rootScanner.complexity = rootInfo.Complexity
rootScanner.currentSchedule = rootInfo.CurrentSchedule
rootScanner.quickScanTime = rootInfo.QuickScanTime
rootScanner.fullScanTime = rootInfo.FullScanTime
rootScanner.numDirs = rootInfo.NumDirs
rootScanner.numFiles = rootInfo.NumFiles
rootScanner.lastScanned = rootInfo.LastScanned
}
}
idx.mu.Lock()
idx.scanners["/"] = rootScanner
idx.mu.Unlock()
@@ -155,7 +271,20 @@ func (idx *Index) setupMultiScanner() {
// Create child scanner for each top-level directory
for _, dirPath := range topLevelDirs {
childScanner := idx.createChildScanner(dirPath)
childScanner := idx.createScanner(dirPath)
// Restore persisted stats for child scanner if available (and DB is not new)
if persistedScanners != nil {
if childInfo, ok := persistedScanners[dirPath]; ok {
childScanner.complexity = childInfo.Complexity
childScanner.currentSchedule = childInfo.CurrentSchedule
childScanner.quickScanTime = childInfo.QuickScanTime
childScanner.fullScanTime = childInfo.FullScanTime
childScanner.numDirs = childInfo.NumDirs
childScanner.numFiles = childInfo.NumFiles
childScanner.lastScanned = childInfo.LastScanned
}
}
idx.mu.Lock()
idx.scanners[dirPath] = childScanner
@@ -167,20 +296,8 @@ func (idx *Index) setupMultiScanner() {
logger.Debugf("Created %d scanners for [%v] (1 root + %d children)", len(topLevelDirs)+1, idx.Name, len(topLevelDirs))
}
// createRootScanner creates a scanner for the root directory (non-recursive)
func (idx *Index) createRootScanner() *Scanner {
return &Scanner{
scanPath: "/",
idx: idx,
stopChan: make(chan struct{}),
currentSchedule: 0,
fullScanCounter: 0,
complexity: 0, // 0 = unknown until first full scan completes
}
}
// createChildScanner creates a scanner for a specific child directory (recursive)
func (idx *Index) createChildScanner(dirPath string) *Scanner {
func (idx *Index) createScanner(dirPath string) *Scanner {
return &Scanner{
scanPath: dirPath,
idx: idx,
@@ -191,97 +308,6 @@ func (idx *Index) createChildScanner(dirPath string) *Scanner {
}
}
// Legacy function kept for backwards compatibility - now deprecated
func (idx *Index) setupIndexingScanners() {
// Use new multi-scanner system
idx.setupMultiScanner()
}
// aggregateStatsFromScanners aggregates stats from all scanners to Index-level stats
func (idx *Index) aggregateStatsFromScanners() {
idx.mu.Lock()
defer idx.mu.Unlock()
if len(idx.scanners) == 0 {
return
}
// Aggregate stats from all scanners
var totalDirs uint64 = 0
var totalFiles uint64 = 0
var totalQuickScanTime = 0
var totalFullScanTime = 0
var mostRecentScan time.Time
var maxComplexity uint = 0 // Start at 0 (unknown)
allScannedAtLeastOnce := true
for _, scanner := range idx.scanners {
totalDirs += scanner.numDirs
totalFiles += scanner.numFiles
totalQuickScanTime += scanner.quickScanTime
totalFullScanTime += scanner.fullScanTime
// Track most recent scan
if scanner.lastScanned.After(mostRecentScan) {
mostRecentScan = scanner.lastScanned
}
// Check if all scanners have scanned at least once
if scanner.lastScanned.IsZero() {
allScannedAtLeastOnce = false
}
if !allScannedAtLeastOnce {
continue
}
// Track highest complexity (most conservative assessment)
// Only consider scanners that have been assessed (complexity > 0)
if scanner.complexity > 0 && scanner.complexity > maxComplexity {
maxComplexity = scanner.complexity
}
}
if allScannedAtLeastOnce && idx.FullScanTime == 0 {
maxComplexity = 1 // assess as simple because it took 0 seconds total
}
// Update Index-level stats
idx.NumDirs = totalDirs
idx.NumFiles = totalFiles
idx.QuickScanTime = totalQuickScanTime
idx.FullScanTime = totalFullScanTime
idx.Complexity = maxComplexity
// Update last indexed time
if !mostRecentScan.IsZero() {
idx.LastIndexed = mostRecentScan
idx.LastIndexedUnix = mostRecentScan.Unix()
idx.wasIndexed = true
}
// Log first complete scan round (once)
if allScannedAtLeastOnce && !idx.hasLoggedInitialScan {
totalDuration := time.Since(idx.initialScanStartTime)
truncatedToSecond := totalDuration.Truncate(time.Second)
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Name, truncatedToSecond)
idx.hasLoggedInitialScan = true
}
// Update status: if all scanners have completed at least one scan, mark as READY
if allScannedAtLeastOnce && idx.activeScannerPath == "" {
idx.Status = READY
// Send update event to notify clients
idx.mu.Unlock()
err := idx.SendSourceUpdateEvent()
idx.mu.Lock()
if err != nil {
logger.Errorf("Error sending source update event: %v", err)
}
} else if idx.activeScannerPath != "" {
idx.Status = INDEXING
}
}
// GetScannerStatus returns detailed information about all active scanners
func (idx *Index) GetScannerStatus() map[string]interface{} {
idx.mu.RLock()
@@ -289,9 +315,9 @@ func (idx *Index) GetScannerStatus() map[string]interface{} {
status := make(map[string]interface{})
// Current active scanner (if any is running)
status["activeScanner"] = idx.activeScannerPath
status["isScanning"] = idx.activeScannerPath != ""
activePath := idx.getActiveScannerPathUnlocked()
status["activeScanner"] = activePath
status["isScanning"] = activePath != ""
// Individual scanner stats
scannerStats := make([]map[string]interface{}, 0, len(idx.scanners))
+24 -16
View File
@@ -6,14 +6,14 @@ import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
)
func BenchmarkFillIndex(b *testing.B) {
Initialize(&settings.Source{
Name: "test",
Path: "/srv",
}, true)
}, true, false) // mock=true, isNewDb=false
idx := GetIndex("test")
if idx == nil {
b.Fatal("index is nil")
@@ -55,6 +55,15 @@ func TestGetIndex(t *testing.T) {
// TestMultiScannerMutex verifies that only one scanner can run at a time
func TestMultiScannerMutex(t *testing.T) {
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, _, err = dbsql.NewIndexDB("test_multiscanner", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
}
// Create a mock index with multi-scanner support
idx := &Index{
Source: settings.Source{
@@ -64,18 +73,15 @@ func TestMultiScannerMutex(t *testing.T) {
DisableIndexing: false,
},
},
mock: true,
Directories: make(map[string]*iteminfo.FileInfo),
DirectoriesLedger: make(map[string]struct{}),
processedInodes: make(map[uint64]struct{}),
FoundHardLinks: make(map[string]uint64),
scanners: make(map[string]*Scanner),
mock: true,
db: indexDB,
scanners: make(map[string]*Scanner),
}
// Create multiple scanners
scanner1 := idx.createRootScanner()
scanner2 := idx.createChildScanner("/test1/")
scanner3 := idx.createChildScanner("/test2/")
scanner1 := idx.createScanner("/")
scanner2 := idx.createScanner("/test1/")
scanner3 := idx.createScanner("/test2/")
idx.scanners["/"] = scanner1
idx.scanners["/test1/"] = scanner2
@@ -107,7 +113,9 @@ func TestMultiScannerMutex(t *testing.T) {
}
// Verify scanner counters can be updated
idx.activeScannerPath = "/test1/"
// Test with active scanner - mark scanner2 as scanning
scanner2.isScanning = true
idx.scanners = map[string]*Scanner{"/test1/": scanner2}
idx.incrementScannerDirs()
idx.incrementScannerFiles()
@@ -149,7 +157,7 @@ func TestMakeIndexPath(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "/srv"}, mock: true}
result := idx.MakeIndexPath(tt.subPath)
result := idx.MakeIndexPath(tt.subPath, true) // All test paths are directories
if result != tt.expected {
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
@@ -170,7 +178,7 @@ func TestMakeIndexPath(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "C:\\Users"}, mock: true}
result := idx.MakeIndexPath(tt.subPath)
result := idx.MakeIndexPath(tt.subPath, true) // All test paths are directories
if result != tt.expected {
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
@@ -191,7 +199,7 @@ func TestMakeIndexPathRoot(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "/rootpath", Name: "default"}, mock: true}
result := idx.MakeIndexPath(tt.subPath)
result := idx.MakeIndexPath(tt.subPath, true) // Root path is a directory
if result != tt.expected {
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
@@ -242,7 +250,7 @@ func TestCheckIndexExclude(t *testing.T) {
},
},
}
result := idx.shouldSkip(tc.isDir, tc.isHidden, tc.fullPath, tc.baseName, nil)
result := idx.ShouldSkip(tc.isDir, tc.fullPath, tc.isHidden, false, false)
if result != tc.expect {
t.Errorf("shouldSkip(%v, %v, %q, %q) = %v; want %v", tc.isDir, tc.isHidden, tc.fullPath, tc.baseName, result, tc.expect)
}
+9
View File
@@ -9,6 +9,10 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
const (
LargeFileSizeThreshold = 75 * 1024 * 1024 // 75MB
)
var AllFiletypeOptions = []string{
"image",
"audio",
@@ -56,6 +60,11 @@ var ResizableImageTypes = map[string]bool{
".tif": true,
".heic": true,
".heif": true,
".webp": true,
".pbm": true,
".pgm": true,
".ppm": true,
".pam": true,
".other": false,
}
+2 -1
View File
@@ -40,7 +40,7 @@ type MediaMetadata struct {
Genre string `json:"genre,omitempty"` // music/video genre
Track int `json:"track,omitempty"` // track number
Duration int `json:"duration,omitempty"` // duration in seconds
AlbumArt string `json:"albumArt,omitempty"` // base64 encoded album art / video thumbnail
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
}
// for efficiency, a response will be a pointer to the data
@@ -55,5 +55,6 @@ type ExtendedFileInfo struct {
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
}
+1 -1
View File
@@ -30,7 +30,7 @@ func (idx *Index) CreateMockData(numDirs, numFilesPerDir int) {
Files: files,
}
idx.UpdateMetadata(dirInfo)
idx.UpdateMetadata(dirInfo, nil) // nil scanner for mock
}
}
Loaded 100 of 283 files, more files were not shown because too many files have changed in this diff. Show more