Compare commits

...
Author SHA1 Message Date
Graham Steffaniak 09713b32a5 updated last minute changes (#2103) 2026-03-07 07:02:10 -06:00
Graham Steffaniak 52e3c5c142 updated with oidc and sidebar changes (#2093) 2026-03-05 18:52:00 -06:00
Graham Steffaniak 2af75311bd fix download issue for users with scope 2026-03-01 10:02:48 -06:00
Graham Steffaniak 82c0182c22 updated to use html/template (#2060) 2026-03-01 09:20:58 -06:00
Graham Steffaniak c8f0b5abea updated share link (#2059) 2026-03-01 09:18:03 -06:00
Graham Steffaniak d3d029d1e9 updated share link edit while on share (#2056) 2026-03-01 07:48:34 -06:00
Graham Steffaniak f39002f375 add strict sharing (#2053) 2026-02-28 18:30:55 -06:00
Graham Steffaniak a658f100c0 Fix database path loading (#2055) 2026-02-28 18:28:15 -06:00
Graham Steffaniak c2d255a25e updated deps (#2052) 2026-02-28 14:26:31 -06:00
Graham Steffaniak 2eb352c2d3 fix delete item issue while viewing (#2043) 2026-02-27 16:29:49 -06:00
Graham Steffaniak 43bb4a36b4 stable/v1.2.1 (#2041) 2026-02-27 13:13:18 -06:00
Stephan Paternotte c67abefc7b Update nl.json (#2032) 2026-02-25 12:26:39 -06:00
Graham Steffaniak 2ecb9036aa Beta/v1.2.7 (#2027) 2026-02-24 12:55:04 -06:00
Graham Steffaniak dbcfba993b Beta/v1.2.6 (#2004) 2026-02-21 10:39:04 -06:00
Graham Steffaniak ed22ce9421 Beta/v1.2.5 (#1999) 2026-02-19 16:35:55 -06:00
Graham Steffaniakandzarev 8caa6d6a60 Beta/v1.2.4 (#1978)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-13 16:08:20 -06:00
Graham Steffaniak fb1af87048 Beta/v1.2.3 (#1940) 2026-02-06 13:52:38 -06:00
+4 f73ca51f94 Beta/v1.2.2 (#1928)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-02-03 18:07:51 -06:00
+4 ad839f92ac Beta/v1.2.1 (#1889)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-25 07:13:59 -06:00
dependabot[bot] eb47df7172 Bump lodash from 4.17.21 to 4.17.23 in /frontend in the npm_and_yarn group across 1 directory (#1882)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 10:26:15 -06:00
+4 f2f5d776ca Beta/v1.2.0 (#1858)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-20 15:56:52 -06:00
Graham Steffaniak 4f73b4cc6b Beta/v1.1.6 (#1812) 2026-01-06 13:08:54 -06:00
SmilingPie 5d7d94d86a fix(frontend): fix preview page sorting (#1787) 2026-01-03 10:09:29 -06:00
Graham SteffaniakandKevin B. 9723799e4f Beta/v1.1.5 (#1781)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2025-12-31 13:19:00 -06:00
Graham Steffaniak 3e5f0312f3 updated token claim process (#1774) 2025-12-31 11:13:10 -06:00
MrPanda376 131c3a3686 Bug fix: file permissions not working (#1762) 2025-12-28 15:43:20 -06:00
ATUR 7e5a579dde fix(http): handle Close() errors in archive creation (#1745) 2025-12-20 14:10:37 -06:00
Adam ABICHOU cb2d4dc518 Move custom CSS and user-selected theme styles to the end of the body… (#1744) 2025-12-20 13:53:20 -06:00
Graham SteffaniakandKevin B. 8587ffc092 Beta/v1.1.4 (#1724)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2025-12-15 11:14:40 -06:00
Graham Steffaniak de6fefad4e Beta/v1.1.3 (#1695) 2025-12-02 09:55:48 -06:00
9d18a369c7 Beta/v1.1.2 (#1679)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-30 13:34:00 -05:00
StefPanther 9b599347f7 Update French translation (#1682) 2025-11-30 11:18:29 -05:00
Graham Steffaniak 37596b1e23 Update README.md (#1667) 2025-11-26 19:04:24 -06:00
d35fb56587 Beta/v1.1.1 (#1655)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-24 15:01:49 -06:00
Graham Steffaniak 5dab4c7b30 Update README.md (#1624) 2025-11-19 14:30:37 -06:00
a33dbfc464 Beta/v1.1.0 (#1614)
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-19 08:50:49 -06:00
18738b3273 Stable/v1.0.1 (#1559)
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
2025-11-10 10:57:17 -06:00
Ironic 1d8c74dbd9 Remove unstable version warning from README (#1512) 2025-11-04 08:05:18 -05:00
0ab22e3a89 Beta/v1.0.1 (#1500)
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
2025-11-02 15:40:06 -06:00
411 changed files with 57156 additions and 15802 deletions

No files matched your search

+1
View File
@@ -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'."
+2 -2
View File
@@ -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 }}
-62
View File
@@ -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 }}
+2 -1
View File
@@ -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 }}
+42 -8
View File
@@ -59,7 +59,7 @@ jobs:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
- name: Modify tags (strip 'v' and add version tags)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
@@ -68,15 +68,31 @@ jobs:
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not)
) |
if any(.tags[]; test("beta")) then
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor and major tags
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
] |
# Add suffix-only tags for beta (only if tag ends with -beta)
if any(.tags[]; split(":")[-1] | endswith("-beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
"ghcr.io/gtsteffaniak/filebrowser:beta"
]
else . end |
if any(.tags[]; test("stable")) then
# Add suffix-only tags for stable (and latest) (only if tag ends with -stable)
if any(.tags[]; split(":")[-1] | endswith("-stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
@@ -138,13 +154,31 @@ jobs:
| select(test(":latest") | not) # Remove the "latest" tag
| . + "-slim" # Append "-slim" to the versioned tag
) |
if any(.tags[]; test("beta")) then
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor and major tags
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
] |
# Add suffix-only tags for beta-slim (only if tag ends with -beta-slim)
if any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
.tags += [
"gtstef/filebrowser:beta-slim",
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
]
else . end |
if any(.tags[]; test("stable")) then
# Add suffix-only tags for stable-slim (only if tag ends with -stable-slim)
if any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
.tags += [
"gtstef/filebrowser:stable-slim",
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+15 -16
View File
@@ -2,7 +2,7 @@
[![Go Report Card](https://goreportcard.com/badge/github.com/gtsteffaniak/filebrowser/backend)](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![latest version](https://img.shields.io/github/release/gtsteffaniak/filebrowser/all.svg)](https://github.com/gtsteffaniak/filebrowser/releases)
[![latest version](https://img.shields.io/github/v/release/gtsteffaniak/filebrowser)](https://github.com/gtsteffaniak/filebrowser/releases)
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
@@ -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
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:8.0-decode AS ffmpeg
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+2 -1
View File
@@ -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
+2
View File
@@ -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
+1
View File
@@ -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
+8 -3
View File
@@ -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
+1
View File
@@ -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 -3
View File
@@ -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.
+25 -13
View File
@@ -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
+2 -1
View File
@@ -14,7 +14,8 @@ frontend:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
auth:
methods:
password:
+2 -1
View File
@@ -16,7 +16,8 @@ frontend:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
auth:
methods:
password:
+12 -1
View File
@@ -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 */
+3 -1
View File
@@ -20,4 +20,6 @@ frontend:
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
title: "Playwright"
styling:
disableEventThemes: true
+3 -3
View File
@@ -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
+21
View File
@@ -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
+1
View File
@@ -3,3 +3,4 @@ air.log
filebrowser
generated.yaml
test_config.yaml
/data
-49
View File
@@ -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
View File
@@ -1 +0,0 @@
FROM ghcr.io/gtsteffaniak/playwright-base:latest
-42
View File
@@ -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" ]
-17
View File
@@ -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"] },
},
],
});
+59 -4
View File
@@ -10,6 +10,9 @@ import (
"os"
"path/filepath"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Cache interface for caching operations
@@ -29,9 +32,61 @@ type FileCache struct {
}
}
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
return &FileCache{dir: dir}, nil
cacheDir := filepath.Join(dir, "diskcache")
// Migrate existing cache files from old structure (if any)
if err := migrateOldCacheStructure(dir, cacheDir); err != nil {
logger.Warningf("failed to migrate old cache structure: %v\n", err)
}
// Create the cache directory immediately with proper permissions
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
return &FileCache{dir: cacheDir}, nil
}
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
func migrateOldCacheStructure(oldDir, newDir string) error {
// Read the old cache directory
entries, err := os.ReadDir(oldDir)
if err != nil {
if os.IsNotExist(err) {
return nil // Directory doesn't exist yet, nothing to migrate
}
return err
}
migrated := 0
for _, entry := range entries {
// Only migrate single-character directories (cache hash directories like a, b, c, etc.)
if !entry.IsDir() || len(entry.Name()) != 1 {
continue
}
// Skip if it's already the diskcache directory
if entry.Name() == "d" && filepath.Join(oldDir, entry.Name()) == newDir {
continue
}
oldPath := filepath.Join(oldDir, entry.Name())
newPath := filepath.Join(newDir, entry.Name())
// Create parent directory in new location
if err := os.MkdirAll(newDir, fileutils.PermDir); err != nil {
return err
}
// Move the directory
if err := os.Rename(oldPath, newPath); err != nil {
// If rename fails (e.g., cross-device), skip this one
continue
}
migrated++
}
return nil
}
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
@@ -40,11 +95,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
defer mu.Unlock()
fileName := f.getFileName(key)
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
return err
}
if err := os.WriteFile(fileName, value, 0600); err != nil {
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
return err
}
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/stretchr/testify/require"
)
@@ -19,6 +20,9 @@ func TestFileCache(t *testing.T) {
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
)
// Set up file permissions before creating cache
fileutils.SetFsPermissions(0644, 0755)
// Create temporary directory for the cache
cacheDir, err := os.MkdirTemp("", cacheRoot)
require.NoError(t, err)
@@ -28,19 +32,20 @@ func TestFileCache(t *testing.T) {
require.NoError(t, err)
// store new key
// Note: NewFileCache creates a "diskcache" subdirectory, so the actual path includes it
err = cache.Store(ctx, key, []byte(value))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
// update existing key
err = cache.Store(ctx, key, []byte(newValue))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
// delete key
err = cache.Delete(ctx, key)
require.NoError(t, err)
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
require.False(t, exists)
}
+138
View File
@@ -0,0 +1,138 @@
package files
import (
"os"
"path/filepath"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
)
func TestGetContent_UTF8Truncation(t *testing.T) {
// Get the path to the test file
// The test file is in frontend/tests/playwright-files/utf8-truncated.txt
// We need to find it relative to the test execution directory
cwd, err := os.Getwd()
require.NoError(t, err)
// Navigate from backend/adapters/fs/files to the project root
// backend/adapters/fs/files -> backend/adapters/fs -> backend/adapters -> backend -> root
testFilePath := filepath.Join(cwd, "..", "..", "..", "..", "frontend", "tests", "playwright-files", "utf8-truncated.txt")
// Check if file exists, if not try alternative path
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
// Try from project root
testFilePath = filepath.Join("frontend", "tests", "playwright-files", "utf8-truncated.txt")
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
t.Skipf("Test file not found at %s, skipping test", testFilePath)
return
}
}
// Get absolute path
var absPath string
absPath, err = filepath.Abs(testFilePath)
require.NoError(t, err)
t.Run("file with UTF-8 truncation at 4096 byte boundary", func(t *testing.T) {
// The test file is longer than 4096 bytes, with valid UTF-8 throughout.
// However, when reading exactly 4096 bytes as a header, it cuts off in the
// middle of a multi-byte UTF-8 sequence (e6 9c, missing the last byte 88 of '月').
// This should trigger the truncation handling in getContent's header validation.
// The fix should trim the incomplete sequence from the header, allowing the
// header check to pass, and then the full file (which is valid UTF-8) should
// pass the full file validation.
content, err := getContent(absPath)
// Should not return an error - the header truncation is handled, and the full file is valid
require.NoError(t, err)
// The content should be the full valid UTF-8 file
require.NotEmpty(t, content, "Content should not be empty - full file is valid UTF-8")
// Verify it contains the expected text
require.Contains(t, content, "文件已备份", "Content should contain Chinese characters")
require.Contains(t, content, "2024年", "Content should contain date information")
})
t.Run("regular UTF-8 text file", func(t *testing.T) {
// Test with a simple text file to ensure normal files still work
tmpFile, err := os.CreateTemp("", "test-utf8-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
testText := "Hello, 世界! This is a test file with UTF-8 characters.\n"
_, err = tmpFile.WriteString(testText)
require.NoError(t, err)
tmpFile.Close()
content, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.Equal(t, testText, content)
})
t.Run("file smaller than header size", func(t *testing.T) {
// Test with a file smaller than 4096 bytes
tmpFile, err := os.CreateTemp("", "test-small-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
testText := "Small file content"
_, err = tmpFile.WriteString(testText)
require.NoError(t, err)
tmpFile.Close()
content, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.Equal(t, testText, content)
})
t.Run("file with Chinese characters at boundary", func(t *testing.T) {
// Create a file that's exactly 4094 bytes, ending with a complete Chinese character
tmpFile, err := os.CreateTemp("", "test-chinese-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
// Create content that's exactly 4094 bytes, ending with complete UTF-8
baseText := "2024年 06月 17日 星期一 04:05:58 CST 文件已备份\n"
content := ""
for len([]byte(content)) < 4094 {
content += baseText
}
// Trim to exactly 4094 bytes
encoded := []byte(content)
if len(encoded) > 4094 {
encoded = encoded[:4094]
}
// Ensure it ends with a complete character by finding the last complete rune
for len(encoded) > 0 {
lastRune, _ := decodeLastRune(encoded)
if lastRune != 0xFFFD { // RuneError
break
}
encoded = encoded[:len(encoded)-1]
}
_, err = tmpFile.Write(encoded)
require.NoError(t, err)
tmpFile.Close()
result, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.NotEmpty(t, result)
require.Equal(t, string(encoded), result)
})
}
// Helper function to check if last rune is valid
func decodeLastRune(p []byte) (rune, int) {
if len(p) == 0 {
return 0, 0
}
r, size := utf8.DecodeLastRune(p)
return r, size
}
+137 -123
View File
@@ -6,8 +6,11 @@ import (
"reflect"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
@@ -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
View File
@@ -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 {
+19 -8
View File
@@ -6,7 +6,9 @@ import (
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// MakeUserDir makes the user directory according to settings.
@@ -25,37 +27,46 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
for i, scope := range u.Scopes {
source, ok := settings.Config.Server.SourceMap[scope.Name]
if !ok {
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
continue
}
// create directory and append user name
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
// validate that scope path exists
// If parent directory doesn't exist and createUserDir is enabled, create it
if !Exists(parentDir) {
return fmt.Errorf("MakeUserDirs: scope path does not exist: %s", scope.Scope)
if err := MakeUserDir(parentDir); err != nil {
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
continue
}
}
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
// create directory exactly as specified
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if !Exists(parentDir) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
continue
}
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: failed to create user home dir: %s", err)
logger.Errorf("create user: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else {
// just assigning scope to path provided, so just check that it exists
path := filepath.Join(source.Path, scope.Scope)
if !Exists(path) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
continue
}
}
u.Scopes[i] = scope
+36
View File
@@ -0,0 +1,36 @@
package fileutils
import (
"io/fs"
"net/http"
)
var (
assetFs fs.FS
)
// Custom dirFS to handle both embedded and non-embedded file systems
type dirFS struct {
http.Dir
}
// Implement the Open method for dirFS, which wraps http.Dir
func (d dirFS) Open(name string) (fs.File, error) {
return d.Dir.Open(name)
}
// InitAssetFS initializes the asset filesystem for the application
// This should be called once during startup before http or preview services start
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
if useEmbedded {
assetFs = embeddedAssets
} else {
// Dev mode: Serve files from http/dist directory
assetFs = dirFS{Dir: http.Dir("http/dist")}
}
}
// GetAssetFS returns the initialized asset filesystem
func GetAssetFS() fs.FS {
return assetFs
}
+15 -6
View File
@@ -62,6 +62,13 @@ func CopyFile(source, dest string) error {
// copySingleFile handles copying a single file.
func copySingleFile(source, dest string) error {
// Get source file info to preserve permissions
srcInfo, err := os.Stat(source)
if err != nil {
return err
}
sourcePerms := srcInfo.Mode().Perm()
// Open the source file.
src, err := os.Open(source)
if err != nil {
@@ -75,8 +82,8 @@ func copySingleFile(source, dest string) error {
return err
}
// Create the destination file.
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, PermFile)
// Create the destination file with source permissions
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
if err != nil {
return err
}
@@ -88,11 +95,13 @@ func copySingleFile(source, dest string) error {
return err
}
// Set the configured file permissions instead of copying from source
err = os.Chmod(dest, PermFile)
// Preserve source file permissions
// Handle chmod errors gracefully (e.g., in rootless containers where chmod may be restricted)
err = os.Chmod(dest, sourcePerms)
if err != nil {
return err
// Log but don't fail - chmod may be restricted in some environments
// The file was copied successfully, so we continue
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
}
return nil
@@ -0,0 +1,31 @@
//go:build !windows
package fileutils
import (
"syscall"
)
// GetPartitionSize returns the filesystem size for Unix systems
func GetPartitionSize(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
// Total size in bytes: Blocks * Block size
total := uint64(stat.Blocks) * uint64(stat.Bsize)
return total, nil
}
// GetFreeSpace returns the available free space for Unix systems
func GetFreeSpace(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
// Available free space in bytes: Available blocks * Block size
free := uint64(stat.Bavail) * uint64(stat.Bsize)
return free, nil
}
@@ -0,0 +1,35 @@
//go:build windows
package fileutils
import (
"golang.org/x/sys/windows"
)
// GetPartitionSize returns the filesystem size for Windows systems
func GetPartitionSize(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
return totalBytes, nil
}
// GetFreeSpace returns the available free space for Windows systems
func GetFreeSpace(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
return freeBytes, nil
}
+5
View File
@@ -22,6 +22,11 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
password := r.Header.Get("X-Password")
// URL-decode password to support special characters in headers
password, err := url.QueryUnescape(password)
if err != nil {
return nil, fmt.Errorf("invalid password encoding: %v", err)
}
totpCode := r.Header.Get("X-Secret")
// If ReCaptcha is enabled, check the code.
+7 -4
View File
@@ -67,15 +67,17 @@ func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, err
secretText := key.Secret()
nonce := ""
secretToStore := secretText
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is provided, encrypt the secret.
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
if err != nil {
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
}
}
// set cache so verify can attempt to use it but not require it for user yet.
TotpCache.Set(user.Username, secretText+"||"+nonce)
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
// Use the original base32 secret in the OTP URL, not the encrypted version
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
return url, nil
}
@@ -181,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
-83
View File
@@ -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]
-11
View File
@@ -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
View File
@@ -60,10 +60,17 @@ func runCLI() bool {
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, dbConfig string
var asAdmin bool
var fsPath, indexPath, ruleCategory, value string
var allow bool
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
setCmd.BoolVar(&asAdmin, "a", false, "Create a user as admin user, used in combination with -u")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
setCmd.StringVar(&fsPath, "f", "", "Real filesystem path (e.g. /mnt/storage) for rule command")
setCmd.StringVar(&indexPath, "p", "", "Index path (e.g. /secret) for rule command")
setCmd.StringVar(&ruleCategory, "r", "", "Rule category: 'user', 'group', or 'all' (for deny only)")
setCmd.StringVar(&value, "v", "", "Value: username or groupname (not required if ruleCategory is 'all')")
setCmd.BoolVar(&allow, "allow", false, "Allow access (default: false, which means deny)")
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
@@ -72,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
View File
@@ -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
}
+57
View File
@@ -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
View File
@@ -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
View File
@@ -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:]
}
+20 -2
View File
@@ -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 != ""
}
+60
View File
@@ -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
},
})
}
+100 -112
View File
@@ -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
+14 -7
View File
@@ -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",
+1 -1
View File
@@ -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)
}
+54 -3
View File
@@ -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
+149 -79
View File
@@ -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
}
+18 -11
View File
@@ -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
}
+11 -11
View File
@@ -26,7 +26,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -52,7 +52,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -81,16 +81,16 @@ integrations:
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// Check explicitly set formats
if !config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
if !*config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
t.Error("Expected mp4 to be enabled")
}
if config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
t.Error("Expected webm to be disabled")
}
if config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
t.Error("Expected mkv to be disabled")
}
if config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
t.Error("Expected wmv to be disabled")
}
@@ -100,7 +100,7 @@ integrations:
M4VVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
}
for _, format := range unmentionedFormats {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled (not mentioned in config), but it was disabled", format)
}
}
@@ -155,13 +155,13 @@ integrations:
}
for _, format := range expectedEnabled {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled, but it was disabled", format)
}
}
for _, format := range expectedDisabled {
if config.Integrations.Media.Convert.VideoPreview[format] {
if *config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be disabled, but it was enabled", format)
}
}
@@ -182,7 +182,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -217,7 +217,7 @@ integrations:
for category, formats := range categories {
t.Run(category, func(t *testing.T) {
for _, format := range formats {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s (%s) to be enabled by default, but it was disabled", format, category)
}
}
+8 -3
View File
@@ -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
+26 -2
View File
@@ -6,9 +6,12 @@ import (
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
@@ -44,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
}
+36
View File
@@ -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
}
+43
View File
@@ -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
}
+65
View File
@@ -0,0 +1,65 @@
package utils
import (
"os"
"unicode/utf8"
)
// IsTextFile checks if a file is viewable/editable as text (like with cat or a text editor).
// It returns true if the file appears to be text, false if it's likely binary.
// This function uses simple heuristics:
// - Valid UTF-8 encoding
// - Not full of null bytes (which indicate binary data)
func IsTextFile(realPath string) (bool, error) {
const sampleSize = 8192 // Read first 8KB to check
const maxNullByteRatio = 0.05 // Reject if more than 5% null bytes
// Read sample from file
content, err := os.ReadFile(realPath)
if err != nil {
return false, err
}
// Empty files are considered text
if len(content) == 0 {
return true, nil
}
// Use sample for large files to avoid reading entire file into memory
sample := content
if len(content) > sampleSize {
sample = content[:sampleSize]
}
// Check 1: Count null bytes - binary files typically have many nulls
nullCount := 0
for _, b := range sample {
if b == 0x00 {
nullCount++
}
}
if nullCount > 0 {
nullRatio := float64(nullCount) / float64(len(sample))
if nullRatio > maxNullByteRatio {
return false, nil
}
}
// Check 2: Validate UTF-8 encoding
// Trim sample to last complete UTF-8 rune to avoid false negatives
trimmedSample := sample
for len(trimmedSample) > 0 {
lastRune, size := utf8.DecodeLastRune(trimmedSample)
if lastRune != utf8.RuneError {
break
}
if size == 1 {
trimmedSample = trimmedSample[:len(trimmedSample)-1]
} else {
break
}
}
if len(trimmedSample) > 0 && !utf8.Valid(trimmedSample) {
return false, nil
}
return true, nil
}
+15 -14
View File
@@ -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
View File
@@ -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()
}
+527 -2
View File
@@ -38,17 +38,32 @@ func setupTestSources() {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: false,
DenyByDefault: false,
DefaultUserScope: "/",
},
},
"mnt/open": {
Path: "mnt/open",
Name: "open",
Config: settings.SourceConfig{
DenyByDefault: false,
DenyByDefault: false,
DefaultUserScope: "/",
},
},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"storage": settings.Config.Server.SourceMap["mnt/storage"],
"open": settings.Config.Server.SourceMap["mnt/open"],
}
settings.Config.Server.Sources = []*settings.Source{
settings.Config.Server.SourceMap["mnt/storage"],
settings.Config.Server.SourceMap["mnt/open"],
}
settings.Config.UserDefaults.DefaultScopes = []users.SourceScope{
{Name: "mnt/storage", Scope: "/"},
}
// Initialize user package resolvers
settings.InitializeUserResolvers()
}
func TestPermitted_UserBlacklist(t *testing.T) {
@@ -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")
}
+4 -3
View File
@@ -19,10 +19,11 @@ func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, inde
}
// Use standardized path with trailing slash for proper path construction
parentPath := index.MakeIndexPath(response.Path)
// response is an ExtendedFileInfo which represents a directory (has Folders and Files)
parentPath := index.MakeIndexPath(response.Path, true)
// Check if user has access to any items
if !s.HasAnyVisibleItems(index.Path, parentPath, allItemNames, username) {
if !s.HasAnyVisibleItems(index.Path, parentPath, allItemNames, username) && len(allItemNames) > 0 {
return errors.ErrAccessDenied
}
@@ -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 {
+34
View File
@@ -0,0 +1,34 @@
package dbindex
import (
"time"
)
// IndexInfo stores persistent information about an index and its scanners
type IndexInfo struct {
// Index identifier - uses the real filesystem path (index.Path) as the key
Path string `json:"path" storm:"id,index"`
// Source name (index.Name) for reference
Source string `json:"source" storm:"index"`
// Index-level stats
Complexity uint `json:"complexity"`
NumDirs uint64 `json:"numDirs"`
NumFiles uint64 `json:"numFiles"`
// Scanner information - map of scanner path to scanner stats
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
}
// PersistedScannerInfo stores persistent information about a scanner
type PersistedScannerInfo struct {
Path string `json:"path"`
Complexity uint `json:"complexity"`
CurrentSchedule int `json:"currentSchedule"`
NumDirs uint64 `json:"numDirs"`
NumFiles uint64 `json:"numFiles"`
QuickScanTime int `json:"quickScanTime"`
FullScanTime int `json:"fullScanTime"`
LastScanned time.Time `json:"lastScanned"`
}
+203
View File
@@ -0,0 +1,203 @@
package dbindex
import (
"sync"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
)
// StorageBackend is the interface to implement for an indexing storage.
type StorageBackend interface {
All() ([]*IndexInfo, error)
GetByPath(path string) (*IndexInfo, error)
GetBySource(source string) ([]*IndexInfo, error)
Save(info *IndexInfo) error
Delete(path string) error
}
// crudBackend implements crud.CrudBackend[IndexInfo] for indexing storage.
type crudBackend struct {
back StorageBackend
}
func (c *crudBackend) GetByID(id any) (*IndexInfo, error) {
path, ok := id.(string)
if !ok {
return nil, errors.ErrInvalidDataType
}
return c.back.GetByPath(path)
}
func (c *crudBackend) GetAll() ([]*IndexInfo, error) {
return c.back.All()
}
func (c *crudBackend) Save(obj *IndexInfo) error {
return c.back.Save(obj)
}
func (c *crudBackend) DeleteByID(id any) error {
path, ok := id.(string)
if !ok {
return errors.ErrInvalidDataType
}
return c.back.Delete(path)
}
// Storage is an indexing storage using generics.
type Storage struct {
Generic *crud.Storage[IndexInfo]
back StorageBackend
indexCache map[string]*IndexInfo
mu sync.RWMutex
}
// NewStorage creates an indexing storage from a backend.
func NewStorage(back StorageBackend) *Storage {
return &Storage{
Generic: crud.NewStorage[IndexInfo](&crudBackend{back: back}),
back: back,
indexCache: make(map[string]*IndexInfo),
}
}
// All wraps StorageBackend.All
func (s *Storage) All() ([]*IndexInfo, error) {
infos, err := s.back.All()
if err != nil {
return nil, err
}
s.mu.Lock()
for i, info := range infos {
if info == nil {
continue
}
if existing, ok := s.indexCache[info.Path]; ok && existing != nil {
infos[i] = existing
} else {
s.indexCache[info.Path] = info
}
}
s.mu.Unlock()
return infos, nil
}
// GetByPath wraps StorageBackend.GetByPath
func (s *Storage) GetByPath(path string) (*IndexInfo, error) {
// return stable in-memory pointer if available
s.mu.RLock()
if info, ok := s.indexCache[path]; ok && info != nil {
s.mu.RUnlock()
return info, nil
}
s.mu.RUnlock()
info, err := s.back.GetByPath(path)
if err != nil {
return nil, err
}
s.mu.Lock()
s.indexCache[path] = info
s.mu.Unlock()
return info, nil
}
// GetBySource wraps StorageBackend.GetBySource
func (s *Storage) GetBySource(source string) ([]*IndexInfo, error) {
infos, err := s.back.GetBySource(source)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, info := range infos {
if info == nil {
continue
}
if existing, ok := s.indexCache[info.Path]; ok && existing != nil {
infos[i] = existing
} else {
s.indexCache[info.Path] = info
}
}
s.mu.Unlock()
return infos, nil
}
// Save wraps StorageBackend.Save
func (s *Storage) Save(info *IndexInfo) error {
if err := s.back.Save(info); err != nil {
return err
}
s.mu.Lock()
s.indexCache[info.Path] = info
s.mu.Unlock()
return nil
}
// Delete wraps StorageBackend.Delete
func (s *Storage) Delete(path string) error {
if err := s.back.Delete(path); err != nil {
return err
}
s.mu.Lock()
delete(s.indexCache, path)
s.mu.Unlock()
return nil
}
// Flush persists the current in-memory state of all indexes to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
s.mu.RLock()
infos := make([]*IndexInfo, 0, len(s.indexCache))
for _, info := range s.indexCache {
if info != nil {
infos = append(infos, info)
}
}
s.mu.RUnlock()
for _, info := range infos {
if err := s.back.Save(info); err != nil {
return err
}
}
return nil
}
// ResetAllComplexities sets the complexity to 0 for all indexes and their scanners.
// This should be called when the index database is recreated (either fresh start or after corruption).
func (s *Storage) ResetAllComplexities() error {
// Get all indexes from storage
infos, err := s.All()
if err != nil {
return err
}
// Reset complexity for each index
for _, info := range infos {
if info == nil {
continue
}
// Reset index-level complexity
info.Complexity = 0
// Reset scanner-level complexities
for _, scanner := range info.Scanners {
if scanner != nil {
scanner.Complexity = 0
}
}
// Save the updated info
if err := s.Save(info); err != nil {
return err
}
}
return nil
}
Loaded 100 of 411 files, more files were not shown because too many files have changed in this diff. Show more