mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 13:12:00 -05:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09713b32a5 | ||
|
|
52e3c5c142 | ||
|
|
2af75311bd | ||
|
|
82c0182c22 | ||
|
|
c8f0b5abea | ||
|
|
d3d029d1e9 | ||
|
|
f39002f375 | ||
|
|
a658f100c0 | ||
|
|
c2d255a25e | ||
|
|
2eb352c2d3 | ||
|
|
43bb4a36b4 | ||
|
|
c67abefc7b | ||
|
|
2ecb9036aa | ||
|
|
dbcfba993b | ||
|
|
ed22ce9421 | ||
|
|
8caa6d6a60 | ||
|
|
fb1af87048 | ||
|
+4 |
f73ca51f94 | ||
|
+4 |
ad839f92ac | ||
|
|
eb47df7172 | ||
|
+4 |
f2f5d776ca | ||
|
|
4f73b4cc6b | ||
|
|
5d7d94d86a | ||
|
|
9723799e4f | ||
|
|
3e5f0312f3 | ||
|
|
131c3a3686 | ||
|
|
7e5a579dde | ||
|
|
cb2d4dc518 | ||
|
|
8587ffc092 | ||
|
|
de6fefad4e | ||
|
|
9d18a369c7 | ||
|
|
9b599347f7 | ||
|
|
37596b1e23 | ||
|
|
d35fb56587 | ||
|
|
5dab4c7b30 | ||
|
|
a33dbfc464 | ||
|
|
18738b3273 | ||
|
|
1d8c74dbd9 | ||
|
|
0ab22e3a89 |
No files matched your search
@@ -7,6 +7,7 @@ on:
|
||||
- "dev/v*.*.*"
|
||||
- "beta/v*.*.*"
|
||||
- "stable/v*.*.*"
|
||||
- "staging/v*.*.*"
|
||||
jobs:
|
||||
prime_docker_cache:
|
||||
name: Prime docker cache
|
||||
|
||||
@@ -86,25 +86,3 @@ jobs:
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
|
||||
|
||||
# - name: Delete beta branch after successful merge
|
||||
# run: |
|
||||
# VERSION="${{ github.event.inputs.version }}"
|
||||
# BETA_BRANCH="beta/v$VERSION"
|
||||
#
|
||||
# # Delete beta branch locally and remotely
|
||||
# git branch -d "$BETA_BRANCH"
|
||||
# git push origin --delete "$BETA_BRANCH"
|
||||
#
|
||||
# echo "✅ Successfully deleted '$BETA_BRANCH'."
|
||||
|
||||
- name: Create Pull Request to Main
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
commit-message: "Promote stable/v${{ github.event.inputs.version }} to main"
|
||||
title: "Promote stable/v${{ github.event.inputs.version }} to main"
|
||||
body: "This PR promotes stable/v${{ github.event.inputs.version }} to the main branch."
|
||||
base: main
|
||||
branch: stable/v${{ github.event.inputs.version }}
|
||||
delete-branch: false
|
||||
@@ -86,14 +86,3 @@ jobs:
|
||||
git push origin "$BETA_BRANCH"
|
||||
|
||||
echo "✅ Successfully merged '$DEV_BRANCH' into '$BETA_BRANCH'."
|
||||
|
||||
# - name: Delete dev branch after successful merge
|
||||
# run: |
|
||||
# VERSION="${{ github.event.inputs.version }}"
|
||||
# DEV_BRANCH="dev/v$VERSION"
|
||||
#
|
||||
# # Delete dev branch locally and remotely
|
||||
# git branch -d "$DEV_BRANCH"
|
||||
# git push origin --delete "$DEV_BRANCH"
|
||||
#
|
||||
# echo "✅ Successfully deleted '$DEV_BRANCH'."
|
||||
@@ -59,8 +59,8 @@ jobs:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false # change this to false when stable gets released
|
||||
make_latest: true # change this to false when stable gets released
|
||||
prerelease: true
|
||||
make_latest: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
@@ -1,62 +0,0 @@
|
||||
name: dev release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "dev/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
push_release_to_registry:
|
||||
name: Push release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{github.actor}}
|
||||
password: ${{ secrets.PAT }}
|
||||
- name: Extract metadata (tags, labels) for Docker and GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
gtstef/filebrowser
|
||||
ghcr.io/gtsteffaniak/filebrowser
|
||||
- name: Modify version names
|
||||
id: modify-json
|
||||
run: |
|
||||
TAGS="${{ steps.meta.outputs.tags }}"
|
||||
# Apply modifications to both docker.io and ghcr.io tags
|
||||
CLEANED_TAGS=$(echo "$TAGS" | sed 's/filebrowser:dev-v/filebrowser:/g' | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev/g')
|
||||
# Add dev tag to both registries
|
||||
CLEANED_TAGS="$CLEANED_TAGS,gtstef/filebrowser:dev,ghcr.io/gtsteffaniak/filebrowser:dev"
|
||||
echo "cleaned_tag<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CLEANED_TAGS" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
platforms: linux/amd64
|
||||
file: ./_docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright
|
||||
name: Test Playwright - regular
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -60,6 +60,7 @@ jobs:
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
@@ -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"
|
||||
|
||||
+5
-1
@@ -20,7 +20,6 @@ frontend/tsconfig.tsbuildinfo
|
||||
/frontend/screenshots
|
||||
/frontend/tests/playwright-files/demo*
|
||||
/frontend/loginAuth.json
|
||||
/frontend/package-lock.json
|
||||
/backend/vendor
|
||||
/backend/*.cov
|
||||
/backend/*test_config.yaml
|
||||
@@ -28,6 +27,7 @@ frontend/tsconfig.tsbuildinfo
|
||||
/backend/srv
|
||||
/backend/http/dist
|
||||
/backend/http/embed
|
||||
/backend/debug
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
@@ -53,3 +53,7 @@ bin/
|
||||
build/
|
||||
*__debug*
|
||||
backend/custom.css
|
||||
|
||||
# Runtime cache directories created by tests/application
|
||||
backend/preview/heic/
|
||||
backend/preview/thumbnails/
|
||||
+379
@@ -2,6 +2,385 @@
|
||||
|
||||
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.2-stable
|
||||
|
||||
**Security**:
|
||||
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse)
|
||||
- Patched Incomplete Remediation of CVE-2026-27611: Password-Protected Share Bypass via /public/api/share/info GHSA-525j-95gf-766f
|
||||
|
||||
**New Features**:
|
||||
- "Divider" option in sidebar links to add a text or divider between links (#1875)
|
||||
- shares offer a "go to source Location" sidebar link and button when editing a share.
|
||||
|
||||
**Notes**:
|
||||
- Share edit/delete permissions are scoped to the user's shares rather than global (#2050)
|
||||
- OIDC group claims accepted as map (#2084)
|
||||
|
||||
**BugFixes**:
|
||||
- fixed the requirement that the database path needed to be set in the config file, now it loads `FILEBROWSER_DATABASE` value by default, fallback to config file property.
|
||||
- Error downloading zipped directory: no such file or directory (users with scope) #2015
|
||||
|
||||
## v1.2.1-stable
|
||||
|
||||
**BugFixes**:
|
||||
- startup icon generation order issue (#2019)
|
||||
- login button on shares not showing (#2028)
|
||||
- removed file watcher link in context menu for shares (#2025)
|
||||
- Direct download link for single-file shares generates incorrect URL (#2026)
|
||||
- Deleting files causes error V1.2.0 #2039
|
||||
|
||||
## v1.2.7-beta
|
||||
|
||||
**BugFixes**:
|
||||
- head > title > infinitely duplicating titles (#2016)
|
||||
- Proxy auth not working with Nginx, stuck at logo (#2013)
|
||||
|
||||
## v1.2.6-beta
|
||||
|
||||
**Security**:
|
||||
- resolves https://github.com/gtsteffaniak/filebrowser/security/advisories/GHSA-8vrh-3pm2-v4v6
|
||||
|
||||
## v1.2.5-beta
|
||||
|
||||
**Notes**:
|
||||
- Dependency Updates for frontend and backend packages
|
||||
- Upgrade to go 1.26
|
||||
- file list prompt interaction behavior, single-click always selects, double-click navigates. (#1911)
|
||||
|
||||
**BugFixes**:
|
||||
- .ssa and .ass subtitle files get their styling removed during conversion (#1933)
|
||||
- custom icons issue
|
||||
- new database message for noauth (#1935)
|
||||
|
||||
## v1.2.4-beta
|
||||
|
||||
**Notes**:
|
||||
- Changed `/api/media/subtitles` api endpoint to better support subtitles.
|
||||
- Correcting some errors in French language (#1947)
|
||||
- add Dutch `nl.json` for frontend i18n -- thanks @Stephan-P (#1957)
|
||||
- share banner and icon images automatically serve a scaled down 1024x1024
|
||||
|
||||
**BugFixes**:
|
||||
- Can't access directories with space (and possibly other special characters) when browsing a public link share (#1956)
|
||||
- External ASS subtitles are not usable in web preview (#1933)
|
||||
- Filename blocking rename/move (#1950)
|
||||
- enforcedOtp: true blocks all user settings updates with HTTP 400 (#1962)
|
||||
- Share favicon URL not showing up and Share description causing endless loading (#1911)
|
||||
- folder sizes not updated for changes to filesystem outside of filebrowser (#1974)
|
||||
|
||||
## v1.2.3-beta
|
||||
|
||||
**Notes**:
|
||||
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
|
||||
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
|
||||
|
||||
**BugFixes**:
|
||||
- fix stuck motion preview popup window issue
|
||||
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
|
||||
- share delete with user scope issue
|
||||
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
|
||||
- delete prompt icons show image previews
|
||||
|
||||
## v1.2.2-beta
|
||||
|
||||
**New Features**:
|
||||
- Resizable sidebar (#1896)
|
||||
- OIDC Authentication: Change Button Text via `frontend.oidcLoginButtonText` (#1708)
|
||||
- improved favicon processing (#1899)
|
||||
- supports more formats
|
||||
- supports larger images
|
||||
- automatcially generates multiple favicon sizes on startup for non-svg images. Custom svg favicons need a companion *.png to exist broad compatibilty
|
||||
- Enhanced media playback: Ability to control the queue from your device's lock screen and notification panel - Will also show
|
||||
metadata of the current playing media if available (#1917)
|
||||
|
||||
**Notes**:
|
||||
- Better text file content detection (#1726)
|
||||
- More url encoding changes for API which should make things more consistent. Open issues if you see path/source not found errors.
|
||||
- adjustments to the startup behavior for sqlite index for reusing the previous database on startup
|
||||
- CTRL + B disables sticky sidebar forever (#1869)
|
||||
- added context menu back to duplicate finder
|
||||
- Sharing a link for uploads - folder/file access and UX polishing (#1902)
|
||||
- improved listing view and scrolling performance
|
||||
- improved image viewer which will utilize recent thumbnails as a placeholder when loading the full image.
|
||||
- Small reorganization of "Share" settings to make the popup clear (#1826)
|
||||
|
||||
**BugFixes**:
|
||||
- Added docker default `FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"` back -- mistakenly removed. see https://filebrowserquantum.com/en/docs/getting-started/config/#3-default-locations. (#1891)
|
||||
- Fix brand text in login screen (#1898)
|
||||
- Sidebar links cointains urls with 2x repeated source name (#1847)
|
||||
- 2 factor auth getting overwritten if you edit the auth from admin (#1819)
|
||||
- New created user's files are listing error, Probably related with language setting. (#1565)
|
||||
- thumbnail generation cpu/memory and concurrency bug.
|
||||
- added 75MB filesize limit for image previews
|
||||
- optimized concurrency for large vs small images
|
||||
|
||||
|
||||
## v1.2.1-beta
|
||||
|
||||
**New Features**:
|
||||
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
|
||||
|
||||
**Notes**:
|
||||
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850)
|
||||
- updated download api to use repeated `file` query param for path instead of `files` with comma. See swagger for details (#1881)
|
||||
|
||||
**BugFixes**:
|
||||
- OnlyOffice stopped working after 1.2.0 upgrade due to download api changes
|
||||
- Search result link to wrong path when search context other than / (#1863)
|
||||
- File actions doesn't work if using user scopes (#1879)
|
||||
- Drop items into folders doesn't work (#1860)
|
||||
- duplicate finder context menu (#1862)
|
||||
- File list in previews doesn't show the parent items (#1861)
|
||||
|
||||
## v1.2.0-beta
|
||||
|
||||
This is a major version update with many code changes which could cause unexpected behavior. Upgrades should proceed with caution and report any undesriable behavior by opening an issue on github. The index is now fully in sqlite database - see [Indexing Overview](https://filebrowserquantum.com/en/docs/features/indexing/) for more information.
|
||||
|
||||
Sorry for the delay -- a lot of effort went into this release, specifically about fine-tuning sqlite indexing. It took me a long time to iron out the behavior, but it should be mostly there now. Enjoy and report any issues as always :)
|
||||
|
||||
**New Features**:
|
||||
- SQLite-based indexing
|
||||
- reduced memory usage, higher CPU and IO usage.
|
||||
- index persistence between restarts (requires [persistent cacheDir](https://filebrowserquantum.com/en/docs/configuration/server/#cachedir))
|
||||
- new tool - "realtime file watcher" low latency if user has realtime permissions (#917)
|
||||
- Access control works with individual files too
|
||||
- conditionally hide symbolic links as indexing rule config (#1540)
|
||||
- External applications can connect to filebrowser over webDAV (#209) (#1764)
|
||||
- make breadcrumbs act as a drop area (#1785)
|
||||
- search multiple sources at once (#848)
|
||||
- file uploads resume from bad internet connection #1599
|
||||
- chunked downloads - fix for 524 errors on cloudflare #1502
|
||||
- made size calculation consistent: defaults to "size on disk" style to mimic "du -sh", and allows config `source.config.useLogicalSize: true` for 0 size folders and actual size file sizes. (#1266)
|
||||
- Feature: allow deleting selected duplicates from duplicate finder (#1659)
|
||||
- share changes:
|
||||
- opengraph support for shared links
|
||||
- Allow adding "share settings" option to customizable sidebar links (#1825)
|
||||
- file picker for share favicon and banner icon
|
||||
- Allow disabling "Login button" for shares (#1673)
|
||||
|
||||
**Notes**:
|
||||
- `server.cacheDirCleanup` defaults to `false` instead of `true`. For docker, you would still need to mount a cacheDir volume to persist cache between restarts.
|
||||
- indexing rules have been streamlined, see [wiki]. The previous style is deprecated but still functional.
|
||||
- improved listing load times for directories with metadata -- a two-pass approach. First a fast load to get the listing items, then a second api request to include metadata.
|
||||
- Enhanced delete prompt
|
||||
- bulkDelete API added and replaces the delete API for all UI actions. See swagger docs for usage.
|
||||
- raw download and patch resource api simplified to a single source per request. See swagger docs for usage.
|
||||
- UserScope function re-organization, if you notice any user scope issues, please open a GitHub issue.
|
||||
- file upload resume from bad internet connection #1599
|
||||
|
||||
**BugFixes**:
|
||||
- Source info not properly read from external storage NAS (#1727)
|
||||
- ensure user requests scopes are strongly enforced (#1789)
|
||||
|
||||
## New Contributors
|
||||
* @reddec made their first contribution in https://github.com/gtsteffaniak/filebrowser/pull/1764
|
||||
* @maximeborges made their first contribution in https://github.com/gtsteffaniak/filebrowser/pull/1849
|
||||
|
||||
**Full Changelog**: https://github.com/gtsteffaniak/filebrowser/compare/v1.1.6-beta...v1.2.0-beta
|
||||
|
||||
## v1.1.6-beta
|
||||
|
||||
**Notes**:
|
||||
- "showHidden" is now a backend attribute. Shares will NOT show hidden files by default unless configured to.
|
||||
- improved listing view UI performance, especially for folders with many items. (#1773)
|
||||
|
||||
**BugFixes**:
|
||||
- SortBy config is not respected when navigating next/previous (#1786)
|
||||
- unauthenticated users unable to preview files (#1792)
|
||||
- File permission not correctly applied( #1790)
|
||||
- API Key loses "download" permission after creation despite being enabled (#1799)
|
||||
- never exclude root folder (#1749)
|
||||
- sources with "files" name not showing after 1.1.x update (#1772)
|
||||
- Symbolic links can't be deleted from webui. (#1566)
|
||||
- panic: close of closed channel during indexing (#1771)
|
||||
- Public sharing returns 404 errors for album art (#1792)
|
||||
- Directories with some symbols in their name "fail to open" (#1808)
|
||||
|
||||
## v1.1.5-beta
|
||||
|
||||
**Notes**:
|
||||
- Major git container tag request #1756
|
||||
|
||||
**BugFixes**:
|
||||
- Sharing broken if source `disableIndexing: true` (#1742)
|
||||
- Password protected share permission issues (#1729) (#1606) (#1593)
|
||||
- Sidebar Sliding left (#1737)
|
||||
- file permissions not working (#1762)
|
||||
- handle Close() errors in archive creation (#1745)
|
||||
- Move custom CSS and user-selected theme styles to the end of the body (#1744)
|
||||
|
||||
## v1.1.4-beta
|
||||
|
||||
**New Features**:
|
||||
- toggle to show searching results with thumbnails #1545
|
||||
|
||||
**Notes**:
|
||||
- file list has loading spinner to help prevent double click issues and give better feedback
|
||||
- improved cacheDir startup checks
|
||||
- warning for slow read/write speeds below 50MB/s
|
||||
- warning for low free space below 20GB
|
||||
- warning if read/write latency is slow
|
||||
- fatal for any errors reading or writing to the directory
|
||||
- links to official docs for more info
|
||||
- access rule changes:
|
||||
- denied folders won't show up in parent directory listing view (#1684)
|
||||
- tools will respect access rules
|
||||
- shares will check access rules based on user that created the share
|
||||
|
||||
**BugFixes**:
|
||||
- passwords with special characters not working properly (#1648)
|
||||
- encoded filename navigation issue (#1711)
|
||||
- Fix/Improve some behaviors in nextPrevious (#1707)
|
||||
- Files recognized as folder if they have the same name as previously deleted folders #1697
|
||||
- multi logger support issue #1701
|
||||
|
||||
## v1.1.3-beta
|
||||
|
||||
**Notes**:
|
||||
- continue create user dir scope even if filesystem path creation fails #1509
|
||||
- access control cache is cleared more aggresively to ensure no delay
|
||||
- removed default .ico favicon in favor of .svg
|
||||
|
||||
**BugFixes**:
|
||||
- indexing used size increases over time #1685
|
||||
- download progress is not shown #1687
|
||||
|
||||
## v1.1.2-beta
|
||||
|
||||
**Notes**:
|
||||
- changes to duplicate detector
|
||||
- temp SQLite database each query to reduce memory pressure
|
||||
- max limit is 500 groups total
|
||||
- Downloads from UI requests utf-8 formatted names to support chinese and other characters. Updated swagger docs.
|
||||
- beta releases no longer publish to `latest` docker tag #1675
|
||||
|
||||
**BugFixes**:
|
||||
- better index status updates, fixing delays #1649
|
||||
- fixed long load times for listings with media info due sequential processing of files.
|
||||
- downloaded files always included `utf-8` in filename #1671
|
||||
- custom sidebar links allow external links like `https://google.com`
|
||||
- html title not populated correctly for links #1676
|
||||
|
||||
## v1.1.1-beta
|
||||
|
||||
**Notes**:
|
||||
- [docker] upgraded ffmpeg 8.0 to 8.0.1
|
||||
- stricter access control checks on file downloads.
|
||||
- Wrong translation on Chinese "save" button #1650
|
||||
- Incorrect Spanish Translations in Folder Creation and Rename Actions #1626
|
||||
- duplicate file detector has stricter partial checksum match #1617
|
||||
|
||||
**BugFixes**:
|
||||
- added missing exiftool to docker image for heic conversion orientation support
|
||||
- v1.1.0-beta - Incorrect naming of 1 file in directory-info #1621
|
||||
- disable only office viewing settings not applying
|
||||
- OnlyOffice integration does not work behind proxy authentication #1422
|
||||
- Newly created users "add on" to defined scope of previous user #1628 #1518
|
||||
- disable chown on upload / file saving #1469 #1546
|
||||
- Uploading a file will silently overwrite any existing file with the same name #1564
|
||||
- share file url issue
|
||||
- Fix drag and drop and rows in normal view #1651
|
||||
- Some text based files are not able to edit. #1567
|
||||
|
||||
## v1.1.0-beta
|
||||
|
||||
**New Features**:
|
||||
- Added tools
|
||||
- size analyzer -- quickly show largest files in interactive graphical chart
|
||||
- duplicate finder -- find duplicate files larger than 1MB
|
||||
- material icon picker and previewer
|
||||
- Listing View Style updates
|
||||
- Status bar for listing view #1459
|
||||
- Duration field shows up if media is shown with duration
|
||||
- More dynamic view modes with additional styling changes
|
||||
- Total display modes consolidated into 3 main groups: list, gallery, normal. "icon" and "compact" mode exist as views based on display size setting.
|
||||
- Customizable sidebar links
|
||||
- Logged in users can have their preferences saved and synced
|
||||
- Can rearrange, add custom links, and save your preferences.
|
||||
- Can customize shares with their own custom sidebar links.
|
||||
- Dynamic scopes for OIDC #1414 #1363
|
||||
- Share and Access Rules validation
|
||||
- If a file/path is moved/renamed in UI its rules and shares will always follow.
|
||||
- If a file/path is moved/renamed outside UI, a warning message will show and ability to associate to new path
|
||||
- Cascade delete access in Access Management when deleting a User #1347
|
||||
- Enhanced notifications #1331
|
||||
- Added "toast" notification type support -- most success messages show as toast now.
|
||||
- multiple stacked notifications possible
|
||||
- notification button support, such as click to open folder after moving file.
|
||||
- notification history (not including toasts)
|
||||
- Add swipe and visual timeout to notifications #1600
|
||||
- Choose different bind ip via `server.listen` #1573
|
||||
- Allow disabling clearing cache each startup via `server.cacheDirCleanup: false` in config #1576
|
||||
|
||||
**Notes**:
|
||||
- more efficient user update actions. Successful PUT actions return only 204 if successful.
|
||||
- Do not enter directory on "New Folder" action #1343
|
||||
- Improved indexing performance - individual scanners per directory at root.
|
||||
- Improved search memory efficiency
|
||||
- Improved scrolling performance
|
||||
- Static file handling has been streamlined, could potentially see some changes from this.
|
||||
- No longer automatically publishing any dev builds to docker
|
||||
- deprecated "DefaultLandingPage" option -- now determined by first source link in sidebar.
|
||||
- Improve rename prompt #1608 #1556
|
||||
|
||||
**BugFixes**:
|
||||
- uploading/editing/saving to password protected shares error
|
||||
- updating a share with password protection without a password removed the password.
|
||||
- per-user download counts were not persistant accross restarts
|
||||
- "Update" button in Chinese wraps incorrectely #1542
|
||||
|
||||
## v1.0.1-stable
|
||||
|
||||
**BugFixes**:
|
||||
- UserDefault always applies to newly created users #1518
|
||||
- Updating user's own password sometimes doesn't work
|
||||
- Anonymous Share link with optional password does not allow downloads #1553
|
||||
- Share options should go in the meta tags #1511
|
||||
- Unable to select compression format when downloading multiple files/dir via Share link (v1.0.0) #1557
|
||||
- fixed sidebar clipping on some browsers like safari
|
||||
- Files show as Download is not available even though it is #1537
|
||||
|
||||
## v1.0.1-beta & v1.0.0-stable
|
||||
|
||||
**New Features**:
|
||||
- login icon support added via `frontend.loginIcon` config path variable
|
||||
|
||||
**Notes**:
|
||||
- updated default login icon
|
||||
- stopped publishing rolling `dev` docker tag.
|
||||
- build requirement change -- from node 18 > node 20 with npm 9.0.0+
|
||||
- version is not shown for unauthenticated users #1444
|
||||
- adjusted how static assets are served to better handle icon standards
|
||||
- fixed signup login #1444
|
||||
- update makefile linker flags to properly set version and sha #1474
|
||||
- better windows build support -- `make setup` and `make dev` work as long as git is installed on windows
|
||||
- added better first initilization detection
|
||||
- If filebrowser initialized and does not detect a database, a new warning message in server logs appears.
|
||||
- If theres no database on start, any admin user's first login will see a welcome message in the ui.
|
||||
- For docker, it defaults to ./data/database.db, but will also fallback to ./database.db without any additional configuration.
|
||||
|
||||
**BugFixes**:
|
||||
- Generating multiple HEIC previews in parallel fails #1470
|
||||
- ? in path not always encoded right #1447
|
||||
- fixed some condition that the halloween background doesn't load properly
|
||||
- some comments not showing up on config viewer in settings
|
||||
|
||||
## v1.0.0-beta
|
||||
|
||||
**Notes**:
|
||||
- Enhanced onlyoffice debugger with more wholistic backend logs
|
||||
- Updated info prompt styling
|
||||
- share qr code links to externalUrl for entire share if exists #1329
|
||||
|
||||
**BugFixes**:
|
||||
- cookie host is set using x-Forwarded-Host to better support reverse proxies.
|
||||
- HEIC conversion not working #1460
|
||||
- 'Prevent user changing their password' also prevents admins changing the users password. #1365
|
||||
- OnlyOffice write problem #1397 #1068
|
||||
- Conditionals starting with a . seem to not work. #1455
|
||||
- Last modified in file info is empty #1443
|
||||
- Share links: 'Default View Mode' does not apply to subfolders. #1463
|
||||
|
||||
## v0.8.11-beta
|
||||
|
||||
**New Features**:
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.25+** (see `backend/go.mod`)
|
||||
- **Node.js 18.0.0+** with npm 7.0.0+ (see `frontend/package.json`)
|
||||
- **Node.js 20.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
|
||||
- **Docker** (optional, for containerized development)
|
||||
- **Git**
|
||||
- **Git** especially improtant on windows -- needed for bash support.
|
||||
|
||||
### Optional Tools
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
|
||||
[](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://hub.docker.com/r/gtstef/filebrowser)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
@@ -15,12 +15,9 @@
|
||||
<img width="800" src="https://github.com/user-attachments/assets/162d7a95-33b7-49bd-976c-dd6822c0d22b">
|
||||
</div>
|
||||
|
||||
> [!WARNING]
|
||||
> There is no stable version -- :construction: coming very soon!
|
||||
|
||||
## Pinned
|
||||
|
||||
:loudspeaker: [Stable Release & v1.0.0 Update](https://github.com/gtsteffaniak/filebrowser/discussions/1293)
|
||||
:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
|
||||
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
@@ -38,26 +35,26 @@ Ready to try it out? See [Getting Started Docs](https://filebrowserquantum.com/e
|
||||
|
||||
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
|
||||
|
||||
1. ✅ Multiple sources support
|
||||
1. ✅ Add and configure multiple sources
|
||||
2. ✅ Login support for OIDC, password + 2FA, and proxy.
|
||||
3. ✅ Beautiful, Responsive, and Customizable user interface.
|
||||
4. ✅ Simplified configuration via `config.yaml` config file.
|
||||
4. ✅ Streamlined configuration via `config.yaml` config file.
|
||||
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
|
||||
- Real-time search results as you type.
|
||||
- Real-time monitoring and updates in the UI.
|
||||
- Search supports file and folder sizes, along with various filters.
|
||||
6. ✅ Better listing browsing
|
||||
- More file type previews, such as **office** and **video** file previews
|
||||
- Instantly switches view modes and sort order without reloading data.
|
||||
- Folder sizes are displayed.
|
||||
- Better thumbnail support including **office**, **video**, and **album artwork**
|
||||
- Faster and more responsive views with animations.
|
||||
- Folder sizes are displayed and support for thumbnails
|
||||
- Navigating remembers the last scroll position.
|
||||
7. ✅ Highly configurable and customizable sharing options, incuding:
|
||||
7. ✅ Highly configurable and customizable sharing options
|
||||
- share expiration time
|
||||
- users who can access share (including anonymous)
|
||||
- styling and themes
|
||||
- file viewing, editing, and uploading permissions
|
||||
8. ✅ Directory-level access control that can be scoped to user or group.
|
||||
8. ✅ Developer API support
|
||||
9. ✅ Developer API support
|
||||
- Ability to create long-lived API Tokens.
|
||||
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
|
||||
|
||||
@@ -105,14 +102,14 @@ Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | :construction: | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -149,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.
|
||||
+7
-5
@@ -1,4 +1,4 @@
|
||||
FROM gtstef/ffmpeg:8.0-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
@@ -6,14 +6,13 @@ WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev upx
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
RUN go build -tags mupdf,musl -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
RUN upx filebrowser
|
||||
|
||||
FROM node:lts-slim AS nbuild
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
@@ -27,20 +26,23 @@ 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
|
||||
# copy config to two locations for compatibility
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
RUN [ "ffmpeg", "-version" ]
|
||||
RUN [ "ffprobe", "-version" ]
|
||||
RUN [ "exiftool", "-ver" ]
|
||||
USER root
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
|
||||
@@ -15,5 +15,6 @@ RUN mkdir -p ./http/dist
|
||||
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
|
||||
@@ -14,4 +14,5 @@ RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -14,4 +14,5 @@ RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
#RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -8,6 +8,8 @@ RUN mkdir -p ./http/dist
|
||||
COPY [ "./_docker/src/oidc/backend/", "./"]
|
||||
COPY ["./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
WORKDIR /app/frontend
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN node mock-oidc-server.js & sleep 2 && cd ../backend/ && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -11,4 +11,5 @@ RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
|
||||
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -2,16 +2,21 @@ FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/no-config/", "./" ]
|
||||
COPY [ "./_docker/src/settings/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/no-config/frontend/", "./" ]
|
||||
COPY [ "./_docker/src/settings/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser set -u testuser1,testuser1
|
||||
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
|
||||
@@ -14,4 +14,5 @@ RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -4,25 +4,25 @@ ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add upx
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
RUN go build -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
RUN upx filebrowser
|
||||
|
||||
FROM node:lts-slim AS nbuild
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /app
|
||||
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
|
||||
@@ -30,6 +30,7 @@ USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
|
||||
@@ -2,6 +2,7 @@ frontend-main: &frontend-main
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
customThemes:
|
||||
no-rounded:
|
||||
description: "No rounded corners" # The description of the theme to display in the UI.
|
||||
|
||||
@@ -8,28 +8,40 @@ 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'
|
||||
- fileNames: 'fileNames'
|
||||
- folderName: 'folderName'
|
||||
- fileName: 'fileName'
|
||||
- folderName: '.hiddenDir'
|
||||
- fileName: '.hidden'
|
||||
|
||||
auth:
|
||||
methods:
|
||||
noauth: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
@@ -37,11 +49,11 @@ frontend:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
userDefaults:
|
||||
permissions:
|
||||
realtime: true
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
|
||||
userDefaults:
|
||||
showHidden: true
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
|
||||
@@ -14,7 +14,8 @@ frontend:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
|
||||
@@ -16,7 +16,8 @@ frontend:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
|
||||
@@ -13,10 +13,16 @@ server:
|
||||
config:
|
||||
disableIndexing: true
|
||||
defaultEnabled: true
|
||||
- path: "/tests/playwright-files"
|
||||
name: "access"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
denyByDefault: true
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
customThemes:
|
||||
no-rounded:
|
||||
description: "No rounded corners" # The description of the theme to display in the UI.
|
||||
@@ -24,4 +30,9 @@ frontend:
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
title: "Playwright"
|
||||
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
signup: true
|
||||
@@ -11,14 +11,14 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 10000,
|
||||
timeout: 3000,
|
||||
testDir: "./tests/playwright/settings",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
retries: 1,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
|
||||
@@ -20,4 +20,6 @@ frontend:
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
+3
-3
@@ -2,11 +2,11 @@ root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=testing'\" ."
|
||||
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=version'\" ."
|
||||
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
|
||||
@@ -0,0 +1,21 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/filebrowser.exe --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=version'\" ."
|
||||
bin = "tmp/filebrowser.exe"
|
||||
args_bin = ["-c", "test_config.yaml"]
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", "http\\dist", "http\\embed", "swagger\\docs"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = false
|
||||
poll = true
|
||||
poll_interval = 1000
|
||||
log = "air.log"
|
||||
delay = 1000 # ms
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
@@ -3,3 +3,4 @@ air.log
|
||||
filebrowser
|
||||
generated.yaml
|
||||
test_config.yaml
|
||||
/data
|
||||
@@ -1,49 +0,0 @@
|
||||
FROM gtstef/ffmpeg:8.0-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev upx
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
RUN go build -tags mupdf,musl -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
RUN upx filebrowser
|
||||
|
||||
FROM node:lts-slim AS nbuild
|
||||
WORKDIR /app
|
||||
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
|
||||
COPY ./frontend/ ./
|
||||
RUN npm run build:docker
|
||||
|
||||
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 PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
# copy config to two locations for compatibility
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
RUN [ "ffmpeg", "-version" ]
|
||||
RUN [ "ffprobe", "-version" ]
|
||||
USER root
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:80/health || exit 1
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM node:22-slim
|
||||
WORKDIR /app/frontend
|
||||
RUN npm i @playwright/test oauth2-mock-server @types/node
|
||||
RUN npx playwright install --with-deps firefox
|
||||
@@ -1,19 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/general/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/general/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
RUN ./filebrowser set -u testuser1,testuser1
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -1,17 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/no-config/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/no-config/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -1,17 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/noauth/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
#RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -1,13 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
|
||||
COPY [ "./_docker/src/oidc/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./_docker/src/oidc/backend/", "./"]
|
||||
COPY ["./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
# Run tests
|
||||
WORKDIR /app/frontend
|
||||
RUN node mock-oidc-server.js & sleep 2 && cd ../backend/ && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
COPY [ "./_docker/src/proxy/", "./" ]
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
|
||||
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -1,17 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/no-config/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/no-config/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -1,17 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/sharing/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/sharing/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -1 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
@@ -1,42 +0,0 @@
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add upx
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
RUN go build -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
RUN upx filebrowser
|
||||
|
||||
FROM node:lts-slim AS nbuild
|
||||
WORKDIR /app
|
||||
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
|
||||
COPY ./frontend/ ./
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
USER root
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:80/health || exit 1
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -1,17 +0,0 @@
|
||||
services:
|
||||
nginx-proxy-auth:
|
||||
image: nginx
|
||||
container_name: nginx-proxy-auth
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
|
||||
filebrowser:
|
||||
hostname: filebrowser
|
||||
volumes:
|
||||
- '../frontend:/home/frontend'
|
||||
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./_docker/Dockerfile.slim
|
||||
@@ -1,15 +0,0 @@
|
||||
server:
|
||||
<<: *server-main
|
||||
frontend:
|
||||
<<: *frontend-main
|
||||
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
@@ -1,12 +0,0 @@
|
||||
frontend-main: &frontend-main
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
styling:
|
||||
customThemes:
|
||||
no-rounded:
|
||||
description: "No rounded corners" # The description of the theme to display in the UI.
|
||||
css: "no-rounded.css" # The css file path and filename to use for the theme.
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
@@ -1,15 +0,0 @@
|
||||
server-main: &server-main
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
disableIndexing: true
|
||||
defaultEnabled: true
|
||||
@@ -1,47 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/general",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/no-config-setup",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/no-config",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "exclude"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
conditionals:
|
||||
rules:
|
||||
- folderPath: "excluded"
|
||||
- folderPath: "excludedButVisible"
|
||||
viewable: true
|
||||
- path: "../frontend/tests/playwright-files2"
|
||||
name: "include"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
conditionals:
|
||||
rules:
|
||||
- includeRootItem: "myfolder"
|
||||
- includeRootItem: "subfolderExclusions"
|
||||
- filePath: "/subfolderExclusions/exclusionlist.sh"
|
||||
- folderPath: "/subfolderExclusions/folderItem"
|
||||
viewable: true
|
||||
- fileStartsWith: "startsWith"
|
||||
- folderEndsWith: "Folder"
|
||||
|
||||
auth:
|
||||
methods:
|
||||
noauth: true
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
userDefaults:
|
||||
permissions:
|
||||
realtime: true
|
||||
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
@@ -1,48 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/noauth-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/noauth",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
storageState: "noauth.json",
|
||||
actionTimeout: 5000,
|
||||
//storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: false
|
||||
oidc:
|
||||
enabled: true
|
||||
createUser: true
|
||||
clientId: "vyBrzOmcrU3OokiWEZoObdGBscw0eYPNJauBtOJr"
|
||||
clientSecret: "gIMKbkEcckUhlGrnE2Vxi8eZhEcfxQqfwFgM0ATzk4DciHGrW2YRtQfqcpRTsmzrxEETkKvZ8yeK3sfoXnUV1IQTzqK7wxw3lEMtmeXi5clj0h7nH1rVGFq2Q3HL8zUe"
|
||||
issuerUrl: "http://localhost:8080"
|
||||
adminGroup: "Admins"
|
||||
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: false
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: false
|
||||
api: false
|
||||
download: true
|
||||
create: true
|
||||
@@ -1,60 +0,0 @@
|
||||
|
||||
import { OAuth2Server } from 'oauth2-mock-server';
|
||||
|
||||
// This is a list of pre-defined users. You can add more users here.
|
||||
// The filebrowser application will use this data when a user logs in.
|
||||
const users = {
|
||||
// Default user
|
||||
johndoe: {
|
||||
sub: 'johndoe',
|
||||
preferred_username: 'johndoe',
|
||||
email: 'johndoe@example.com',
|
||||
groups: ['Admins', 'Users'],
|
||||
},
|
||||
// Another example user
|
||||
testuser: {
|
||||
sub: 'testuser',
|
||||
preferred_username: 'testuser',
|
||||
email: 'testuser@example.com',
|
||||
groups: ['Users'],
|
||||
},
|
||||
};
|
||||
|
||||
const server = new OAuth2Server();
|
||||
|
||||
// Generate a new RSA key for the server.
|
||||
await server.issuer.keys.generate('RS256');
|
||||
|
||||
/**
|
||||
* This hook is called before the ID token is signed.
|
||||
* We're using it to add claims to the ID token.
|
||||
* This is the primary way the application will get user information.
|
||||
*/
|
||||
server.service.on('beforeIdTokenSigning', (token, req) => {
|
||||
// By default, the mock server uses 'johndoe' as the subject.
|
||||
const user = users[token.payload.sub] || users.johndoe;
|
||||
console.log(`[ID Token] Customizing token for user: ${user.sub}`);
|
||||
Object.assign(token.payload, user);
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* This hook is called before the userinfo response is sent.
|
||||
* We're using it to provide user claims when the application calls the /userinfo endpoint.
|
||||
*/
|
||||
server.service.on('beforeUserinfo', (userInfoResponse, req) => {
|
||||
const user = users.johndoe;
|
||||
console.log(`[UserInfo] Customizing userinfo for user: ${user.sub}`);
|
||||
userInfoResponse.body = user;
|
||||
});
|
||||
|
||||
|
||||
const port = 8080;
|
||||
const host = '0.0.0.0'; // Bind to 0.0.0.0 to be accessible within Docker
|
||||
await server.start(port, host);
|
||||
console.log(`OAuth 2 Mock Server listening on http://${host}:${port}`);
|
||||
console.log(`Issuer URL: ${server.issuer.url}`);
|
||||
console.log('---');
|
||||
console.log('Pre-defined users:');
|
||||
console.log(JSON.stringify(users, null, 2));
|
||||
console.log('---');
|
||||
@@ -1,48 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/oidc-setup",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/oidc",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
storageState: "loginAuth.json",
|
||||
actionTimeout: 5000,
|
||||
//storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
demo-127.0.0.1:$apr1$j5FRA4Bc$RYdNFuM7YAAo9Qtie8w4b/
|
||||
@@ -1,46 +0,0 @@
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/subpath"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: false
|
||||
proxy:
|
||||
enabled: true
|
||||
header: "X-Username"
|
||||
createUser: true
|
||||
logoutRedirectUrl: "https://localhost/api/auth/logout2"
|
||||
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: false
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: true
|
||||
api: false
|
||||
download: true
|
||||
create: true
|
||||
@@ -1,32 +0,0 @@
|
||||
|
||||
map $remote_addr $uuid {
|
||||
default "demo-${remote_addr}";
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
location /subpath/public/ {
|
||||
proxy_pass http://filebrowser:8080/subpath/public/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Username $uuid;
|
||||
}
|
||||
|
||||
# requires basic auth demo-127.0.0.1:SecurePass123!
|
||||
location /subpath {
|
||||
auth_basic "Restricted Access";
|
||||
auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
|
||||
proxy_set_header X-Username $uuid;
|
||||
add_header X-Username $uuid;
|
||||
proxy_pass http://filebrowser:8080/subpath;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/proxy-setup.ts",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/proxy",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
disableIndexing: true
|
||||
defaultEnabled: true
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
styling:
|
||||
customThemes:
|
||||
no-rounded:
|
||||
description: "No rounded corners" # The description of the theme to display in the UI.
|
||||
css: "no-rounded.css" # The css file path and filename to use for the theme.
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
@@ -1,47 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/settings",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
private: true
|
||||
defaultEnabled: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
@@ -1,47 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/sharing",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Cache interface for caching operations
|
||||
@@ -29,9 +32,61 @@ type FileCache struct {
|
||||
}
|
||||
}
|
||||
|
||||
// NewFileCache creates a new FileCache
|
||||
func NewFileCache(dir string) (*FileCache, error) {
|
||||
return &FileCache{dir: dir}, nil
|
||||
cacheDir := filepath.Join(dir, "diskcache")
|
||||
|
||||
// Migrate existing cache files from old structure (if any)
|
||||
if err := migrateOldCacheStructure(dir, cacheDir); err != nil {
|
||||
logger.Warningf("failed to migrate old cache structure: %v\n", err)
|
||||
}
|
||||
|
||||
// Create the cache directory immediately with proper permissions
|
||||
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
return &FileCache{dir: cacheDir}, nil
|
||||
}
|
||||
|
||||
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
|
||||
func migrateOldCacheStructure(oldDir, newDir string) error {
|
||||
// Read the old cache directory
|
||||
entries, err := os.ReadDir(oldDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // Directory doesn't exist yet, nothing to migrate
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
migrated := 0
|
||||
for _, entry := range entries {
|
||||
// Only migrate single-character directories (cache hash directories like a, b, c, etc.)
|
||||
if !entry.IsDir() || len(entry.Name()) != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if it's already the diskcache directory
|
||||
if entry.Name() == "d" && filepath.Join(oldDir, entry.Name()) == newDir {
|
||||
continue
|
||||
}
|
||||
|
||||
oldPath := filepath.Join(oldDir, entry.Name())
|
||||
newPath := filepath.Join(newDir, entry.Name())
|
||||
|
||||
// Create parent directory in new location
|
||||
if err := os.MkdirAll(newDir, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Move the directory
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
// If rename fails (e.g., cross-device), skip this one
|
||||
continue
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
@@ -40,11 +95,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
defer mu.Unlock()
|
||||
|
||||
fileName := f.getFileName(key)
|
||||
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fileName, value, 0600); err != nil {
|
||||
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,9 @@ func TestFileCache(t *testing.T) {
|
||||
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
|
||||
)
|
||||
|
||||
// Set up file permissions before creating cache
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
|
||||
// Create temporary directory for the cache
|
||||
cacheDir, err := os.MkdirTemp("", cacheRoot)
|
||||
require.NoError(t, err)
|
||||
@@ -28,19 +32,20 @@ func TestFileCache(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// store new key
|
||||
// Note: NewFileCache creates a "diskcache" subdirectory, so the actual path includes it
|
||||
err = cache.Store(ctx, key, []byte(value))
|
||||
require.NoError(t, err)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
|
||||
|
||||
// update existing key
|
||||
err = cache.Store(ctx, key, []byte(newValue))
|
||||
require.NoError(t, err)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
|
||||
|
||||
// delete key
|
||||
err = cache.Delete(ctx, key)
|
||||
require.NoError(t, err)
|
||||
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
|
||||
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
|
||||
require.False(t, exists)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetContent_UTF8Truncation(t *testing.T) {
|
||||
// Get the path to the test file
|
||||
// The test file is in frontend/tests/playwright-files/utf8-truncated.txt
|
||||
// We need to find it relative to the test execution directory
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Navigate from backend/adapters/fs/files to the project root
|
||||
// backend/adapters/fs/files -> backend/adapters/fs -> backend/adapters -> backend -> root
|
||||
testFilePath := filepath.Join(cwd, "..", "..", "..", "..", "frontend", "tests", "playwright-files", "utf8-truncated.txt")
|
||||
|
||||
// Check if file exists, if not try alternative path
|
||||
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
|
||||
// Try from project root
|
||||
testFilePath = filepath.Join("frontend", "tests", "playwright-files", "utf8-truncated.txt")
|
||||
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
|
||||
t.Skipf("Test file not found at %s, skipping test", testFilePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get absolute path
|
||||
var absPath string
|
||||
absPath, err = filepath.Abs(testFilePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("file with UTF-8 truncation at 4096 byte boundary", func(t *testing.T) {
|
||||
// The test file is longer than 4096 bytes, with valid UTF-8 throughout.
|
||||
// However, when reading exactly 4096 bytes as a header, it cuts off in the
|
||||
// middle of a multi-byte UTF-8 sequence (e6 9c, missing the last byte 88 of '月').
|
||||
// This should trigger the truncation handling in getContent's header validation.
|
||||
// The fix should trim the incomplete sequence from the header, allowing the
|
||||
// header check to pass, and then the full file (which is valid UTF-8) should
|
||||
// pass the full file validation.
|
||||
content, err := getContent(absPath)
|
||||
|
||||
// Should not return an error - the header truncation is handled, and the full file is valid
|
||||
require.NoError(t, err)
|
||||
|
||||
// The content should be the full valid UTF-8 file
|
||||
require.NotEmpty(t, content, "Content should not be empty - full file is valid UTF-8")
|
||||
|
||||
// Verify it contains the expected text
|
||||
require.Contains(t, content, "文件已备份", "Content should contain Chinese characters")
|
||||
require.Contains(t, content, "2024年", "Content should contain date information")
|
||||
})
|
||||
|
||||
t.Run("regular UTF-8 text file", func(t *testing.T) {
|
||||
// Test with a simple text file to ensure normal files still work
|
||||
tmpFile, err := os.CreateTemp("", "test-utf8-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
testText := "Hello, 世界! This is a test file with UTF-8 characters.\n"
|
||||
_, err = tmpFile.WriteString(testText)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
content, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testText, content)
|
||||
})
|
||||
|
||||
t.Run("file smaller than header size", func(t *testing.T) {
|
||||
// Test with a file smaller than 4096 bytes
|
||||
tmpFile, err := os.CreateTemp("", "test-small-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
testText := "Small file content"
|
||||
_, err = tmpFile.WriteString(testText)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
content, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testText, content)
|
||||
})
|
||||
|
||||
t.Run("file with Chinese characters at boundary", func(t *testing.T) {
|
||||
// Create a file that's exactly 4094 bytes, ending with a complete Chinese character
|
||||
tmpFile, err := os.CreateTemp("", "test-chinese-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
// Create content that's exactly 4094 bytes, ending with complete UTF-8
|
||||
baseText := "2024年 06月 17日 星期一 04:05:58 CST 文件已备份\n"
|
||||
content := ""
|
||||
for len([]byte(content)) < 4094 {
|
||||
content += baseText
|
||||
}
|
||||
// Trim to exactly 4094 bytes
|
||||
encoded := []byte(content)
|
||||
if len(encoded) > 4094 {
|
||||
encoded = encoded[:4094]
|
||||
}
|
||||
// Ensure it ends with a complete character by finding the last complete rune
|
||||
for len(encoded) > 0 {
|
||||
lastRune, _ := decodeLastRune(encoded)
|
||||
if lastRune != 0xFFFD { // RuneError
|
||||
break
|
||||
}
|
||||
encoded = encoded[:len(encoded)-1]
|
||||
}
|
||||
|
||||
_, err = tmpFile.Write(encoded)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
result, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result)
|
||||
require.Equal(t, string(encoded), result)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to check if last rune is valid
|
||||
func decodeLastRune(p []byte) (rune, int) {
|
||||
if len(p) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
r, size := utf8.DecodeLastRune(p)
|
||||
return r, size
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
@@ -98,11 +101,11 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "File2.txt"},
|
||||
{Name: "File10.txt"},
|
||||
{Name: "File1"},
|
||||
{Name: "banana"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
|
||||
},
|
||||
},
|
||||
expected: iteminfo.FileInfo{
|
||||
@@ -112,11 +115,11 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "banana"},
|
||||
{Name: "File1"},
|
||||
{Name: "File10.txt"},
|
||||
{Name: "File2.txt"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -128,10 +131,10 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "apple"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "Zebra"},
|
||||
{Name: "apple"},
|
||||
{Name: "cat"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
|
||||
},
|
||||
},
|
||||
expected: iteminfo.FileInfo{
|
||||
@@ -140,10 +143,10 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "dog.txt"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "apple"},
|
||||
{Name: "cat"},
|
||||
{Name: "Zebra"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -153,7 +156,7 @@ func TestSortItems(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.input.SortItems()
|
||||
|
||||
getNames := func(items []iteminfo.ItemInfo) []string {
|
||||
getFolderNames := func(items []iteminfo.ItemInfo) []string {
|
||||
names := []string{}
|
||||
for _, folder := range items {
|
||||
names = append(names, folder.Name)
|
||||
@@ -161,15 +164,23 @@ func TestSortItems(t *testing.T) {
|
||||
return names
|
||||
}
|
||||
|
||||
actualFolderNames := getNames(test.input.Folders)
|
||||
expectedFolderNames := getNames(test.expected.Folders)
|
||||
getFileNames := func(items []iteminfo.ExtendedItemInfo) []string {
|
||||
names := []string{}
|
||||
for _, file := range items {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
actualFolderNames := getFolderNames(test.input.Folders)
|
||||
expectedFolderNames := getFolderNames(test.expected.Folders)
|
||||
|
||||
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
|
||||
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
|
||||
}
|
||||
|
||||
actualFileNames := getNames(test.input.Files)
|
||||
expectedFileNames := getNames(test.expected.Files)
|
||||
actualFileNames := getFileNames(test.input.Files)
|
||||
expectedFileNames := getFileNames(test.expected.Files)
|
||||
|
||||
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
|
||||
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
|
||||
@@ -178,80 +189,37 @@ func TestSortItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilesCacheClearing(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "filebrowser_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a test file
|
||||
testFile := filepath.Join(tempDir, "Test Object")
|
||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Initialize a mock index
|
||||
idx := indexing.Index{
|
||||
Source: settings.Source{
|
||||
Path: tempDir,
|
||||
},
|
||||
}
|
||||
|
||||
// Get the real path and cache it
|
||||
_, isDir, err := idx.GetRealPath("/Test Object")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get real path: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file is detected as a file (not directory)
|
||||
if isDir {
|
||||
t.Errorf("Expected file to be detected as file, but got directory")
|
||||
}
|
||||
|
||||
// Initialize the index in the indexing system
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: tempDir,
|
||||
}, true) // true for mock mode
|
||||
|
||||
// Delete the file
|
||||
err = DeleteFiles("test", testFile, tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete file: %v", err)
|
||||
}
|
||||
|
||||
// Create a directory with the same name
|
||||
testDir := filepath.Join(tempDir, "Test Object")
|
||||
err = os.Mkdir(testDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test directory: %v", err)
|
||||
}
|
||||
|
||||
// Get the real path again - it should now detect it as a directory
|
||||
// The cache should have been cleared, so it should re-detect the type
|
||||
realPath2, isDir2, err := idx.GetRealPath("/Test Object")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get real path after recreation: %v", err)
|
||||
}
|
||||
|
||||
// Verify the directory is detected as a directory
|
||||
if !isDir2 {
|
||||
t.Errorf("Expected directory to be detected as directory, but got file. Real path: %s", realPath2)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
// TestDeleteFilesCacheClearing was removed because cache clearing is not performed
|
||||
// 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")
|
||||
@@ -259,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: "/",
|
||||
@@ -270,25 +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.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")
|
||||
}
|
||||
@@ -322,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")
|
||||
@@ -334,36 +337,47 @@ 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: "/",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "Test Object", Size: 12}, // Length of "test content"
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{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")
|
||||
}
|
||||
|
||||
+451
-276
@@ -1,92 +1,200 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
index := indexing.GetIndex(opts.Source)
|
||||
if index == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
if !strings.HasPrefix(opts.Path, "/") {
|
||||
opts.Path = "/" + opts.Path
|
||||
}
|
||||
realPath, isDir, err := index.GetRealPath(opts.Path)
|
||||
if err != nil {
|
||||
return response, fmt.Errorf("could not get real path for requested path: %v, error: %v", opts.Path, err)
|
||||
}
|
||||
if !strings.HasSuffix(opts.Path, "/") && isDir {
|
||||
opts.Path = opts.Path + "/"
|
||||
}
|
||||
opts.IsDir = isDir
|
||||
var info *iteminfo.FileInfo
|
||||
|
||||
// Check if path is viewable (allows filesystem access without indexing)
|
||||
isViewable := index.IsViewable(isDir, opts.Path)
|
||||
|
||||
// For non-viewable paths, verify they are indexed
|
||||
// Skip this check if indexing is disabled for the entire source
|
||||
if !isViewable && !index.Config.DisableIndexing {
|
||||
err = index.RefreshFileInfo(opts)
|
||||
if err != nil {
|
||||
return response, fmt.Errorf("path not accessible: %v", err)
|
||||
// processDirectoryMetadata extracts metadata for audio/video files in directories
|
||||
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
|
||||
metadataCount := 0
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
if isItemAudio || isItemVideo {
|
||||
metadataCount++
|
||||
}
|
||||
}
|
||||
// Process files concurrently using goroutines
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // Protects processedCount
|
||||
|
||||
if isDir {
|
||||
info, err = index.GetFsDirInfo(opts.Path)
|
||||
// Refresh directory to update folder sizes
|
||||
wg.Go(func() {
|
||||
indexPath := idx.MakeIndexPath(response.Path, true)
|
||||
err := idx.RefreshDirectory(indexPath, false)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
} else {
|
||||
// For files, get info from parent directory to ensure HasPreview is set correctly
|
||||
info, err = index.GetFsDirInfo(opts.Path)
|
||||
if err != nil {
|
||||
return response, err
|
||||
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Set hasMetadata flag if there are files with potential metadata
|
||||
if metadataCount > 0 {
|
||||
response.HasMetadata = true
|
||||
}
|
||||
|
||||
response.FileInfo = *info
|
||||
response.RealPath = realPath
|
||||
response.Source = opts.Source
|
||||
// Only process metadata if explicitly requested
|
||||
if opts.Metadata && metadataCount > 0 {
|
||||
processedCount := 0
|
||||
|
||||
// Create a single shared FFmpegService instance for all files to coordinate concurrency
|
||||
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
|
||||
if sharedFFmpegService != nil {
|
||||
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
if opts.Content || opts.Metadata {
|
||||
processContent(response, index, opts)
|
||||
}
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -99,26 +207,44 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
}
|
||||
|
||||
if isVideo {
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
|
||||
if exists {
|
||||
info.DetectSubtitles(parentInfo)
|
||||
err := info.LoadSubtitleContent()
|
||||
if err != nil {
|
||||
logger.Debug("failed to load subtitle content: " + err.Error())
|
||||
}
|
||||
}
|
||||
// Extract duration for video
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
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 {
|
||||
info.Metadata = extItem.Metadata
|
||||
}
|
||||
|
||||
// Handle subtitles if requested
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
|
||||
if exists {
|
||||
info.GetSubtitles(parentInfo)
|
||||
}
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
info.Subtitles = append(info.Subtitles, subtitles...)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if isAudio {
|
||||
err := extractAudioMetadata(info)
|
||||
// Create an ExtendedItemInfo to hold the metadata
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
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 {
|
||||
info.HasPreview = info.AudioMeta.AlbumArt != ""
|
||||
// Copy metadata to ExtendedFileInfo
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasMetadata = true
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -148,8 +274,10 @@ func generateOfficeId(realPath string) string {
|
||||
}
|
||||
|
||||
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
|
||||
func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
|
||||
file, err := os.Open(item.RealPath)
|
||||
// and optionally extracts duration using the ffmpeg service with concurrency control
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -172,7 +300,7 @@ func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
|
||||
return err
|
||||
}
|
||||
|
||||
item.AudioMeta = &iteminfo.AudioMetadata{
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Title: m.Title(),
|
||||
Artist: m.Artist(),
|
||||
Album: m.Album(),
|
||||
@@ -182,50 +310,99 @@ func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
|
||||
|
||||
// Extract track number
|
||||
track, _ := m.Track()
|
||||
item.AudioMeta.Track = track
|
||||
item.Metadata.Track = track
|
||||
|
||||
// Extract album art and encode as base64 with strict size limits
|
||||
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
|
||||
// This respects concurrency limits and gracefully handles missing ffmpeg
|
||||
if getDuration {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !getArt {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.AudioMeta.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
|
||||
item.Metadata.AlbumArt = picture.Data
|
||||
} else {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", item.RealPath, len(picture.Data))
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteFiles(source, absPath string, absDirPath string) error {
|
||||
err := os.RemoveAll(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
// extractVideoMetadata extracts duration from video files using ffprobe
|
||||
// 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
|
||||
}
|
||||
if duration > 0 {
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Duration: int(duration),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear RealPathCache entries for the deleted path to prevent cache issues
|
||||
// when a folder with the same name is created later
|
||||
indexPath := index.MakeIndexPath(absPath)
|
||||
realPath, _, err := index.GetRealPath(indexPath)
|
||||
if err == nil {
|
||||
// Clear both the path and the isdir cache entries
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.RealPathCache.Delete(realPath + ":isdir")
|
||||
}
|
||||
if !index.Config.DisableIndexing {
|
||||
indexPath := index.MakeIndexPath(absPath, isDir)
|
||||
|
||||
refreshConfig := utils.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
if err != nil {
|
||||
// Perform the physical deletion
|
||||
err := os.RemoveAll(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear cache entries
|
||||
indexing.RealPathCache.Delete(absPath)
|
||||
indexing.IsDirCache.Delete(absPath + ":isdir")
|
||||
|
||||
// Remove metadata from index
|
||||
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
|
||||
refreshConfig := utils.FileOptions{
|
||||
Path: index.MakeIndexPath(filepath.Dir(absPath), true),
|
||||
IsDir: true,
|
||||
}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
// Indexing disabled, just delete the file
|
||||
return os.RemoveAll(absPath)
|
||||
}
|
||||
|
||||
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
|
||||
@@ -237,19 +414,45 @@ 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
|
||||
}
|
||||
|
||||
// For directories, check if the path exists on disk
|
||||
// If it doesn't exist, remove it from the index
|
||||
if isDir {
|
||||
realPath, _, err := idx.GetRealPath(path)
|
||||
if err == nil {
|
||||
// Check if the directory exists on disk
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := idx.RefreshFileInfo(utils.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
|
||||
if err != nil {
|
||||
logger.Errorf("RefreshFileInfo failed: %v", err)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -282,21 +485,17 @@ func validateMoveDestination(src, dst string, isSrcDir bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage) error {
|
||||
func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage, a *access.Storage) error {
|
||||
// Check if source and destination are the same file
|
||||
if realsrc == realdst {
|
||||
return fmt.Errorf("cannot move a file to itself: %s", realsrc)
|
||||
}
|
||||
|
||||
// Validate the move operation before executing
|
||||
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := fileutils.MoveFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For move operations:
|
||||
// 1. Delete the source from the index (recursively if it's a directory)
|
||||
// 2. Recursively index the destination to capture the entire moved tree
|
||||
|
||||
// Get indexes for deletion and refresh operations
|
||||
srcIdx := indexing.GetIndex(sourceIndex)
|
||||
if srcIdx == nil {
|
||||
@@ -307,77 +506,123 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
return fmt.Errorf("could not get destination index: %v", destIndex)
|
||||
}
|
||||
|
||||
// Delete from source index (recursively for directories)
|
||||
go srcIdx.DeleteMetadata(realsrc, isSrcDir, isSrcDir) //nolint:errcheck
|
||||
// Prepare paths for index operations
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc, isSrcDir)
|
||||
srcParentPath := filepath.Dir(realsrc)
|
||||
|
||||
// Clear RealPathCache entries for the moved path to prevent cache issues
|
||||
// when a file/folder with the same name is created later
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc)
|
||||
srcRealPath, _, err := srcIdx.GetRealPath(srcIndexPath)
|
||||
if err == nil {
|
||||
// Clear both the path and the isdir cache entries
|
||||
indexing.RealPathCache.Delete(srcRealPath)
|
||||
indexing.RealPathCache.Delete(srcRealPath + ":isdir")
|
||||
// Perform the physical move
|
||||
err := fileutils.MoveFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For move operations, refresh the moved item and its new parent directory
|
||||
// Run all refresh operations in a goroutine to avoid blocking the API
|
||||
if isSrcDir {
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !srcIdx.Config.DisableIndexing {
|
||||
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
|
||||
|
||||
// THEN refresh the parent directory so it sees the newly indexed child
|
||||
parentDir := filepath.Dir(realdst)
|
||||
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
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)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// If moving a file, just refresh the parent directory
|
||||
parentDir := filepath.Dir(realdst)
|
||||
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
}
|
||||
|
||||
// Handle DESTINATION indexing
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !dstIdx.Config.DisableIndexing {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Refresh parent to update its size
|
||||
parentDir := filepath.Dir(realdst)
|
||||
if err := RefreshIndex(destIndex, parentDir, true, false); err != nil {
|
||||
logger.Errorf("Failed to refresh destination parent %s: %v", parentDir, err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// 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, isSrcDir), dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
|
||||
}
|
||||
// Cross-source moves don't preserve access rules (they're source-specific)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string) error {
|
||||
// Check if source and destination are the same file
|
||||
if realsrc == realdst {
|
||||
return fmt.Errorf("cannot copy a file to itself: %s", realsrc)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -397,15 +642,10 @@ func WriteDirectory(opts utils.FileOptions) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove existing file to create directory: %v", err)
|
||||
}
|
||||
// Clear the cache for the removed file
|
||||
realPath, _, err = idx.GetRealPath(opts.Path)
|
||||
if err == nil {
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.RealPathCache.Delete(realPath + ":isdir")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -413,11 +653,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 {
|
||||
@@ -436,18 +691,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)
|
||||
}
|
||||
}
|
||||
// Clear the cache for the removed directory (use realPath without re-fetching)
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.RealPathCache.Delete(realPath + ":isdir")
|
||||
// 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
|
||||
@@ -462,135 +719,53 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
|
||||
// Explicitly set file permissions to bypass umask
|
||||
err = os.Chmod(realPath, fileutils.PermFile)
|
||||
if err != nil {
|
||||
// Handle chmod error gracefully
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
}
|
||||
|
||||
// Refresh the file itself
|
||||
err = RefreshIndex(source, path, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return RefreshIndex(source, path, false, false)
|
||||
// Refresh parent directory to update its size
|
||||
parentPath := filepath.Dir(path)
|
||||
if parentPath != "." && parentPath != "/" && parentPath != path {
|
||||
if err := RefreshIndex(source, parentPath, true, false); err != nil {
|
||||
logger.Debugf("Could not refresh parent directory %s: %v", parentPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getContent reads and returns the file content if it's considered an editable text file.
|
||||
// This is a wrapper around utils.IsTextFile that also returns the file content.
|
||||
func getContent(realPath string) (string, error) {
|
||||
const headerSize = 4096
|
||||
// Thresholds for detecting binary-like content (these can be tuned)
|
||||
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
|
||||
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
|
||||
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
|
||||
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
|
||||
|
||||
// Open file
|
||||
f, err := os.Open(realPath)
|
||||
// First check if it's a text file using the consolidated validation logic
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Read header
|
||||
headerBytes := make([]byte, headerSize)
|
||||
n, err := f.Read(headerBytes)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
if !isText {
|
||||
// Not a text file, return empty string (no error, just not text)
|
||||
return "", nil
|
||||
}
|
||||
actualHeader := headerBytes[:n]
|
||||
|
||||
// --- Start of new heuristic checks ---
|
||||
|
||||
if n > 0 {
|
||||
// 1. Basic Check: Is the header valid UTF-8?
|
||||
// If not, it's unlikely an editable UTF-8 text file.
|
||||
if !utf8.Valid(actualHeader) {
|
||||
return "", nil // Not an error, just not the text file we want
|
||||
}
|
||||
|
||||
// 2. Check for excessive null bytes in the header
|
||||
nullCountInHeader := 0
|
||||
for _, b := range actualHeader {
|
||||
if b == 0x00 {
|
||||
nullCountInHeader++
|
||||
}
|
||||
}
|
||||
// Reject if too many nulls absolutely or relatively in the header
|
||||
if nullCountInHeader > 0 { // Only perform check if there are any nulls
|
||||
if nullCountInHeader > maxNullBytesInHeaderAbs ||
|
||||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
|
||||
return "", nil // Too many nulls in header
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for other non-text ASCII control characters in the header
|
||||
// (C0 controls excluding \t, \n, \r)
|
||||
for _, b := range actualHeader {
|
||||
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
|
||||
return "", nil // Found problematic control character
|
||||
}
|
||||
// C1 control characters (0x80-0x9F) would be caught by utf8.Valid if part of invalid sequences,
|
||||
// or by the non-printable rune check later if they form valid (but undesirable) codepoints.
|
||||
}
|
||||
|
||||
// Optional: Use http.DetectContentType for an additional check on the header
|
||||
// contentType := http.DetectContentType(actualHeader)
|
||||
// if !strings.HasPrefix(contentType, "text/") && contentType != "application/octet-stream" {
|
||||
// // If it's clearly a non-text MIME type (e.g., "image/jpeg"), reject it.
|
||||
// // "application/octet-stream" is ambiguous, so we rely on other heuristics.
|
||||
// return "", nil
|
||||
// }
|
||||
}
|
||||
// --- End of new heuristic checks for header ---
|
||||
|
||||
// Now read the full file (original logic)
|
||||
// It's a text file, read and return the content
|
||||
content, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Handle empty file (original logic - returns specific string)
|
||||
|
||||
// Handle empty file (return specific marker string)
|
||||
if len(content) == 0 {
|
||||
return "empty-file-x6OlSil", nil
|
||||
}
|
||||
|
||||
stringContent := string(content)
|
||||
|
||||
// 4. Final UTF-8 validation for the entire file
|
||||
// (This is crucial as the header might be fine, but the rest of the file isn't)
|
||||
if !utf8.ValidString(stringContent) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 5. Check for excessive null bytes in the entire file content
|
||||
if len(content) > 0 { // Check only for non-empty files
|
||||
totalNullCount := 0
|
||||
for _, b := range content {
|
||||
if b == 0x00 {
|
||||
totalNullCount++
|
||||
}
|
||||
}
|
||||
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
|
||||
return "", nil // Too many nulls in the entire file
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check for excessive non-printable runes in the entire file content
|
||||
// (Excluding tab, newline, carriage return, which are common in text files)
|
||||
if len(stringContent) > 0 { // Check only for non-empty strings
|
||||
nonPrintableRuneCount := 0
|
||||
totalRuneCount := 0
|
||||
for _, r := range stringContent {
|
||||
totalRuneCount++
|
||||
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
|
||||
// It excludes control characters. We explicitly allow \t, \n, \r.
|
||||
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
|
||||
nonPrintableRuneCount++
|
||||
}
|
||||
}
|
||||
|
||||
if totalRuneCount > 0 { // Avoid division by zero
|
||||
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
|
||||
return "", nil // Too many non-printable runes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The file has passed all checks and is considered editable text.
|
||||
return stringContent, nil
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
func IsNamedPipe(mode os.FileMode) bool {
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// MakeUserDir makes the user directory according to settings.
|
||||
@@ -25,37 +27,46 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
|
||||
for i, scope := range u.Scopes {
|
||||
source, ok := settings.Config.Server.SourceMap[scope.Name]
|
||||
if !ok {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
continue
|
||||
}
|
||||
// create directory and append user name
|
||||
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
|
||||
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
|
||||
parentDir := filepath.Join(source.Path, scope.Scope)
|
||||
// validate that scope path exists
|
||||
// If parent directory doesn't exist and createUserDir is enabled, create it
|
||||
if !Exists(parentDir) {
|
||||
return fmt.Errorf("MakeUserDirs: scope path does not exist: %s", scope.Scope)
|
||||
if err := MakeUserDir(parentDir); err != nil {
|
||||
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
|
||||
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
|
||||
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
// Continue to next scope even if this one failed
|
||||
}
|
||||
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
|
||||
// create directory exactly as specified
|
||||
fullPath := filepath.Join(source.Path, scope.Scope)
|
||||
parentDir := filepath.Dir(fullPath)
|
||||
if !Exists(parentDir) {
|
||||
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
|
||||
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
|
||||
continue
|
||||
}
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user: failed to create user home dir: %s", err)
|
||||
logger.Errorf("create user: failed to create user home dir: %s", err)
|
||||
// Continue to next scope even if this one failed
|
||||
}
|
||||
} else {
|
||||
// just assigning scope to path provided, so just check that it exists
|
||||
path := filepath.Join(source.Path, scope.Scope)
|
||||
if !Exists(path) {
|
||||
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
|
||||
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
|
||||
continue
|
||||
}
|
||||
}
|
||||
u.Scopes[i] = scope
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
assetFs fs.FS
|
||||
)
|
||||
|
||||
// Custom dirFS to handle both embedded and non-embedded file systems
|
||||
type dirFS struct {
|
||||
http.Dir
|
||||
}
|
||||
|
||||
// Implement the Open method for dirFS, which wraps http.Dir
|
||||
func (d dirFS) Open(name string) (fs.File, error) {
|
||||
return d.Dir.Open(name)
|
||||
}
|
||||
|
||||
// InitAssetFS initializes the asset filesystem for the application
|
||||
// This should be called once during startup before http or preview services start
|
||||
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
|
||||
if useEmbedded {
|
||||
assetFs = embeddedAssets
|
||||
} else {
|
||||
// Dev mode: Serve files from http/dist directory
|
||||
assetFs = dirFS{Dir: http.Dir("http/dist")}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAssetFS returns the initialized asset filesystem
|
||||
func GetAssetFS() fs.FS {
|
||||
return assetFs
|
||||
}
|
||||
@@ -62,6 +62,13 @@ func CopyFile(source, dest string) error {
|
||||
|
||||
// copySingleFile handles copying a single file.
|
||||
func copySingleFile(source, dest string) error {
|
||||
// Get source file info to preserve permissions
|
||||
srcInfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourcePerms := srcInfo.Mode().Perm()
|
||||
|
||||
// Open the source file.
|
||||
src, err := os.Open(source)
|
||||
if err != nil {
|
||||
@@ -75,8 +82,8 @@ func copySingleFile(source, dest string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the destination file.
|
||||
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, PermFile)
|
||||
// Create the destination file with source permissions
|
||||
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,11 +95,13 @@ func copySingleFile(source, dest string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the configured file permissions instead of copying from source
|
||||
err = os.Chmod(dest, PermFile)
|
||||
|
||||
// Preserve source file permissions
|
||||
// Handle chmod errors gracefully (e.g., in rootless containers where chmod may be restricted)
|
||||
err = os.Chmod(dest, sourcePerms)
|
||||
if err != nil {
|
||||
return err
|
||||
// Log but don't fail - chmod may be restricted in some environments
|
||||
// The file was copied successfully, so we continue
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -67,15 +67,17 @@ func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, err
|
||||
|
||||
secretText := key.Secret()
|
||||
nonce := ""
|
||||
secretToStore := secretText
|
||||
if settings.Config.Auth.TotpSecret != "" {
|
||||
// If an encryption key is provided, encrypt the secret.
|
||||
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
|
||||
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
|
||||
}
|
||||
}
|
||||
// set cache so verify can attempt to use it but not require it for user yet.
|
||||
TotpCache.Set(user.Username, secretText+"||"+nonce)
|
||||
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
|
||||
// Use the original base32 secret in the OTP URL, not the encrypted version
|
||||
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
|
||||
return url, nil
|
||||
}
|
||||
@@ -181,12 +183,13 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
Algorithm: TOTPAlgorithm,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("error during TOTP validation: %v", err)
|
||||
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
|
||||
}
|
||||
if !valid {
|
||||
logger.Warningf("Invalid TOTP code for user %s", user.Username)
|
||||
return fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
if totpSecret != "" {
|
||||
if totpSecret != "" || !user.OtpEnabled {
|
||||
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
|
||||
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
|
||||
user.OtpEnabled = true // Enable OTP for the user
|
||||
|
||||
@@ -1,83 +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.004s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 0.006s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.003s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
|
||||
? github.com/gtsteffaniak/go-cache/cache [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/logger [no test files]
|
||||
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
|
||||
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.006s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.003s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.002s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/events [no test files]
|
||||
2025/05/11 20:42:56 [DEBUG] Saving user [admin] changepass: true
|
||||
2025/05/11 20:42:56 [DEBUG] Saving user [non-admin] changepass: true
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/http 0.197s
|
||||
2025/05/11 20:42:57 initializing index: [test]
|
||||
2025/05/11 20:42:57 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:57 [DEBUG] Starting full scan for [test]
|
||||
2025/05/11 20:42:57 Index assessment : [test] complexity=simple directories=0 files=0
|
||||
2025/05/11 20:42:57 [DEBUG] Time spent indexing [test]: 0 seconds
|
||||
2025/05/11 20:42:57 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:57 [DEBUG] Next scan in 1m0s
|
||||
2025/05/11 20:42:58 initializing index: [test]
|
||||
2025/05/11 20:42:58 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:58 [DEBUG] Starting full scan for [test]
|
||||
2025/05/11 20:42:58 Index assessment : [test] complexity=simple directories=0 files=0
|
||||
2025/05/11 20:42:58 [DEBUG] Time spent indexing [test]: 0 seconds
|
||||
2025/05/11 20:42:58 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:58 [DEBUG] Next scan in 1m0s
|
||||
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/05/11 20:42:59 initializing index: [test]
|
||||
2025/05/11 20:42:59 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:59 [DEBUG] Starting full scan for [test]
|
||||
2025/05/11 20:42:59 Index assessment : [test] complexity=simple directories=0 files=0
|
||||
2025/05/11 20:42:59 [DEBUG] Time spent indexing [test]: 0 seconds
|
||||
2025/05/11 20:42:59 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:59 [DEBUG] Next scan in 1m0s
|
||||
10 3548759 ns/op 38032 B/op 452 allocs/op
|
||||
BenchmarkCheckIndexExclude-8 10 172.6 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkCheckIndexConditionsInclude-8 10 118.2 ns/op 0 B/op 0 allocs/op
|
||||
2025/05/11 20:43:01 initializing index: [test]
|
||||
2025/05/11 20:43:01 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:43:01 [DEBUG] Starting full scan for [test]
|
||||
2025/05/11 20:43:01 Index assessment : [test] complexity=simple directories=0 files=0
|
||||
2025/05/11 20:43:01 [DEBUG] Time spent indexing [test]: 0 seconds
|
||||
2025/05/11 20:43:01 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:43:01 [DEBUG] Next scan in 1m0s
|
||||
BenchmarkSearchAllIndexes-8 2025/05/11 20:43:02 initializing index: [test]
|
||||
2025/05/11 20:43:02 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:43:02 [DEBUG] Starting full scan for [test]
|
||||
2025/05/11 20:43:02 Index assessment : [test] complexity=simple directories=0 files=0
|
||||
2025/05/11 20:43:02 [DEBUG] Time spent indexing [test]: 0 seconds
|
||||
2025/05/11 20:43:02 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:43:02 [DEBUG] Next scan in 1m0s
|
||||
10 781119 ns/op 38515 B/op 900 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/indexing 5.097s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.005s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.128s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/tools [no test files]
|
||||
@@ -15,14 +15,8 @@ func validateAccessRules() {
|
||||
if store.Access == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Validating and migrating access rules to new format...")
|
||||
|
||||
// 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 {
|
||||
@@ -50,8 +44,6 @@ func validateAccessRules() {
|
||||
// Create the new path with trailing slash
|
||||
newPath := oldPath + "/"
|
||||
|
||||
logger.Debugf("Migrating access rule from '%s' to '%s'", oldPath, newPath)
|
||||
|
||||
// Migrate the rule to the new path
|
||||
if err := migrateAccessRule(sourcePath, oldPath, newPath, rule); err != nil {
|
||||
logger.Errorf("Failed to migrate rule from %s to %s: %v", oldPath, newPath, err)
|
||||
@@ -70,12 +62,9 @@ func validateAccessRules() {
|
||||
|
||||
// After migration, clear cache
|
||||
if migratedCount > 0 {
|
||||
store.Access.ClearCacheForSource(sourcePath)
|
||||
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Access rules validation completed")
|
||||
}
|
||||
|
||||
// migrateAccessRule creates a new access rule with the new path format
|
||||
|
||||
+165
-55
@@ -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,68 +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.Config.UserDefaults.Permissions
|
||||
newUser.Permissions.Admin = asAdmin
|
||||
err = storage.CreateUser(newUser)
|
||||
// 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
|
||||
}
|
||||
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
|
||||
@@ -147,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"`
|
||||
@@ -350,8 +461,7 @@ func createConfig(configpath string) {
|
||||
|
||||
func generateYaml() {
|
||||
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
|
||||
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
if generateConfig || devMode {
|
||||
if generateConfig || settings.Env.IsDevMode {
|
||||
logger.Info("Generating config.yaml")
|
||||
settings.GenerateYaml()
|
||||
}
|
||||
|
||||
+75
-12
@@ -3,9 +3,11 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "net/http/pprof"
|
||||
|
||||
@@ -15,7 +17,9 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
"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"
|
||||
@@ -79,11 +83,10 @@ func StartFilebrowser() {
|
||||
}
|
||||
|
||||
// Dev mode enables development features like template hot-reloading
|
||||
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
_, err := os.Stat("http/dist")
|
||||
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
|
||||
if !devMode {
|
||||
settings.Config.Server.EmbeddedFs = os.IsNotExist(err)
|
||||
if !settings.Env.IsDevMode {
|
||||
settings.Env.EmbeddedFs = os.IsNotExist(err)
|
||||
}
|
||||
|
||||
sourceList := []string{}
|
||||
@@ -95,8 +98,16 @@ func StartFilebrowser() {
|
||||
logger.Infof("Auth Methods : %v", settings.Config.Auth.AuthMethods)
|
||||
logger.Info(database)
|
||||
logger.Infof("Sources : %v", sourceList)
|
||||
logger.Debugf("Using Embedded FS : %v", settings.Config.Server.EmbeddedFs)
|
||||
|
||||
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,12 +116,30 @@ 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()
|
||||
validateAccessRules()
|
||||
validateShareInfo()
|
||||
// Start the rootCMD in a goroutine
|
||||
go func() {
|
||||
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
|
||||
@@ -126,10 +155,23 @@ func StartFilebrowser() {
|
||||
case <-done:
|
||||
logger.Info("Server stopped unexpectedly. Shutting down...")
|
||||
}
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
|
||||
<-shutdownComplete // Ensure we don't exit prematurely
|
||||
// Wait for the server to stop
|
||||
// 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
|
||||
logger.Info("Shutdown complete.")
|
||||
}
|
||||
|
||||
@@ -139,12 +181,33 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
}
|
||||
cacheDir := settings.Config.Server.CacheDir
|
||||
numWorkers := settings.Config.Server.NumImageProcessors
|
||||
ffpmpegPath := settings.Config.Integrations.Media.FfmpegPath
|
||||
// setup disk cache
|
||||
err := preview.StartPreviewGenerator(numWorkers, ffpmpegPath, cacheDir)
|
||||
ffmpeg.SetFFmpegPaths()
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// validateShareInfo migrates share links to add default sidebar links
|
||||
func validateShareInfo() {
|
||||
if store.Share == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all shares
|
||||
shares, err := store.Share.All()
|
||||
if err != nil {
|
||||
logger.Debugf("No shares found or error getting shares: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
migratedCount := 0
|
||||
for _, link := range shares {
|
||||
// Check if this share needs migration (version not set or 0)
|
||||
if link.Version == 0 {
|
||||
// Add default sidebar links
|
||||
link.SidebarLinks = []users.SidebarLink{
|
||||
{
|
||||
Name: "Share QR Code and Info",
|
||||
Category: "shareInfo",
|
||||
Target: "#",
|
||||
Icon: "qr_code",
|
||||
},
|
||||
{
|
||||
Name: "Download",
|
||||
Category: "download",
|
||||
Target: "#",
|
||||
Icon: "download",
|
||||
},
|
||||
}
|
||||
|
||||
// Set version to 1 to indicate migration is complete
|
||||
link.Version = 1
|
||||
|
||||
// Save the updated share
|
||||
if err := store.Share.Save(link); err != nil {
|
||||
logger.Errorf("Failed to migrate share %s: %v", link.Hash, err)
|
||||
continue
|
||||
}
|
||||
|
||||
migratedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if migratedCount > 0 {
|
||||
logger.Infof("Migrated %d share links with default sidebar links", migratedCount)
|
||||
}
|
||||
}
|
||||
+77
-10
@@ -34,6 +34,12 @@ func validateUserInfo(newDB bool) {
|
||||
if updateLoginType(user) {
|
||||
updateUser = true
|
||||
}
|
||||
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
|
||||
@@ -73,27 +79,23 @@ func updateUserScopes(user *users.User) bool {
|
||||
|
||||
// Preserve order by using Config.Server.Sources
|
||||
for _, src := range settings.Config.Server.Sources {
|
||||
realsource, ok := settings.Config.Server.NameToSource[src.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
existingScope, ok := existing[realsource.Path]
|
||||
existingScope, ok := existing[src.Path]
|
||||
if ok {
|
||||
// If scope is empty and there's a default, apply default
|
||||
if existingScope.Scope == "" {
|
||||
existingScope.Scope = src.Config.DefaultUserScope
|
||||
}
|
||||
} else if realsource.Config.DefaultEnabled {
|
||||
existingScope.Scope = realsource.Config.DefaultUserScope
|
||||
} else if src.Config.DefaultEnabled {
|
||||
existingScope.Scope = src.Config.DefaultUserScope
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: realsource.Path,
|
||||
Name: src.Path,
|
||||
Scope: existingScope.Scope,
|
||||
})
|
||||
seen[realsource.Path] = struct{}{}
|
||||
seen[src.Path] = struct{}{}
|
||||
}
|
||||
|
||||
// Preserve user-defined scopes not matching current sources, append to end
|
||||
@@ -107,9 +109,17 @@ func updateUserScopes(user *users.User) bool {
|
||||
return changed
|
||||
}
|
||||
|
||||
func updateShowFirstLogin(user *users.User) bool {
|
||||
if user.ShowFirstLogin && !settings.Env.IsFirstLoad {
|
||||
user.ShowFirstLogin = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// func to convert legacy user with perm key to permissions
|
||||
func updatePermissions(user *users.User) bool {
|
||||
if user.Version == 1 {
|
||||
if user.Version >= 1 {
|
||||
return false
|
||||
}
|
||||
updateUser := true
|
||||
@@ -179,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
|
||||
}
|
||||
+390
-227
@@ -3,6 +3,7 @@ package settings
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -33,6 +35,9 @@ func Initialize(configFile string) {
|
||||
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
|
||||
logger.Fatal(err.Error())
|
||||
}
|
||||
setupLogging()
|
||||
// setup logging first to ensure we log any errors
|
||||
setupEnv()
|
||||
err = ValidateConfig(Config)
|
||||
if err != nil {
|
||||
errmsg := "The provided config file failed validation. "
|
||||
@@ -44,12 +49,35 @@ func Initialize(configFile string) {
|
||||
logger.Fatal(err.Error())
|
||||
}
|
||||
setupFs()
|
||||
setupLogging()
|
||||
setupServer()
|
||||
setupAuth(false)
|
||||
setupSources(false)
|
||||
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
|
||||
setupUrls()
|
||||
setupFrontend(false)
|
||||
setupVideoPreview()
|
||||
setupMedia()
|
||||
}
|
||||
|
||||
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() {
|
||||
Env.IsPlaywright = os.Getenv("FILEBROWSER_PLAYWRIGHT_TEST") == "true"
|
||||
if Env.IsPlaywright {
|
||||
logger.Warning("Running in playwright test mode. This is not recommended for production.")
|
||||
}
|
||||
Env.IsDevMode = os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
if Env.IsDevMode {
|
||||
logger.Warning("Running in dev mode. This is not recommended for production.")
|
||||
}
|
||||
}
|
||||
|
||||
func setupFs() {
|
||||
@@ -66,20 +94,137 @@ func setupFs() {
|
||||
}
|
||||
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
|
||||
|
||||
// try writing to cache dir
|
||||
absCacheDir, err := filepath.Abs(Config.Server.CacheDir)
|
||||
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)
|
||||
}
|
||||
// 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.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) {
|
||||
// Load login icon configuration at startup
|
||||
loadLoginIcon()
|
||||
if Config.Server.MinSearchLength == 0 {
|
||||
Config.Server.MinSearchLength = 3
|
||||
}
|
||||
@@ -99,7 +244,13 @@ func setupFrontend(generate bool) {
|
||||
}
|
||||
Config.Frontend.Styling.LightBackground = FallbackColor(Config.Frontend.Styling.LightBackground, "#f5f5f5")
|
||||
Config.Frontend.Styling.DarkBackground = FallbackColor(Config.Frontend.Styling.DarkBackground, "#141D24")
|
||||
Config.Frontend.Styling.CustomCSSRaw = readCustomCSS(Config.Frontend.Styling.CustomCSS)
|
||||
var err error
|
||||
if Config.Frontend.Styling.CustomCSS != "" {
|
||||
Config.Frontend.Styling.CustomCSSRaw, err = readCustomCSS(Config.Frontend.Styling.CustomCSS)
|
||||
if err != nil {
|
||||
logger.Warning(err.Error())
|
||||
}
|
||||
}
|
||||
Config.Frontend.Styling.CustomThemeOptions = map[string]CustomTheme{}
|
||||
if Config.Frontend.Styling.CustomThemes == nil {
|
||||
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
|
||||
@@ -129,52 +280,42 @@ func setupFrontend(generate bool) {
|
||||
loadCustomFavicon()
|
||||
}
|
||||
|
||||
func setupVideoPreview() {
|
||||
// If VideoPreview is not initialized, initialize with all types enabled
|
||||
if Config.Integrations.Media.Convert.VideoPreview == nil {
|
||||
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
Config.Integrations.Media.Convert.VideoPreview[t] = true
|
||||
}
|
||||
return
|
||||
func setupMedia() {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
func checkPathExists(realPath string) error {
|
||||
// check path exists
|
||||
if _, err := os.Stat(realPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("source path %v is currently not available", realPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupSources(generate bool) {
|
||||
if len(Config.Server.Sources) == 0 {
|
||||
logger.Fatal("There are no `server.sources` configured. If you have `server.root` configured, please update the config and add at least one `server.sources` with a `path` configured.")
|
||||
@@ -187,9 +328,9 @@ func setupSources(generate bool) {
|
||||
if err != nil {
|
||||
logger.Fatalf("error getting real path for source %v: %v", source.Path, err)
|
||||
}
|
||||
err = checkPathExists(realPath)
|
||||
if err != nil {
|
||||
logger.Warningf("error checking path exists: %v", err)
|
||||
exists := utils.CheckPathExists(realPath)
|
||||
if !exists {
|
||||
logger.Warningf("source path %v is currently not available", realPath)
|
||||
}
|
||||
name := filepath.Base(realPath)
|
||||
if name == "\\" {
|
||||
@@ -494,6 +635,9 @@ func setDefaults(generate bool) Settings {
|
||||
if database == "" {
|
||||
database = "database.db"
|
||||
}
|
||||
if _, err := os.Stat(database); os.IsNotExist(err) {
|
||||
logger.Warning("database file could not be found. If this is unexpected, please set the FILEBROWSER_DATABASE environment variable to the correct path.")
|
||||
}
|
||||
s := Settings{
|
||||
Server: Server{
|
||||
Port: 80,
|
||||
@@ -504,6 +648,12 @@ func setDefaults(generate bool) Settings {
|
||||
NameToSource: map[string]*Source{},
|
||||
MaxArchiveSizeGB: 50,
|
||||
CacheDir: "tmp",
|
||||
IndexSqlConfig: IndexSqlConfig{
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
},
|
||||
Filesystem: Filesystem{
|
||||
CreateFilePermission: "644",
|
||||
CreateDirectoryPermission: "755",
|
||||
@@ -523,229 +673,210 @@ func setDefaults(generate bool) Settings {
|
||||
Frontend: Frontend{
|
||||
Name: "FileBrowser Quantum",
|
||||
},
|
||||
|
||||
UserDefaults: UserDefaults{
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf",
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
LockPassword: false,
|
||||
ShowHidden: false,
|
||||
DarkMode: true,
|
||||
DarkMode: boolPtr(true),
|
||||
DisableSettings: false,
|
||||
ViewMode: "normal",
|
||||
Locale: "en",
|
||||
GallerySize: 3,
|
||||
ThemeColor: "var(--blue)",
|
||||
Permissions: users.Permissions{
|
||||
Modify: false,
|
||||
Share: false,
|
||||
Admin: false,
|
||||
Api: false,
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Modify: false,
|
||||
Share: false,
|
||||
Admin: false,
|
||||
Api: false,
|
||||
Download: boolPtr(true), // defaults to true
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
HighQuality: boolPtr(true),
|
||||
Image: boolPtr(true),
|
||||
Video: boolPtr(true),
|
||||
MotionVideoPreview: boolPtr(true),
|
||||
Office: boolPtr(true),
|
||||
PopUp: boolPtr(true),
|
||||
AutoplayMedia: boolPtr(true),
|
||||
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
|
||||
}
|
||||
|
||||
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
|
||||
if len(scopes) == 0 {
|
||||
return Config.UserDefaults.DefaultScopes, nil
|
||||
// validateCustomImage validates a custom image file path and returns the absolute path or error
|
||||
func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) {
|
||||
// Get absolute path
|
||||
absolutePath, err = filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not resolve path: %w", err)
|
||||
}
|
||||
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]
|
||||
if !ok {
|
||||
// source might no longer be configured
|
||||
continue
|
||||
}
|
||||
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,
|
||||
})
|
||||
// Check if file exists
|
||||
_, err = os.Stat(absolutePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not access file: %w", err)
|
||||
}
|
||||
return newScopes, 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,
|
||||
})
|
||||
// Validate file format
|
||||
ext := strings.ToLower(filepath.Ext(absolutePath))
|
||||
validFormat := false
|
||||
for _, format := range allowedFormats {
|
||||
if ext == format {
|
||||
validFormat = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return newScopes
|
||||
}
|
||||
|
||||
func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return true
|
||||
}
|
||||
if !validFormat {
|
||||
return "", fmt.Errorf("unsupported format '%s', supported formats: %v", ext, allowedFormats)
|
||||
}
|
||||
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 absolutePath, nil
|
||||
}
|
||||
|
||||
func loadCustomFavicon() {
|
||||
const imageName = "favicon"
|
||||
allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||
|
||||
// Set default embedded favicon path
|
||||
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
|
||||
|
||||
// Check if a custom favicon path is configured
|
||||
if Config.Frontend.Favicon == "" {
|
||||
logger.Debug("No custom favicon configured, using default")
|
||||
Env.FaviconPath = Env.FaviconEmbeddedPath
|
||||
Env.FaviconIsCustom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Get absolute path for the favicon
|
||||
faviconPath, err := filepath.Abs(Config.Frontend.Favicon)
|
||||
// Validate custom favicon
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats)
|
||||
if err != nil {
|
||||
logger.Warningf("Could not resolve favicon path '%v': %v", Config.Frontend.Favicon, err)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
logger.Warningf("Custom favicon validation failed: %v, using default", err)
|
||||
Config.Frontend.Favicon = ""
|
||||
Env.FaviconPath = Env.FaviconEmbeddedPath
|
||||
Env.FaviconIsCustom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the favicon file exists and get info
|
||||
stat, err := os.Stat(faviconPath)
|
||||
// Update to validated path and mark as custom
|
||||
Config.Frontend.Favicon = validatedPath
|
||||
Env.FaviconPath = validatedPath
|
||||
Env.FaviconIsCustom = true
|
||||
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
|
||||
}
|
||||
|
||||
func loadLoginIcon() {
|
||||
const imageName = "login icon"
|
||||
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
|
||||
|
||||
// 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 == "" {
|
||||
Env.LoginIconPath = Env.LoginIconEmbeddedPath
|
||||
Env.LoginIconIsCustom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Validate custom login icon
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats)
|
||||
if err != nil {
|
||||
logger.Warningf("Could not access custom favicon file '%v': %v", faviconPath, err)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
logger.Warningf("Custom login icon validation failed: %v, using default", err)
|
||||
Env.LoginIconPath = Env.LoginIconEmbeddedPath
|
||||
Env.LoginIconIsCustom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size (limit to 1MB for security)
|
||||
const maxFaviconSize = 1024 * 1024 // 1MB
|
||||
if stat.Size() > maxFaviconSize {
|
||||
logger.Warningf("Favicon file '%v' is too large (%d bytes), maximum allowed is %d bytes", faviconPath, stat.Size(), maxFaviconSize)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file format based on extension
|
||||
ext := strings.ToLower(filepath.Ext(faviconPath))
|
||||
switch ext {
|
||||
case ".ico", ".png", ".svg":
|
||||
// Valid favicon formats
|
||||
default:
|
||||
logger.Warningf("Unsupported favicon format '%v', supported formats: .ico, .png, .svg", ext)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
return
|
||||
}
|
||||
|
||||
// Update to absolute path and mark as valid
|
||||
Config.Frontend.Favicon = faviconPath
|
||||
|
||||
logger.Infof("Successfully validated custom favicon at '%v' (%d bytes, %s)", faviconPath, stat.Size(), ext)
|
||||
// Update to validated path and mark as custom
|
||||
Env.LoginIconPath = validatedPath
|
||||
Env.LoginIconIsCustom = true
|
||||
logger.Infof("Using custom login icon: %s", Env.LoginIconPath)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -770,8 +901,20 @@ func setConditionals(config *Source) {
|
||||
if rule.IncludeRootItem != "" {
|
||||
resolved.IncludeRootItems[rule.IncludeRootItem] = struct{}{}
|
||||
}
|
||||
if rule.FileNames != "" {
|
||||
resolved.FileNames[rule.FileNames] = rule
|
||||
}
|
||||
if rule.FolderNames != "" {
|
||||
resolved.FolderNames[rule.FolderNames] = rule
|
||||
}
|
||||
if rule.FileName != "" {
|
||||
resolved.FileNames[rule.FileName] = rule
|
||||
}
|
||||
if rule.FolderName != "" {
|
||||
resolved.FolderNames[rule.FolderName] = rule
|
||||
}
|
||||
}
|
||||
config.Config.ResolvedConditionals = resolved
|
||||
config.Config.ResolvedRules = resolved
|
||||
}
|
||||
|
||||
func modifyExcludeInclude(config *Source) {
|
||||
@@ -803,20 +946,40 @@ func modifyExcludeInclude(config *Source) {
|
||||
return strings.Trim(value, "/")
|
||||
}
|
||||
|
||||
// Normalize []ConditionalIndexConfig slices for full paths
|
||||
for i, rule := range config.Config.Conditionals.ItemRules {
|
||||
// Normalize []ConditionalRule slices for full paths
|
||||
// Handle both old format (Conditionals.ItemRules) and new format (Rules)
|
||||
allRules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...)
|
||||
|
||||
for i, rule := range allRules {
|
||||
// normalize full paths
|
||||
config.Config.Conditionals.ItemRules[i].FilePath = normalizeFullPath(rule.FilePath, true)
|
||||
config.Config.Conditionals.ItemRules[i].FolderPath = normalizeFullPath(rule.FolderPath, true)
|
||||
config.Config.Conditionals.ItemRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true)
|
||||
config.Config.Conditionals.ItemRules[i].IncludeRootItem = normalizeFullPath(rule.IncludeRootItem, true)
|
||||
allRules[i].FilePath = normalizeFullPath(rule.FilePath, true)
|
||||
// FolderPath gets trailing slash for proper prefix matching
|
||||
if rule.FolderPath != "" {
|
||||
normalized := normalizeFullPath(rule.FolderPath, true)
|
||||
if normalized != "/" && !strings.HasSuffix(normalized, "/") {
|
||||
normalized = normalized + "/"
|
||||
}
|
||||
allRules[i].FolderPath = normalized
|
||||
}
|
||||
allRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true)
|
||||
if rule.IncludeRootItem != "" {
|
||||
normalized := normalizeFullPath(rule.IncludeRootItem, true)
|
||||
if normalized != "/" && !strings.HasSuffix(normalized, "/") {
|
||||
normalized = normalized + "/"
|
||||
}
|
||||
allRules[i].IncludeRootItem = normalized
|
||||
}
|
||||
|
||||
// normalize names
|
||||
config.Config.Conditionals.ItemRules[i].FileNames = normalizeName(rule.FileNames)
|
||||
config.Config.Conditionals.ItemRules[i].FolderNames = normalizeName(rule.FolderNames)
|
||||
config.Config.Conditionals.ItemRules[i].FileName = normalizeName(rule.FileName)
|
||||
config.Config.Conditionals.ItemRules[i].FolderName = normalizeName(rule.FolderName)
|
||||
allRules[i].FileNames = normalizeName(rule.FileNames)
|
||||
allRules[i].FolderNames = normalizeName(rule.FolderNames)
|
||||
allRules[i].FileName = normalizeName(rule.FileName)
|
||||
allRules[i].FolderName = normalizeName(rule.FolderName)
|
||||
}
|
||||
|
||||
// Update the original slices with normalized values
|
||||
itemRulesLen := len(config.Config.Conditionals.ItemRules)
|
||||
config.Config.Conditionals.ItemRules = allRules[:itemRulesLen]
|
||||
config.Config.Rules = allRules[itemRulesLen:]
|
||||
|
||||
}
|
||||
@@ -1,9 +1,27 @@
|
||||
package settings
|
||||
|
||||
func CanConvertImage(ext string) bool {
|
||||
return Config.Integrations.Media.FfmpegPath != "" && 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 Config.Integrations.Media.FfmpegPath != "" && 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 {
|
||||
return Env.FFmpegPath != "" && Env.FFprobePath != ""
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
logger.Debug("Could not get scope from source name: ", sourceName)
|
||||
return "", nil
|
||||
}
|
||||
return source.Path, nil
|
||||
})
|
||||
|
||||
// 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 {
|
||||
return users.SourceInfo{}, false
|
||||
}
|
||||
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 users.SourceInfo{}, false
|
||||
}
|
||||
return users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
}, true
|
||||
},
|
||||
GetAllSources: func() []users.SourceInfo {
|
||||
sources := make([]users.SourceInfo, 0, len(Config.Server.Sources))
|
||||
for _, source := range Config.Server.Sources {
|
||||
sources = append(sources, users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
return sources
|
||||
},
|
||||
GetDefaultScopes: func() []users.SourceScope {
|
||||
return Config.UserDefaults.DefaultScopes
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -111,74 +112,93 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
|
||||
return comments, secrets, deprecated, nil
|
||||
}
|
||||
|
||||
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
|
||||
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
|
||||
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var files []*ast.File
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedSyntax | packages.NeedFiles,
|
||||
Dir: absDir,
|
||||
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
|
||||
return parser.ParseFile(fset, filename, src, parser.ParseComments)
|
||||
},
|
||||
}
|
||||
pkgs, err := packages.Load(cfg, ".")
|
||||
if err == nil && len(pkgs) > 0 {
|
||||
for _, pkg := range pkgs {
|
||||
files = append(files, pkg.Syntax...)
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
// Fallback for directories outside a module (e.g. test temp dirs): parse .go files directly
|
||||
files, err = parseDirGoFiles(absDir)
|
||||
if err != nil || len(files) == 0 {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
comments := make(CommentsMap)
|
||||
secrets := make(SecretFieldsMap)
|
||||
deprecated := make(DeprecatedFieldsMap)
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Files {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
for _, file := range files {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
ts, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
ts, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
st, ok := ts.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeName := ts.Name.Name
|
||||
commentMap := make(map[string]string)
|
||||
secretMap := make(map[string]bool)
|
||||
deprecatedMap := make(map[string]bool)
|
||||
comments[typeName] = commentMap
|
||||
secrets[typeName] = secretMap
|
||||
deprecated[typeName] = deprecatedMap
|
||||
|
||||
for _, field := range st.Fields.List {
|
||||
if len(field.Names) == 0 {
|
||||
continue
|
||||
}
|
||||
st, ok := ts.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
name := field.Names[0].Name
|
||||
var parts []string
|
||||
var fullComment string
|
||||
|
||||
if field.Doc != nil {
|
||||
docText := strings.TrimSpace(field.Doc.Text())
|
||||
parts = append(parts, docText)
|
||||
fullComment += docText + " "
|
||||
}
|
||||
if field.Comment != nil {
|
||||
commentText := strings.TrimSpace(field.Comment.Text())
|
||||
parts = append(parts, commentText)
|
||||
fullComment += commentText
|
||||
}
|
||||
typeName := ts.Name.Name
|
||||
commentMap := make(map[string]string)
|
||||
secretMap := make(map[string]bool)
|
||||
deprecatedMap := make(map[string]bool)
|
||||
comments[typeName] = commentMap
|
||||
secrets[typeName] = secretMap
|
||||
deprecated[typeName] = deprecatedMap
|
||||
|
||||
for _, field := range st.Fields.List {
|
||||
if len(field.Names) == 0 {
|
||||
continue
|
||||
}
|
||||
name := field.Names[0].Name
|
||||
var parts []string
|
||||
var fullComment string
|
||||
if len(parts) > 0 {
|
||||
commentMap[name] = strings.Join(parts, " : ")
|
||||
}
|
||||
|
||||
if field.Doc != nil {
|
||||
docText := strings.TrimSpace(field.Doc.Text())
|
||||
parts = append(parts, docText)
|
||||
fullComment += docText + " "
|
||||
}
|
||||
if field.Comment != nil {
|
||||
commentText := strings.TrimSpace(field.Comment.Text())
|
||||
parts = append(parts, commentText)
|
||||
fullComment += commentText
|
||||
}
|
||||
// Check if field should be treated as secret
|
||||
if strings.Contains(strings.ToLower(fullComment), "secret:") {
|
||||
secretMap[name] = true
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
commentMap[name] = strings.Join(parts, " : ")
|
||||
}
|
||||
|
||||
// Check if field should be treated as secret
|
||||
if strings.Contains(strings.ToLower(fullComment), "secret:") {
|
||||
secretMap[name] = true
|
||||
}
|
||||
|
||||
// Check if field should be treated as deprecated
|
||||
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
|
||||
deprecatedMap[name] = true
|
||||
}
|
||||
// Check if field should be treated as deprecated
|
||||
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
|
||||
deprecatedMap[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +207,29 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
|
||||
return comments, secrets, deprecated, nil
|
||||
}
|
||||
|
||||
// parseDirGoFiles parses all .go files in dir and returns their ASTs. Used when the directory
|
||||
// is not inside a Go module (e.g. test temp dirs) so packages.Load would fail.
|
||||
func parseDirGoFiles(dir string) ([]*ast.File, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
var files []*ast.File
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// PathToTypeField represents a mapping from YAML path to Go type and field info
|
||||
type PathToTypeField struct {
|
||||
TypeName string
|
||||
@@ -577,7 +620,8 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
|
||||
// Check if this field should be redacted as a secret
|
||||
var valNode *yaml.Node
|
||||
var err error
|
||||
if secrets[typeName][sf.Name] {
|
||||
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
|
||||
if secrets[typeName][sf.Name] && !generateConfig {
|
||||
// Check if the secret value is empty
|
||||
fieldValue := currentField.Interface()
|
||||
if str, ok := fieldValue.(string); ok && str == "" {
|
||||
@@ -648,11 +692,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))
|
||||
@@ -706,16 +745,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)
|
||||
@@ -726,7 +764,7 @@ func GenerateYaml() {
|
||||
|
||||
setupSources(true)
|
||||
setupUrls()
|
||||
setupVideoPreview()
|
||||
setupMedia()
|
||||
setupFrontend(true)
|
||||
|
||||
// Restore original paths so the YAML output has the correct paths, not the placeholder
|
||||
@@ -1135,53 +1173,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
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
func TestGenerateConfigYaml_Basic(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Test using the actual source directory structure
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default value
|
||||
DarkMode: true, // Default value
|
||||
DarkMode: &trueVal, // Default value
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This field is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
@@ -104,6 +105,11 @@ func TestGenerateConfigYaml_Basic(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
|
||||
if value == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
// Add debug info to see what's wrong
|
||||
|
||||
@@ -108,6 +108,11 @@ type Auth struct {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
|
||||
if value == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
t.Errorf("String value should be quoted: %s (value: '%s')", line, value)
|
||||
@@ -204,10 +209,11 @@ type UserDefaults struct {
|
||||
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
|
||||
|
||||
// Create a config with both default and non-default values
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
DarkMode: true, // This matches default
|
||||
Locale: "es", // This differs from default ("en")
|
||||
DarkMode: &trueVal, // This matches default
|
||||
Locale: "es", // This differs from default ("en")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -278,10 +284,11 @@ func TestGenerateConfigYaml_CommentsOnOff(t *testing.T) {
|
||||
func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Comprehensive test with all features
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default
|
||||
DarkMode: true, // Default
|
||||
DarkMode: &trueVal, // Default
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
@@ -400,8 +407,8 @@ func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip arrays, objects, and empty values
|
||||
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
|
||||
// Skip arrays, objects, empty values, and null
|
||||
if value == "" || value == "null" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -433,8 +440,8 @@ func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
|
||||
name: "only_defaults",
|
||||
config: &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
DarkMode: true, // default value
|
||||
Locale: "en", // default value
|
||||
DarkMode: boolPtr(true), // default value
|
||||
Locale: "en", // default value
|
||||
},
|
||||
},
|
||||
desc: "Config with only default values",
|
||||
|
||||
@@ -214,7 +214,7 @@ frontend:
|
||||
}
|
||||
|
||||
// Verify user defaults
|
||||
if Config.UserDefaults.DarkMode != true {
|
||||
if Config.UserDefaults.DarkMode == nil || !*Config.UserDefaults.DarkMode {
|
||||
t.Errorf("Expected dark mode true, got %v", Config.UserDefaults.DarkMode)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,33 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// boolPtr returns a pointer to a bool value
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
// boolValueOrDefault returns the value of a bool pointer, or the default if nil
|
||||
func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
|
||||
if ptr != nil {
|
||||
return *ptr
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
|
||||
func ConvertPermissionsToUsers(p UserDefaultsPermissions) users.Permissions {
|
||||
return users.Permissions{
|
||||
Api: p.Api,
|
||||
Admin: p.Admin,
|
||||
Modify: p.Modify,
|
||||
Share: p.Share,
|
||||
Realtime: p.Realtime,
|
||||
Delete: p.Delete,
|
||||
Create: p.Create,
|
||||
Download: boolValueOrDefault(p.Download, true),
|
||||
}
|
||||
}
|
||||
|
||||
const DefaultUsersHomeBasePath = "/users"
|
||||
|
||||
// AuthMethod describes an authentication method.
|
||||
@@ -41,12 +68,36 @@ func AdminPerms() users.Permissions {
|
||||
func ApplyUserDefaults(u *users.User) {
|
||||
u.StickySidebar = Config.UserDefaults.StickySidebar
|
||||
u.DisableSettings = Config.UserDefaults.DisableSettings
|
||||
u.DarkMode = Config.UserDefaults.DarkMode
|
||||
|
||||
// Handle DarkMode with default - dereference pointer from config
|
||||
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
|
||||
|
||||
u.Locale = Config.UserDefaults.Locale
|
||||
u.ViewMode = Config.UserDefaults.ViewMode
|
||||
u.SingleClick = Config.UserDefaults.SingleClick
|
||||
u.Permissions = Config.UserDefaults.Permissions
|
||||
u.Preview = Config.UserDefaults.Preview
|
||||
|
||||
// Handle Permissions - convert from pointer-based defaults to regular bools
|
||||
u.Permissions.Api = Config.UserDefaults.Permissions.Api
|
||||
u.Permissions.Admin = Config.UserDefaults.Permissions.Admin
|
||||
u.Permissions.Modify = Config.UserDefaults.Permissions.Modify
|
||||
u.Permissions.Share = Config.UserDefaults.Permissions.Share
|
||||
u.Permissions.Realtime = Config.UserDefaults.Permissions.Realtime
|
||||
u.Permissions.Delete = Config.UserDefaults.Permissions.Delete
|
||||
u.Permissions.Create = Config.UserDefaults.Permissions.Create
|
||||
u.Permissions.Download = boolValueOrDefault(Config.UserDefaults.Permissions.Download, true)
|
||||
|
||||
// Handle Preview - convert from pointer-based defaults to regular bools
|
||||
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
|
||||
u.Preview.HighQuality = boolValueOrDefault(Config.UserDefaults.Preview.HighQuality, true)
|
||||
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
|
||||
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
|
||||
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
|
||||
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
|
||||
|
||||
u.ShowHidden = Config.UserDefaults.ShowHidden
|
||||
u.DateFormat = Config.UserDefaults.DateFormat
|
||||
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
type AllowedMethods string
|
||||
|
||||
var Env Environment
|
||||
|
||||
const (
|
||||
ProxyAuth AllowedMethods = "proxyAuth"
|
||||
NoAuth AllowedMethods = "noAuth"
|
||||
@@ -20,31 +22,53 @@ type Settings struct {
|
||||
Integrations Integrations `json:"integrations"`
|
||||
}
|
||||
|
||||
type Environment struct {
|
||||
IsPlaywright bool `json:"-"`
|
||||
IsDevMode bool `json:"-"`
|
||||
IsFirstLoad bool `json:"-"` // used internally to track if this is the first load of the application
|
||||
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
|
||||
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
|
||||
FFmpegPath string `json:"-"`
|
||||
FFprobePath string `json:"-"`
|
||||
LoginIconPath string `json:"-"` // resolved login icon path (filesystem or embedded)
|
||||
LoginIconIsCustom bool `json:"-"` // true if login icon is from custom filesystem path
|
||||
LoginIconEmbeddedPath string `json:"-"` // embedded asset path for default icon
|
||||
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
|
||||
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
|
||||
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
|
||||
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
|
||||
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
|
||||
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
|
||||
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
|
||||
}
|
||||
|
||||
type Filesystem struct {
|
||||
@@ -52,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"`
|
||||
@@ -74,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"
|
||||
)
|
||||
|
||||
@@ -92,6 +124,7 @@ func (i ImagePreviewType) String() string {
|
||||
// AllImagePreviewTypes contains all supported image preview types.
|
||||
var AllImagePreviewTypes = []ImagePreviewType{
|
||||
HEICImagePreview,
|
||||
JPEGImagePreview,
|
||||
//RAWImagePreview,
|
||||
}
|
||||
|
||||
@@ -163,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 manual overide interval in minutes to re-index the source
|
||||
DisableIndexing bool `json:"disableIndexing,omitempty"` // disable the indexing of this source
|
||||
Conditionals ConditionalFilter `json:"conditionals"` // conditional rules to apply when indexing to include/exclude certain items
|
||||
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
|
||||
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
|
||||
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-"
|
||||
@@ -194,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 {
|
||||
@@ -226,8 +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
|
||||
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 {
|
||||
@@ -253,38 +298,63 @@ type ExternalLink struct {
|
||||
Url string `json:"url" validate:"required"` // the url to link to
|
||||
}
|
||||
|
||||
// UserDefaultsPreview holds preview settings with pointer types for defaults
|
||||
type UserDefaultsPreview struct {
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
|
||||
HighQuality *bool `json:"highQuality"` // use high quality thumbnails
|
||||
Image *bool `json:"image"` // show thumbnails for image files
|
||||
Video *bool `json:"video"` // show thumbnails for video files
|
||||
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
Office *bool `json:"office"` // show thumbnails for office files
|
||||
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
|
||||
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
|
||||
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
|
||||
}
|
||||
|
||||
// UserDefaultsPermissions holds permission settings with pointer types for defaults
|
||||
type UserDefaultsPermissions struct {
|
||||
Api bool `json:"api"` // allow api access
|
||||
Admin bool `json:"admin"` // allow admin access
|
||||
Modify bool `json:"modify"` // allow modifying files
|
||||
Share bool `json:"share"` // allow sharing files
|
||||
Realtime bool `json:"realtime"` // allow realtime updates
|
||||
Delete bool `json:"delete"` // allow deleting files
|
||||
Create bool `json:"create"` // allow creating or uploading files
|
||||
Download *bool `json:"download"` // allow downloading files
|
||||
}
|
||||
|
||||
// UserDefaults is a type that holds the default values
|
||||
// for some fields on User.
|
||||
type UserDefaults struct {
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
|
||||
Preview users.Preview `json:"preview"`
|
||||
DefaultScopes []users.SourceScope `json:"-"`
|
||||
Permissions users.Permissions `json:"permissions"`
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
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"` // default landing page to use if no redirect is specified: eg. /files/mysource/mysubpath, /settings, etc.
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
|
||||
Preview UserDefaultsPreview `json:"preview"`
|
||||
DefaultScopes []users.SourceScope `json:"-"`
|
||||
Permissions UserDefaultsPermissions `json:"permissions"`
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -83,31 +85,36 @@ func addCustomTheme(name, description, cssFilePath string) {
|
||||
// Load CSS content for runtime use (frontend consumption)
|
||||
cssContent := ""
|
||||
if cssFilePath != "" {
|
||||
cssContent = readCustomCSS(cssFilePath)
|
||||
var err error
|
||||
cssContent, err = readCustomCSS(cssFilePath)
|
||||
if err != nil {
|
||||
logger.Warning(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
Config.Frontend.Styling.CustomThemeOptions[name] = CustomTheme{
|
||||
Description: description,
|
||||
CSS: cssFilePath,
|
||||
CssRaw: cssContent, // Store loaded content
|
||||
}
|
||||
logger.Debugf("Added custom theme: %s", name)
|
||||
}
|
||||
|
||||
func readCustomCSS(path string) string {
|
||||
func readCustomCSS(path string) (string, error) {
|
||||
if path == "" {
|
||||
return ""
|
||||
return "", errors.New("path is empty")
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// Only log if the file exists but cannot be read (e.g., permission error)
|
||||
if !os.IsNotExist(err) {
|
||||
logger.Warningf("Could not read custom CSS file %s: %v", path, err)
|
||||
return ""
|
||||
}
|
||||
return "", fmt.Errorf("could not read custom CSS file: %v", err)
|
||||
}
|
||||
if len(content) > 128*1024 {
|
||||
logger.Warningf("Custom CSS file %s is too large (%d bytes), ignoring", path, len(content))
|
||||
return ""
|
||||
return "", fmt.Errorf("custom CSS file is too large (%d bytes)", len(content))
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return "", fmt.Errorf("custom CSS file is empty: %s", path)
|
||||
}
|
||||
logger.Debugf("Loaded custom CSS from: %s (%d bytes)", path, len(content))
|
||||
return string(content)
|
||||
return string(content), nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -30,6 +31,10 @@ func CheckForUpdates() (updateInfo, error) {
|
||||
repoOwner := "gtsteffaniak"
|
||||
repoName := "filebrowser"
|
||||
currentVersion := version.Version
|
||||
isDevMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
if currentVersion == "untracked" || currentVersion == "testing" || currentVersion == "" || isDevMode {
|
||||
return updateInfo{}, nil
|
||||
}
|
||||
splitVersion := strings.Split(currentVersion, "-")
|
||||
versionCategory := "stable"
|
||||
if len(splitVersion) > 1 {
|
||||
@@ -51,13 +56,13 @@ func CheckForUpdates() (updateInfo, error) {
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
|
||||
return updateInfo{}, err
|
||||
}
|
||||
// Find the latest beta version greater than the current one
|
||||
// Find the latest version greater than the current one that matches the version category
|
||||
var NewVersion string
|
||||
for _, tag := range tags {
|
||||
// Check if the version is valid, is a pre-release, and contains "beta"
|
||||
// Check if the version is valid and the prerelease contains the version category
|
||||
if semver.IsValid(tag.Name) && strings.Contains(semver.Prerelease(tag.Name), versionCategory) {
|
||||
// Check if this tag is greater than the current version
|
||||
// and also greater than any other beta version we've found so far
|
||||
// and also greater than any other version we've found so far
|
||||
if semver.Compare(tag.Name, currentVersion) > 0 {
|
||||
if NewVersion == "" || semver.Compare(tag.Name, NewVersion) > 0 {
|
||||
NewVersion = tag.Name
|
||||
|
||||
@@ -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,14 +47,35 @@ 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 := filepath.Clean(userPath)
|
||||
|
||||
// Split the path into segments to check for path traversal attempts
|
||||
// We check if ".." appears as a complete path segment (not just in a filename)
|
||||
segments := strings.Split(clean, string(filepath.Separator))
|
||||
|
||||
for _, segment := range segments {
|
||||
// Check if any segment is exactly ".." (path traversal attempt)
|
||||
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
|
||||
if segment == ".." {
|
||||
return "", fmt.Errorf("invalid path: path traversal detected")
|
||||
}
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
math "math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -128,3 +130,37 @@ 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 "/"
|
||||
if indexPath == "/" {
|
||||
return "/"
|
||||
}
|
||||
// For all other paths, ensure they have trailing slashes
|
||||
if !strings.HasSuffix(indexPath, "/") {
|
||||
return indexPath + "/"
|
||||
}
|
||||
return indexPath
|
||||
}
|
||||
|
||||
func CheckPathExists(realPath string) bool {
|
||||
if _, err := os.Stat(realPath); os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
type SubtitleTrack struct {
|
||||
Name string `json:"name"` // filename for external, or descriptive name for embedded
|
||||
Language string `json:"language"` // language code
|
||||
Title string `json:"title,omitempty"` // title/description
|
||||
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
|
||||
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
|
||||
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
|
||||
}
|
||||
|
||||
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
|
||||
// validate the size is not too large
|
||||
info, err := os.Stat(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Size() > 1024*1024*50 { // 50MB
|
||||
return "", errors.New("subtitle file is too large")
|
||||
}
|
||||
|
||||
// First check if it's a text file using the consolidated validation logic
|
||||
isText, err := IsTextFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isText {
|
||||
// Not a text file, return empty string (no error, just not text)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// read the file content
|
||||
content, err := os.ReadFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// IsTextFile checks if a file is viewable/editable as text (like with cat or a text editor).
|
||||
// It returns true if the file appears to be text, false if it's likely binary.
|
||||
// This function uses simple heuristics:
|
||||
// - Valid UTF-8 encoding
|
||||
// - Not full of null bytes (which indicate binary data)
|
||||
func IsTextFile(realPath string) (bool, error) {
|
||||
const sampleSize = 8192 // Read first 8KB to check
|
||||
const maxNullByteRatio = 0.05 // Reject if more than 5% null bytes
|
||||
// Read sample from file
|
||||
content, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Empty files are considered text
|
||||
if len(content) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
// Use sample for large files to avoid reading entire file into memory
|
||||
sample := content
|
||||
if len(content) > sampleSize {
|
||||
sample = content[:sampleSize]
|
||||
}
|
||||
// Check 1: Count null bytes - binary files typically have many nulls
|
||||
nullCount := 0
|
||||
for _, b := range sample {
|
||||
if b == 0x00 {
|
||||
nullCount++
|
||||
}
|
||||
}
|
||||
|
||||
if nullCount > 0 {
|
||||
nullRatio := float64(nullCount) / float64(len(sample))
|
||||
if nullRatio > maxNullByteRatio {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2: Validate UTF-8 encoding
|
||||
// Trim sample to last complete UTF-8 rune to avoid false negatives
|
||||
trimmedSample := sample
|
||||
for len(trimmedSample) > 0 {
|
||||
lastRune, size := utf8.DecodeLastRune(trimmedSample)
|
||||
if lastRune != utf8.RuneError {
|
||||
break
|
||||
}
|
||||
if size == 1 {
|
||||
trimmedSample = trimmedSample[:len(trimmedSample)-1]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(trimmedSample) > 0 && !utf8.Valid(trimmedSample) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
+15
-14
@@ -11,7 +11,7 @@ userDefaults:
|
||||
disableQuickToggles: false # disable the quick toggles in the sidebar
|
||||
disableSearchOptions: false # disable the search options in the search bar
|
||||
stickySidebar: true # keep sidebar open when navigating
|
||||
darkMode: true # should dark mode be enabled
|
||||
darkMode: true # should dark mode be enabled (defaults to true if not set)
|
||||
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
|
||||
singleClick: false # open directory on single click, also enables middle click to open in new tab
|
||||
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
@@ -24,16 +24,16 @@ userDefaults:
|
||||
lockPassword: false # disable the user from changing their password
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
preview:
|
||||
disableHideSidebar: true # disable the hide sidebar preview for previews and editors
|
||||
highQuality: true # generate high quality thumbnail preview images
|
||||
image: true # show thumbnail preview image for image files
|
||||
video: true # show thumbnail preview image for video files
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
|
||||
office: true # show thumbnail preview image for office files
|
||||
popup: true # show larger popup preview when hovering over thumbnail
|
||||
autoplayMedia: true # autoplay media files in preview
|
||||
defaultMediaPlayer: true # disable html5 media player and use the default media player
|
||||
folder: true # show thumbnail preview image for folder files
|
||||
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
|
||||
highQuality: true # generate high quality thumbnail preview images (defaults to true if not set)
|
||||
image: true # show thumbnail preview image for image files (defaults to true if not set)
|
||||
video: true # show thumbnail preview image for video files (defaults to true if not set)
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
|
||||
office: true # show thumbnail preview image for office files (defaults to true if not set)
|
||||
popup: true # show larger popup preview when hovering over thumbnail (defaults to true if not set)
|
||||
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
|
||||
defaultMediaPlayer: false # disable html5 media player and use the default media player
|
||||
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
admin: false # allow admin access
|
||||
@@ -42,9 +42,10 @@ userDefaults:
|
||||
realtime: false # allow realtime updates
|
||||
delete: false # allow deleting files
|
||||
create: false # allow creating or uploading files
|
||||
download: true # allow downloading files
|
||||
download: true # allow downloading files (defaults to true if not set)
|
||||
disableUpdateNotifications: false # disable update notifications banner for admin users
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
disableOnlyOfficeExt: ".md .txt .pdf" # list of file extensions to disable onlyoffice editor for
|
||||
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config.
|
||||
showSelectMultiple: false # show select multiple files on desktop
|
||||
defaultLandingPage: "" # default landing page to use if no redirect is specified: eg. /files/mysource/mysubpath, /settings, etc.
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
+234
-114
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -63,6 +64,7 @@ type FrontendAccessRule struct {
|
||||
Deny FrontendRuleSet `json:"deny"`
|
||||
Allow FrontendRuleSet `json:"allow"`
|
||||
SourceDenyDefault bool `json:"sourceDenyDefault"`
|
||||
PathExists bool `json:"pathExists"`
|
||||
}
|
||||
|
||||
// GroupMap maps group names to a set of usernames.
|
||||
@@ -151,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
|
||||
@@ -178,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)
|
||||
@@ -197,24 +186,11 @@ func (s *Storage) RemoveRuleByPath(sourcePath, indexPath string) {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeRulePath ensures directory paths have trailing slashes for consistent rule storage
|
||||
func normalizeRulePath(indexPath string) string {
|
||||
// Root path stays as "/"
|
||||
if indexPath == "/" {
|
||||
return "/"
|
||||
}
|
||||
// For all other paths, ensure they have trailing slashes
|
||||
if !strings.HasSuffix(indexPath, "/") {
|
||||
return indexPath + "/"
|
||||
}
|
||||
return indexPath
|
||||
}
|
||||
|
||||
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
|
||||
// The caller must hold the lock.
|
||||
func (s *Storage) getOrCreateRuleNL(sourcePath, indexPath string) *AccessRule {
|
||||
// Normalize the path to ensure consistent rule storage
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
if _, ok := s.AllRules[sourcePath]; !ok {
|
||||
s.AllRules[sourcePath] = make(RuleMap)
|
||||
}
|
||||
@@ -244,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()
|
||||
}
|
||||
|
||||
@@ -265,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()
|
||||
}
|
||||
|
||||
@@ -284,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()
|
||||
}
|
||||
|
||||
@@ -303,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()
|
||||
}
|
||||
|
||||
@@ -318,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
|
||||
@@ -412,7 +378,7 @@ func (s *Storage) getRuleAtExactPath(sourcePath, indexPath string) (*AccessRule,
|
||||
return nil, false
|
||||
}
|
||||
// Normalize the path to ensure consistent rule lookup
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := rulesBySource[normalizedPath]
|
||||
return rule, ok
|
||||
}
|
||||
@@ -471,8 +437,12 @@ func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccess
|
||||
sourceDenyDefault = sourceInfo.Config.DenyByDefault
|
||||
}
|
||||
|
||||
// Check if path exists on filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
|
||||
|
||||
frontendRules := FrontendAccessRule{
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
PathExists: pathExists,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: make([]string, 0),
|
||||
Groups: make([]string, 0),
|
||||
@@ -532,10 +502,14 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
|
||||
// This ensures consistency between internal storage and frontend display
|
||||
frontendPath := indexPath
|
||||
|
||||
// Check if path exists on filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
|
||||
|
||||
// Convert AccessRule to FrontendAccessRule
|
||||
frontendRules[frontendPath] = FrontendAccessRule{
|
||||
DenyAll: rule.DenyAll,
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
PathExists: pathExists,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
|
||||
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
|
||||
@@ -653,7 +627,7 @@ func (s *Storage) RemoveUserFromGroup(group, username string) error {
|
||||
func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -662,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)
|
||||
@@ -675,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
|
||||
@@ -686,7 +655,7 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
|
||||
func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -695,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)
|
||||
@@ -708,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
|
||||
@@ -719,7 +683,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
|
||||
func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -728,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)
|
||||
@@ -741,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
|
||||
@@ -752,7 +711,7 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
|
||||
func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -761,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)
|
||||
@@ -774,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
|
||||
@@ -785,7 +739,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
|
||||
func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -793,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
|
||||
@@ -804,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
|
||||
@@ -837,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
|
||||
@@ -874,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
|
||||
@@ -1087,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
|
||||
@@ -1116,3 +1046,193 @@ func (s *Storage) HasAnyVisibleItems(sourcePath, parentPath string, itemNames []
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RemoveUserCascade removes a user from either the allow or deny list for a given path and all its subpaths.
|
||||
// This is used for cascade delete operations when deleting user access from a directory tree.
|
||||
// The allow parameter determines which list to remove from: true for allow list, false for deny list.
|
||||
func (s *Storage) RemoveUserCascade(sourcePath, indexPath, username string, allow bool) (int, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
removedCount := 0
|
||||
|
||||
// Iterate through all rules for this source
|
||||
for rulePath, rule := range rulesBySource {
|
||||
// Check if this rule path matches or is a subpath of the target path
|
||||
if rulePath == normalizedPath || strings.HasPrefix(rulePath, normalizedPath) {
|
||||
if allow {
|
||||
// Remove user from allow list only
|
||||
if _, exists := rule.Allow.Users[username]; exists {
|
||||
delete(rule.Allow.Users, username)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
} else {
|
||||
// Remove user from deny list only
|
||||
if _, exists := rule.Deny.Users[username]; exists {
|
||||
delete(rule.Deny.Users, username)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// If rule is now empty, mark it for deletion
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
|
||||
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.clearAllCaches()
|
||||
return removedCount, s.SaveToDB()
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// RemoveGroupCascade removes a group from either the allow or deny list for a given path and all its subpaths.
|
||||
// This is used for cascade delete operations when deleting group access from a directory tree.
|
||||
// The allow parameter determines which list to remove from: true for allow list, false for deny list.
|
||||
func (s *Storage) RemoveGroupCascade(sourcePath, indexPath, groupname string, allow bool) (int, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
removedCount := 0
|
||||
|
||||
// Iterate through all rules for this source
|
||||
for rulePath, rule := range rulesBySource {
|
||||
// Check if this rule path matches or is a subpath of the target path
|
||||
if rulePath == normalizedPath || strings.HasPrefix(rulePath, normalizedPath) {
|
||||
if allow {
|
||||
// Remove group from allow list only
|
||||
if _, exists := rule.Allow.Groups[groupname]; exists {
|
||||
delete(rule.Allow.Groups, groupname)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
} else {
|
||||
// Remove group from deny list only
|
||||
if _, exists := rule.Deny.Groups[groupname]; exists {
|
||||
delete(rule.Deny.Groups, groupname)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// If rule is now empty, mark it for deletion
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
|
||||
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.clearAllCaches()
|
||||
return removedCount, s.SaveToDB()
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// UpdateRules updates all access rules that match oldPath to point to newPath.
|
||||
// Handles both exact matches and subdirectories. Similar to share.Storage.UpdateShares.
|
||||
func (s *Storage) UpdateRules(sourcePath, oldPath, newPath string) (int, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil // No rules for this source, not an error
|
||||
}
|
||||
|
||||
// Normalize paths (always add trailing slash for access rules)
|
||||
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
newPath = utils.AddTrailingSlashIfNotExists(newPath)
|
||||
|
||||
updated := 0
|
||||
rulesToUpdate := make(map[string]string) // old path -> new path
|
||||
|
||||
// Find all rules that need to be updated
|
||||
for rulePath := range rulesBySource {
|
||||
if rulePath == oldPath {
|
||||
// Exact match
|
||||
rulesToUpdate[rulePath] = newPath
|
||||
} else if strings.HasPrefix(rulePath, oldPath) {
|
||||
// Subdirectory - replace prefix
|
||||
newRulePath := newPath + strings.TrimPrefix(rulePath, oldPath)
|
||||
rulesToUpdate[rulePath] = newRulePath
|
||||
}
|
||||
}
|
||||
|
||||
// Update all matched rules
|
||||
for oldRulePath, newRulePath := range rulesToUpdate {
|
||||
rule := rulesBySource[oldRulePath]
|
||||
delete(rulesBySource, oldRulePath)
|
||||
rulesBySource[newRulePath] = rule
|
||||
logger.Info("access rule updated", "source", sourcePath, "fromPath", oldRulePath, "toPath", newRulePath)
|
||||
updated++
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
s.clearAllCaches()
|
||||
if err := s.SaveToDB(); err != nil {
|
||||
return updated, err
|
||||
}
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// UpdateRulePath updates the path for a specific access rule (used by PATCH API endpoint).
|
||||
func (s *Storage) UpdateRulePath(sourcePath, oldPath, newPath string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
// Normalize paths
|
||||
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
newPath = utils.AddTrailingSlashIfNotExists(newPath)
|
||||
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return fmt.Errorf("no rules found for source: %s", sourcePath)
|
||||
}
|
||||
|
||||
rule, ok := rulesBySource[oldPath]
|
||||
if !ok {
|
||||
return fmt.Errorf("no rule found for path: %s", oldPath)
|
||||
}
|
||||
|
||||
// Remove the old rule and add it with the new path
|
||||
delete(rulesBySource, oldPath)
|
||||
rulesBySource[newPath] = rule
|
||||
s.clearAllCaches()
|
||||
logger.Debugf("access rule path updated: source=%s, fromPath=%s, toPath=%s", sourcePath, oldPath, newPath)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
@@ -38,17 +38,32 @@ func setupTestSources() {
|
||||
Path: "mnt/storage",
|
||||
Name: "storage",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false,
|
||||
DenyByDefault: false,
|
||||
DefaultUserScope: "/",
|
||||
},
|
||||
},
|
||||
"mnt/open": {
|
||||
Path: "mnt/open",
|
||||
Name: "open",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false,
|
||||
DenyByDefault: false,
|
||||
DefaultUserScope: "/",
|
||||
},
|
||||
},
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"storage": settings.Config.Server.SourceMap["mnt/storage"],
|
||||
"open": settings.Config.Server.SourceMap["mnt/open"],
|
||||
}
|
||||
settings.Config.Server.Sources = []*settings.Source{
|
||||
settings.Config.Server.SourceMap["mnt/storage"],
|
||||
settings.Config.Server.SourceMap["mnt/open"],
|
||||
}
|
||||
settings.Config.UserDefaults.DefaultScopes = []users.SourceScope{
|
||||
{Name: "mnt/storage", Scope: "/"},
|
||||
}
|
||||
// Initialize user package resolvers
|
||||
settings.InitializeUserResolvers()
|
||||
}
|
||||
|
||||
func TestPermitted_UserBlacklist(t *testing.T) {
|
||||
@@ -1203,3 +1218,513 @@ func TestHasAnyVisibleItems(t *testing.T) {
|
||||
|
||||
t.Log("✓ HasAnyVisibleItems correctly checks access permissions for items")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_OnlyRemovesSpecificList tests that cascade delete only removes from the specified list
|
||||
func TestRemoveUserCascade_OnlyRemovesSpecificList(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up rules: alice has both allow and deny rules on different paths
|
||||
if err = s.AllowUser("mnt/storage", "/docs/", "alice"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/docs/public/", "alice"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/docs/", "alice"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/docs/private/", "alice"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only allow rules
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/docs/", "alice", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 allow rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify allow rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/docs/")
|
||||
if ok && len(rule.Allow.Users) != 0 {
|
||||
t.Error("Allow rule should be removed from /docs/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/docs/public/")
|
||||
if ok && len(rule.Allow.Users) != 0 {
|
||||
t.Error("Allow rule should be removed from /docs/public/")
|
||||
}
|
||||
|
||||
// Verify deny rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/docs/")
|
||||
if !ok || len(rule.Deny.Users) == 0 {
|
||||
t.Error("Deny rule should still exist on /docs/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/docs/private/")
|
||||
if !ok || len(rule.Deny.Users) == 0 {
|
||||
t.Error("Deny rule should still exist on /docs/private/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes only allow rules, leaving deny rules intact")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_DenyRules tests cascade delete for deny rules specifically
|
||||
func TestRemoveUserCascade_DenyRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "bob")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up both allow and deny rules
|
||||
if err = s.AllowUser("mnt/storage", "/projects/", "bob"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/projects/team/", "bob"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/projects/", "bob"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/projects/secret/", "bob"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only deny rules
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/projects/", "bob", false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 deny rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify deny rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/projects/")
|
||||
if !ok || len(rule.Deny.Users) != 0 {
|
||||
t.Error("Deny rule should be removed from /projects/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/projects/secret/")
|
||||
if ok && len(rule.Deny.Users) != 0 {
|
||||
t.Error("Deny rule should be removed from /projects/secret/")
|
||||
}
|
||||
|
||||
// Verify allow rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/projects/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Allow rule should still exist on /projects/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/projects/team/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Allow rule should still exist on /projects/team/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes only deny rules, leaving allow rules intact")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_MultipleSubpaths tests cascade delete across multiple subpath levels
|
||||
func TestRemoveUserCascade_MultipleSubpaths(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "carol")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up a deep hierarchy of allow rules
|
||||
paths := []string{
|
||||
"/data/",
|
||||
"/data/2024/",
|
||||
"/data/2024/q1/",
|
||||
"/data/2024/q1/jan/",
|
||||
"/data/2024/q1/feb/",
|
||||
"/data/2024/q2/",
|
||||
"/data/2024/q2/apr/",
|
||||
"/data/2025/",
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if err = s.AllowUser("mnt/storage", path, "carol"); err != nil {
|
||||
t.Fatalf("AllowUser failed for %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cascade delete from /data/2024/ should remove all 2024 subpaths
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/data/2024/", "carol", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 6 {
|
||||
t.Errorf("Expected 6 rules removed (2024 + 5 subpaths), got %d", count)
|
||||
}
|
||||
|
||||
// Verify 2024 paths are gone
|
||||
for _, path := range []string{"/data/2024/", "/data/2024/q1/", "/data/2024/q1/jan/", "/data/2024/q1/feb/", "/data/2024/q2/", "/data/2024/q2/apr/"} {
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", path)
|
||||
if ok && len(rule.Allow.Users) > 0 {
|
||||
t.Errorf("Rule should be removed from %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify other paths still exist
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/data/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Rule should still exist on /data/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/data/2025/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Rule should still exist on /data/2025/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes rules from all subpath levels")
|
||||
}
|
||||
|
||||
// TestRemoveGroupCascade_OnlyRemovesSpecificList tests cascade delete for groups
|
||||
func TestRemoveGroupCascade_OnlyRemovesSpecificList(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Create a group
|
||||
if err = s.AddUserToGroup("editors", "alice"); err != nil {
|
||||
t.Fatalf("AddUserToGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Set up both allow and deny rules for the group
|
||||
if err = s.AllowGroup("mnt/storage", "/content/", "editors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.AllowGroup("mnt/storage", "/content/articles/", "editors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/content/", "editors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/content/drafts/", "editors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only allow rules
|
||||
count, err := s.RemoveGroupCascade("mnt/storage", "/content/", "editors", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveGroupCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 allow rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify allow rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/content/")
|
||||
if !ok || len(rule.Allow.Groups) != 0 {
|
||||
t.Error("Allow rule should be removed from /content/")
|
||||
}
|
||||
|
||||
// Verify deny rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/content/")
|
||||
if !ok || len(rule.Deny.Groups) == 0 {
|
||||
t.Error("Deny rule should still exist on /content/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/content/drafts/")
|
||||
if !ok || len(rule.Deny.Groups) == 0 {
|
||||
t.Error("Deny rule should still exist on /content/drafts/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete for groups correctly removes only allow rules")
|
||||
}
|
||||
|
||||
// TestRemoveGroupCascade_DenyRules tests cascade delete for group deny rules specifically
|
||||
func TestRemoveGroupCascade_DenyRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Create a group
|
||||
if err = s.AddUserToGroup("contractors", "alice"); err != nil {
|
||||
t.Fatalf("AddUserToGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Set up both allow and deny rules for the group
|
||||
if err = s.AllowGroup("mnt/storage", "/work/", "contractors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.AllowGroup("mnt/storage", "/work/public/", "contractors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/work/", "contractors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/work/confidential/", "contractors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only deny rules
|
||||
var count int
|
||||
count, err = s.RemoveGroupCascade("mnt/storage", "/work/", "contractors", false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveGroupCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 deny rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify deny rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/work/")
|
||||
if ok && len(rule.Deny.Groups) != 0 {
|
||||
t.Error("Deny rule should be removed from /work/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/work/confidential/")
|
||||
if ok && len(rule.Deny.Groups) != 0 {
|
||||
t.Error("Deny rule should be removed from /work/confidential/")
|
||||
}
|
||||
|
||||
// Verify allow rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/work/")
|
||||
if !ok || len(rule.Allow.Groups) == 0 {
|
||||
t.Error("Allow rule should still exist on /work/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/work/public/")
|
||||
if !ok || len(rule.Allow.Groups) == 0 {
|
||||
t.Error("Allow rule should still exist on /work/public/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete for groups correctly removes only deny rules, leaving allow rules intact")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_EmptyResult tests cascade delete when no rules exist
|
||||
func TestRemoveUserCascade_EmptyResult(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "dave")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Try to cascade delete when no rules exist
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/nonexistent/", "dave", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade should not error on nonexistent rules: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("Expected 0 rules removed, got %d", count)
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly returns 0 when no rules exist")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_ExactPathOnly tests that exact path is included in cascade
|
||||
func TestRemoveUserCascade_ExactPathOnly(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "eve")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Add rule only on exact path, no subpaths
|
||||
if err = s.AllowUser("mnt/storage", "/single/", "eve"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete should remove the exact path rule
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/single/", "eve", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 rule removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify rule is gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/single/")
|
||||
if ok && len(rule.Allow.Users) > 0 {
|
||||
t.Error("Rule should be removed from /single/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes the exact path rule")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_DoesNotAffectParentPaths tests that parent paths are not affected
|
||||
func TestRemoveUserCascade_DoesNotAffectParentPaths(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "frank")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up rules on parent and child paths
|
||||
if err = s.AllowUser("mnt/storage", "/parent/", "frank"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/parent/child/", "frank"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/parent/child/grandchild/", "frank"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete from child path
|
||||
count, err := s.RemoveUserCascade("mnt/storage", "/parent/child/", "frank", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 rules removed (child + grandchild), got %d", count)
|
||||
}
|
||||
|
||||
// Verify parent rule still exists
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/parent/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Parent rule should still exist")
|
||||
}
|
||||
|
||||
// Verify child rules are gone
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/parent/child/")
|
||||
if ok && len(rule.Allow.Users) > 0 {
|
||||
t.Error("Child rule should be removed")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete does not affect parent paths")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_CleanupEmptyRules tests that empty rules are cleaned up
|
||||
func TestRemoveUserCascade_CleanupEmptyRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "grace")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Add only allow rule (no deny rules)
|
||||
if err = s.AllowUser("mnt/storage", "/temp/", "grace"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/temp/files/", "grace"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify rules exist
|
||||
allRules, err := s.GetAllRules("mnt/storage")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllRules failed: %v", err)
|
||||
}
|
||||
if len(allRules) < 2 {
|
||||
t.Error("Expected at least 2 rules to exist")
|
||||
}
|
||||
|
||||
// Cascade delete - should remove rules and cleanup empty rule objects
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/temp/", "grace", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify rules are completely gone (not just empty)
|
||||
allRules, err = s.GetAllRules("mnt/storage")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllRules failed: %v", err)
|
||||
}
|
||||
if _, exists := allRules["/temp/"]; exists {
|
||||
t.Error("Empty rule should be cleaned up from /temp/")
|
||||
}
|
||||
if _, exists := allRules["/temp/files/"]; exists {
|
||||
t.Error("Empty rule should be cleaned up from /temp/files/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly cleans up empty rules")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_MixedUsers tests that cascade delete only affects the specified user
|
||||
func TestRemoveUserCascade_MixedUsers(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "henry")
|
||||
createTestUser(t, userStore, "iris")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Add rules for both users on same paths
|
||||
if err = s.AllowUser("mnt/storage", "/shared/", "henry"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/shared/", "iris"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/shared/docs/", "henry"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/shared/docs/", "iris"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only henry's rules
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/shared/", "henry", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 rules removed for henry, got %d", count)
|
||||
}
|
||||
|
||||
// Verify henry's rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/shared/")
|
||||
if !ok {
|
||||
t.Fatal("Rule should still exist (iris's rule)")
|
||||
}
|
||||
hasHenry := false
|
||||
for _, user := range rule.Allow.Users {
|
||||
if user == "henry" {
|
||||
hasHenry = true
|
||||
}
|
||||
}
|
||||
if hasHenry {
|
||||
t.Error("henry should be removed from allow list")
|
||||
}
|
||||
|
||||
// Verify iris's rules still exist
|
||||
hasIris := false
|
||||
for _, user := range rule.Allow.Users {
|
||||
if user == "iris" {
|
||||
hasIris = true
|
||||
}
|
||||
}
|
||||
if !hasIris {
|
||||
t.Error("iris should still be in allow list")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete only affects the specified user")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -32,7 +33,7 @@ func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, inde
|
||||
|
||||
// Filter and return only the items the user has access to
|
||||
response.Folders = make([]iteminfo.ItemInfo, 0)
|
||||
response.Files = make([]iteminfo.ItemInfo, 0)
|
||||
response.Files = make([]iteminfo.ExtendedItemInfo, 0)
|
||||
|
||||
// Check each subfolder for access permissions
|
||||
for _, folder := range originalFolders {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package dbindex
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// IndexInfo stores persistent information about an index and its scanners
|
||||
type IndexInfo struct {
|
||||
// Index identifier - uses the real filesystem path (index.Path) as the key
|
||||
Path string `json:"path" storm:"id,index"`
|
||||
|
||||
// Source name (index.Name) for reference
|
||||
Source string `json:"source" storm:"index"`
|
||||
|
||||
// Index-level stats
|
||||
Complexity uint `json:"complexity"`
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
|
||||
// Scanner information - map of scanner path to scanner stats
|
||||
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
|
||||
}
|
||||
|
||||
// PersistedScannerInfo stores persistent information about a scanner
|
||||
type PersistedScannerInfo struct {
|
||||
Path string `json:"path"`
|
||||
Complexity uint `json:"complexity"`
|
||||
CurrentSchedule int `json:"currentSchedule"`
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
QuickScanTime int `json:"quickScanTime"`
|
||||
FullScanTime int `json:"fullScanTime"`
|
||||
LastScanned time.Time `json:"lastScanned"`
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package dbindex
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
|
||||
)
|
||||
|
||||
// StorageBackend is the interface to implement for an indexing storage.
|
||||
type StorageBackend interface {
|
||||
All() ([]*IndexInfo, error)
|
||||
GetByPath(path string) (*IndexInfo, error)
|
||||
GetBySource(source string) ([]*IndexInfo, error)
|
||||
Save(info *IndexInfo) error
|
||||
Delete(path string) error
|
||||
}
|
||||
|
||||
// crudBackend implements crud.CrudBackend[IndexInfo] for indexing storage.
|
||||
type crudBackend struct {
|
||||
back StorageBackend
|
||||
}
|
||||
|
||||
func (c *crudBackend) GetByID(id any) (*IndexInfo, error) {
|
||||
path, ok := id.(string)
|
||||
if !ok {
|
||||
return nil, errors.ErrInvalidDataType
|
||||
}
|
||||
return c.back.GetByPath(path)
|
||||
}
|
||||
|
||||
func (c *crudBackend) GetAll() ([]*IndexInfo, error) {
|
||||
return c.back.All()
|
||||
}
|
||||
|
||||
func (c *crudBackend) Save(obj *IndexInfo) error {
|
||||
return c.back.Save(obj)
|
||||
}
|
||||
|
||||
func (c *crudBackend) DeleteByID(id any) error {
|
||||
path, ok := id.(string)
|
||||
if !ok {
|
||||
return errors.ErrInvalidDataType
|
||||
}
|
||||
return c.back.Delete(path)
|
||||
}
|
||||
|
||||
// Storage is an indexing storage using generics.
|
||||
type Storage struct {
|
||||
Generic *crud.Storage[IndexInfo]
|
||||
back StorageBackend
|
||||
indexCache map[string]*IndexInfo
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewStorage creates an indexing storage from a backend.
|
||||
func NewStorage(back StorageBackend) *Storage {
|
||||
return &Storage{
|
||||
Generic: crud.NewStorage[IndexInfo](&crudBackend{back: back}),
|
||||
back: back,
|
||||
indexCache: make(map[string]*IndexInfo),
|
||||
}
|
||||
}
|
||||
|
||||
// All wraps StorageBackend.All
|
||||
func (s *Storage) All() ([]*IndexInfo, error) {
|
||||
infos, err := s.back.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for i, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
if existing, ok := s.indexCache[info.Path]; ok && existing != nil {
|
||||
infos[i] = existing
|
||||
} else {
|
||||
s.indexCache[info.Path] = info
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// GetByPath wraps StorageBackend.GetByPath
|
||||
func (s *Storage) GetByPath(path string) (*IndexInfo, error) {
|
||||
// return stable in-memory pointer if available
|
||||
s.mu.RLock()
|
||||
if info, ok := s.indexCache[path]; ok && info != nil {
|
||||
s.mu.RUnlock()
|
||||
return info, nil
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
info, err := s.back.GetByPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.indexCache[path] = info
|
||||
s.mu.Unlock()
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// GetBySource wraps StorageBackend.GetBySource
|
||||
func (s *Storage) GetBySource(source string) ([]*IndexInfo, error) {
|
||||
infos, err := s.back.GetBySource(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for i, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
if existing, ok := s.indexCache[info.Path]; ok && existing != nil {
|
||||
infos[i] = existing
|
||||
} else {
|
||||
s.indexCache[info.Path] = info
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// Save wraps StorageBackend.Save
|
||||
func (s *Storage) Save(info *IndexInfo) error {
|
||||
if err := s.back.Save(info); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.indexCache[info.Path] = info
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete wraps StorageBackend.Delete
|
||||
func (s *Storage) Delete(path string) error {
|
||||
if err := s.back.Delete(path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.indexCache, path)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush persists the current in-memory state of all indexes to the backing store.
|
||||
// Call during graceful shutdown to ensure DB matches memory.
|
||||
func (s *Storage) Flush() error {
|
||||
s.mu.RLock()
|
||||
infos := make([]*IndexInfo, 0, len(s.indexCache))
|
||||
for _, info := range s.indexCache {
|
||||
if info != nil {
|
||||
infos = append(infos, info)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
for _, info := range infos {
|
||||
if err := s.back.Save(info); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetAllComplexities sets the complexity to 0 for all indexes and their scanners.
|
||||
// This should be called when the index database is recreated (either fresh start or after corruption).
|
||||
func (s *Storage) ResetAllComplexities() error {
|
||||
// Get all indexes from storage
|
||||
infos, err := s.All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reset complexity for each index
|
||||
for _, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset index-level complexity
|
||||
info.Complexity = 0
|
||||
|
||||
// Reset scanner-level complexities
|
||||
for _, scanner := range info.Scanners {
|
||||
if scanner != nil {
|
||||
scanner.Complexity = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Save the updated info
|
||||
if err := s.Save(info); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Loaded 100 of 411 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user