Compare commits

..
Author SHA1 Message Date
Graham Steffaniak e8a95cb3cf updated linting testing 2025-09-09 18:36:13 -05:00
Graham Steffaniak 5e79faefa1 updated compilation 2025-09-09 18:29:56 -05:00
Graham Steffaniak 92909a7403 fix merge issues 2025-09-09 18:26:40 -05:00
Graham Steffaniak 2385994736 Merge branch 'fix-issues-features' of github.com:gtsteffaniak/filebrowser into fix-issues-features 2025-09-09 18:24:06 -05:00
Graham Steffaniak b0df91cd45 updated 2025-09-09 18:22:40 -05:00
Graham Steffaniak 3330468c1b fix build 2025-09-09 16:12:05 -05:00
Graham Steffaniak 8ef0435602 updated 2025-09-09 16:12:05 -05:00
Graham Steffaniak 32cbec011a fix issues features 2025-09-09 16:12:05 -05:00
Graham Steffaniak e6a8d090ad updated debugging and scoped path (#1198) 2025-09-09 16:12:05 -05:00
Graham Steffaniak 527d1ec810 feat: remember view settings per folder (#1196) 2025-09-09 16:12:05 -05:00
Graham Steffaniak 586a76c5ca fix build 2025-09-09 16:04:19 -05:00
Graham Steffaniak 5be126dc47 updated 2025-09-09 15:51:44 -05:00
Graham Steffaniak b8d3b6aadc fix issues features 2025-09-09 15:35:03 -05:00
Graham Steffaniak 2d2a3d7707 updated debugging and scoped path (#1198) 2025-09-09 12:21:16 -05:00
Graham Steffaniak 7baf2653e0 feat: remember view settings per folder (#1196) 2025-09-08 18:31:00 -05:00
256f4ca0b8 Beta/v0.8.4 (#1187)
Co-authored-by: Max <mail@kjmax.com>
Co-authored-by: Kurami <kurami32_kbm@proton.me>
2025-09-05 18:56:18 -05:00
Graham Steffaniak 0f5c81a19e updated (#1186) 2025-09-05 14:31:11 -05:00
Graham Steffaniak 5c91eebf2a updated with show running config (#1185) 2025-09-05 13:40:07 -05:00
Kurami 59f95e9f22 Add audio metadata on audio media player (#1182) 2025-09-04 20:04:21 -05:00
Graham Steffaniak d4977713e7 onlyoffice share support (#1181) 2025-09-03 19:32:02 -05:00
Graham Steffaniak 99786eafde editor reverted changes (#1180) 2025-09-03 14:00:53 -05:00
Graham Steffaniak cbca224f69 updated with changes for share (#1177) 2025-09-03 10:10:55 -05:00
Graham Steffaniak d9c465bfad fixed help and rename prompt (#1176) 2025-09-02 19:04:02 -05:00
KuramiandGraham Steffaniak 1a36bc7336 Small fixes / additions on the media player (#1173)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2025-09-02 15:43:22 -05:00
Graham Steffaniak ef416956ad updated linting 2025-09-02 13:17:46 -05:00
Graham Steffaniak d8b38de2d7 updated with a few more changes 2025-09-01 17:42:50 -05:00
Graham Steffaniak b6e70ffa6d State management (#1169) 2025-09-01 15:35:21 -05:00
Graham Steffaniak 5dc8da5064 updated with small bugfix 2025-08-29 18:54:52 -05:00
Graham Steffaniak 5967422335 Update video player embedded (#1161) 2025-08-29 18:51:48 -05:00
KuramiandGraham Steffaniak a933d7ecbf Custom Media Player (#1160)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2025-08-29 16:45:10 -05:00
Graham Steffaniak 2b61350102 access management changes (#1158) 2025-08-29 13:27:35 -05:00
Graham SteffaniakandMax 07601bb075 Feature/embedded subtitles (#1157)
Co-authored-by: Max <mail@kjmax.com>
2025-08-29 12:03:29 -05:00
MaxandGraham Steffaniak 4cfd7e00b4 Embedded subtitles support in video files (#1072)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2025-08-29 11:32:06 -05:00
Graham Steffaniak 831708fe65 Add go tool (#1154) 2025-08-28 18:26:29 -05:00
Graham Steffaniak 50bd853266 Beta/v0.8.3 (#1150) 2025-08-28 15:04:08 -05:00
Graham Steffaniak 206180de0d Beta/v0.8.2 (#1123) 2025-08-25 10:58:31 -05:00
Graham Steffaniak 5b0f388fa5 Beta/v0.8.1 (#1106) 2025-08-22 17:45:44 -05:00
7d0707ebe0 Beta/v0.8.0 (#1080)
Co-authored-by: Kurami <178341547+Kurami32@users.noreply.github.com>
Co-authored-by: Socramk <155040241+socramcaro2@users.noreply.github.com>
2025-08-20 10:04:44 -05:00
Kurami 41cb97a1c5 Updated translation for spanish (#1076) 2025-08-19 16:03:10 -05:00
Socramk 0b212ce1b4 Traduccion es (#1063) 2025-08-16 11:44:49 -05:00
Kurami 7f0a0209af Update translation for spanish (#1055) 2025-08-12 09:25:38 -05:00
Graham Steffaniakandmordillo 642333ec68 Beta/v0.7.18 (#1034)
Co-authored-by: mordillo <121123040+mordilloSan@users.noreply.github.com>
2025-07-30 17:50:07 -05:00
Graham Steffaniakandmordillo ec43926d3c Beta/v0.7.17 (#1017)
Co-authored-by: mordillo <121123040+mordilloSan@users.noreply.github.com>
2025-07-25 17:46:00 -05:00
Graham Steffaniak 9202043000 Update pr.yaml to run against main (#998) 2025-07-22 13:53:21 -05:00
Graham Steffaniak 4d04e02c31 v0.7.16-beta (#991) (#992) 2025-07-21 18:21:31 -05:00
Graham Steffaniak 04d72998b6 Beta/v0.7.15 (#988) 2025-07-20 11:38:03 -05:00
Graham Steffaniak f836a2c4df Beta/v0.7.14 (#964) 2025-07-12 16:57:31 -05:00
cc0212b754 Beta/v0.7.13 (#947)
Co-authored-by: mordillo <121123040+mordilloSan@users.noreply.github.com>
Co-authored-by: Valentyn Lavrinenko <Valentin.Lavrinenko@gmail.com>
2025-07-11 15:34:57 -05:00
Graham Steffaniak f66a4d9313 Beta/v0.7.12 (#912) 2025-07-05 17:50:22 -05:00
Graham Steffaniak 3e59bf90f7 Beta/v0.7.11 (#863) 2025-06-30 13:07:16 -05:00
Graham Steffaniak 812f30cccd Beta/v0.7.10 retagged changes (#833) 2025-06-23 10:20:04 -05:00
Graham Steffaniak 93a2e3e05e Beta/v0.7.10 (#823) 2025-06-21 15:51:00 -05:00
Jakub P.andGraham Steffaniak 1f1d1c5b44 oidc: distinguish between username and preferred_username claims (#789)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2025-06-16 19:37:05 -05:00
Graham Steffaniak c581ea760e updated for main (#800) 2025-06-16 19:35:04 -05:00
Graham Steffaniak e3dc484c28 Update README.md (#788) 2025-06-15 16:27:27 -05:00
Graham Steffaniak 0ada9eeffa Gtsteffaniak patch 1 (#780) 2025-06-14 16:35:26 -05:00
Graham Steffaniak 412c68232c Update README.md (#779) 2025-06-14 16:29:52 -05:00
Graham Steffaniak 5abbe1d64b Beta/v0.7.9 (#778) 2025-06-14 16:04:18 -05:00
Adrian-Ryan AcalaandGraham Steffaniak 80b1259dd1 docs: add comprehensive CONTRIBUTING.md guide (#748)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2025-06-11 17:13:34 -05:00
Graham SteffaniakandGilles 96cf2d6a5e Beta/v0.7.8 (#742)
Co-authored-by: Gilles <gilles@wittezaele.fr>
2025-06-09 20:21:33 -05:00
Graham Steffaniak a69ad4b879 update workflow 2025-06-06 20:06:38 -05:00
Graham SteffaniakandGilles f40209751e Beta/v0.7.7 (#728)
Co-authored-by: Gilles <gilles@wittezaele.fr>
2025-06-06 19:31:23 -05:00
Gilles 9f156fdefb Update fr with no ai translation (#723) 2025-06-06 10:12:54 -05:00
Graham Steffaniak 093acb91b1 updated goreleaser (#716) 2025-06-04 17:23:10 -05:00
Graham Steffaniak 36e826af7f updated releaser 3 (#712) 2025-06-04 16:17:44 -05:00
Graham Steffaniak 02650e62c4 updated (#711) 2025-06-04 16:10:17 -05:00
Graham Steffaniak b1f97d3afa Try all goreleasers (#710) 2025-06-04 15:48:55 -05:00
Graham Steffaniak 8faaeb39db build-all-goreleaser (#709) 2025-06-04 15:19:18 -05:00
Graham Steffaniak 04911ca7b0 Update releaser (#708) 2025-06-04 14:56:15 -05:00
Graham Steffaniak 0a9af58b07 Beta/v0.7.6 (#697) 2025-06-02 09:31:01 -05:00
Graham Steffaniak 40374752a3 Beta/v0.7.5 (#683) 2025-05-27 20:19:29 -05:00
Philippe c667218e30 Update fr.json (#654) 2025-05-18 14:12:26 -05:00
Graham Steffaniak 183d2d89e2 updated with fix for create user (#649) (#651) 2025-05-18 14:11:32 -05:00
Christoph Auer 9c1d1a58cd Corrections to German translation (#644) 2025-05-17 16:37:42 -05:00
Graham Steffaniak c4a5b0dcf9 Update README.md (#646) 2025-05-16 16:44:49 -05:00
Graham SteffaniakandFileRun 7ae5a0fdcb Beta/v0.7.3 release (#640)
Co-authored-by: FileRun <filerun@users.noreply.github.com>
2025-05-15 14:07:38 -05:00
Graham Steffaniak f73ab72d46 Update README.md (#630) 2025-05-11 19:09:02 -05:00
Graham Steffaniak 1ba5d35ba8 revert enforced login method (#612) (#614) 2025-05-09 11:52:04 -05:00
Graham Steffaniak 5adbc065ed Beta/v0.7.1 release (#607) 2025-05-08 10:27:19 -05:00
Graham Steffaniak ad06eaed3e Update release_docker.yaml (#581) 2025-05-03 11:16:32 -05:00
Graham Steffaniak d2ed17e4d9 Update release_docker.yaml (#580) 2025-05-03 10:48:16 -05:00
Graham Steffaniak 3813ced6bd Rename release_docker to release_docker.yaml (#579) 2025-05-03 10:26:57 -05:00
Graham Steffaniak 5c41bbd7a7 Create release_docker (#578) 2025-05-03 10:24:26 -05:00
Graham Steffaniak 7da6115efe Workflow update (#577) 2025-05-03 09:38:00 -05:00
Graham SteffaniakandAaron Ky-Riesenbach fb5bab79e1 Beta/v0.7.0 (#572)
Co-authored-by: Aaron Ky-Riesenbach <aaron@kyriesenba.ch>
2025-05-02 19:15:58 -05:00
Graham Steffaniak 2e2fe5200c Update .goreleaser.yaml (#570) 2025-05-01 19:02:24 -05:00
Graham Steffaniak 418b187acf sync validation change with beta v0.6.8 (#533) 2025-04-14 16:49:19 -05:00
Graham SteffaniakandAaron Ky-Riesenbach 2972772dda Beta/v0.6.8 (#526)
Co-authored-by: Aaron Ky-Riesenbach <aaron@kyriesenba.ch>
2025-04-12 10:32:28 -05:00
Graham Steffaniak a83f39d41f Beta/v0.6.7 (#507) 2025-04-08 10:05:52 -05:00
Graham Steffaniak 2de5dbaf68 updated docker directory perms (#500) 2025-04-06 16:57:14 -05:00
Graham Steffaniak db77552bd8 bump v0.6.6 release with share fix for file (#489) 2025-04-04 09:37:31 -05:00
Graham Steffaniak 2c26b6fc45 Beta/v0.6.6 (#485) 2025-04-03 18:29:31 -05:00
Graham Steffaniak 948add54fc Beta/v0.6.5 (#469) 2025-04-01 12:00:07 -05:00
Graham Steffaniak f56fa85fa4 fixed issues for 0.6.4 (#463) (#464) 2025-03-28 18:48:45 -05:00
Graham Steffaniak 23db04bacb Update README.md (#462) 2025-03-28 09:44:30 -05:00
Christoph Auer dfe024ab62 i18n: Small improvements to de.json (#453) 2025-03-26 18:47:34 -05:00
Graham SteffaniakandNguyễn Trần Chung ce292c7be1 Beta/v0.6.3 (#450)
Co-authored-by: Nguyễn Trần Chung <nguyentranchung52th@gmail.com>
2025-03-24 18:12:06 -05:00
Nguyễn Trần Chung e39cf2ddf9 Update makefile (#445) 2025-03-22 10:29:38 -07:00
Graham Steffaniak 6909df0071 Beta/v0.6.2 (#443) 2025-03-20 18:29:42 -05:00
Graham Steffaniak 8460ddbc13 Beta/v0.6.1 (#428) 2025-03-11 17:48:30 -05:00
Graham Steffaniak be374d75f5 Beta/v0.6.0 (#415) 2025-03-09 11:13:57 -05:00
Graham SteffaniakandAnoop Sundaresh c153a39543 Beta/v0.5.4 (#397)
Co-authored-by: Anoop Sundaresh <16442416+theryecatcher@users.noreply.github.com>
2025-02-23 17:04:47 -06:00
Graham Steffaniak 3d51a5af7a Update tag.yaml (#391) 2025-02-23 16:22:46 -06:00
Graham Steffaniak e6850feddc Update tag.yaml (#390) 2025-02-23 16:17:48 -06:00
Graham Steffaniak a6f7883e64 updated playwright image (#378) 2025-02-17 10:56:46 -06:00
Graham Steffaniak f28115afc3 updated workflows (#377) 2025-02-17 10:51:16 -06:00
Graham Steffaniak c4ccfd48f5 Beta/v0.5.3 (#374) 2025-02-16 08:07:38 -06:00
Graham Steffaniak c84a3b0d41 Beta/v0.5.2 release (#356) 2025-02-07 18:12:11 -06:00
Graham Steffaniak 545a831c66 simple bugfixes beta/v0.5.1 (#344) 2025-02-01 07:10:46 -06:00
Graham Steffaniak e269fc8c0a Beta/v0.5.0 (#333) 2025-01-31 14:26:21 -06:00
Graham Steffaniak 0bc789f9bd Update README.md (#320) 2025-01-27 16:40:31 -06:00
402 changed files with 51370 additions and 14779 deletions

No files matched your search

+2
View File
@@ -1,4 +1,6 @@
backend/**.db
backend/tmp
backend/backend*
backend/vendor/**
frontend/dist/**
frontend/node_modules/**
+6 -16
View File
@@ -1,20 +1,10 @@
**Description**
<!--
Please explain the changes you made here.
If the feature changes current behaviour, explain why your solution is better.
-->
:rotating_light: Before submitting your PR, please read [community](https://github.com/filebrowser/community), and indicate which issues (in any of the repos) are either fixed or closed by this PR. See [GitHub Help: Closing issues using keywords](https://help.github.com/articles/closing-issues-via-commit-messages/).
According to the [contributing guide](https://github.com/gtsteffaniak/filebrowser/wiki/Contributing#contributing-as-an-unofficial-contributor), A PR should contain:
- [ ] DO make sure you are requesting to **pull a topic/feature/bugfix branch** (right side). Don't request your master!
- [ ] DO make sure you are making a pull request against the **master branch** (left side). Also you should start *your branch* off *our master*.
- [ ] DO make sure that File Browser can be successfully built. See [builds](https://github.com/filebrowser/community/blob/master/builds.md) and [development](https://github.com/filebrowser/community/blob/master/development.md).
- [ ] DO make sure that related issues are opened in other repositories. I.e., the frontend, caddy plugins or the web page need to be updated accordingly.
- [ ] AVOID breaking the continuous integration build.
- [ ] A clear description of why it was opened.
- [ ] A short title that best describes the change.
- [ ] Must pass unit and integration tests, which can be run checked locally prior to opening a PR.
- [ ] Any additional details for functionality not covered by tests.
**Further comments**
<!--
If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did, what alternatives you considered, etc.
:heart: Thank you!
-->
**Additional Details**
+58
View File
@@ -0,0 +1,58 @@
name: Create Dev Version
on:
workflow_dispatch:
inputs:
version:
description: "Version to create (format: 0.0.0)"
required: true
type: string
jobs:
create-dev-branch:
name: Create Dev Branch
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }} # Uses the Personal Access Token
- name: Validate version format
id: validate_version
run: |
VERSION="${{ github.event.inputs.version }}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ ERROR: Version must be in '0.0.0' format."
exit 1
fi
echo "✅ Version format validated: $VERSION"
- name: Check if branch already exists
id: check_branch
run: |
VERSION="${{ github.event.inputs.version }}"
DEV_BRANCH="dev/v$VERSION"
if git ls-remote --exit-code origin "$DEV_BRANCH"; then
echo "❌ ERROR: Branch '$DEV_BRANCH' already exists!"
exit 1
fi
echo "✅ Branch '$DEV_BRANCH' does not exist."
- name: Create and push new dev branch
run: |
VERSION="${{ github.event.inputs.version }}"
DEV_BRANCH="dev/v$VERSION"
# Fetch latest changes
git fetch origin main
# Create new branch from main
git checkout -b "$DEV_BRANCH" origin/main
# Push branch to remote
git push origin "$DEV_BRANCH"
echo "✅ Successfully created branch '$DEV_BRANCH'!"
+2 -41
View File
@@ -6,7 +6,7 @@ on:
- "main"
jobs:
test_frontend:
test_playwright:
name: Test Playwright
runs-on: ubuntu-latest
steps:
@@ -28,44 +28,5 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
file: ./_docker/Dockerfile.playwright-general
push: false
push_latest_to_registry:
needs: [ test_frontend ]
name: Push latest
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Find latest beta tag
run: |
echo "LATEST_TAG=$(git describe --tags $(git rev-list --tags="*-beta*" --max-count=1))" >> $GITHUB_ENV
echo "latest tag is $LATEST_TAG"
- 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: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
build-args: |
VERSION=${{ env.LATEST_TAG }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./Dockerfile
push: true
tags: 'gtstef/filebrowser:latest'
labels: ${{ steps.meta.outputs.labels }}
+102 -20
View File
@@ -3,37 +3,117 @@ name: PR Request
on:
pull_request:
branches:
- "dev/v[0-9]+.[0-9]+.[0-9]+"
- "beta/v[0-9]+.[0-9]+.[0-9]+"
- "stable/v[0-9]+.[0-9]+.[0-9]+"
- "main"
- "dev/v*.*.*"
- "beta/v*.*.*"
- "stable/v*.*.*"
jobs:
test_frontend:
name: Test Playwright
prime_docker_cache:
name: Prime docker cache
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Prime Cache
uses: docker/build-push-action@v6
with:
context: .
file: _docker/Dockerfile.primecache
push: false
load: false
cache-to: |
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
build_frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Build frontend
working-directory: frontend
run: npm i && npm run build
- name: Upload frontend build artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: backend/http/dist
build_backend:
name: Build backend
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Build backend
working-directory: backend
run: go build -o filebrowser .
- name: Upload backend build artifact
uses: actions/upload-artifact@v4
with:
name: filebrowser
path: backend/filebrowser
test_playwright:
needs: [build_backend, build_frontend, prime_docker_cache]
name: Test Playwright - ${{ matrix.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: general
dockerfile: ./_docker/Dockerfile.playwright-general
- name: proxy
dockerfile: ./_docker/Dockerfile.playwright-proxy
- name: noauth
dockerfile: ./_docker/Dockerfile.playwright-noauth
- name: oidc
dockerfile: ./_docker/Dockerfile.playwright-oidc
- name: sharing
dockerfile: ./_docker/Dockerfile.playwright-sharing
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download frontend build
uses: actions/download-artifact@v4
with:
name: dist
path: frontend/dist
- name: Download backend build
uses: actions/download-artifact@v4
with:
name: filebrowser
path: backend/
- name: Make backend executable
run: chmod +x backend/filebrowser
- name: Copy frontend build to backend
run: |
mkdir -p backend/http/embed
cp -r frontend/dist/* backend/http/embed
- 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
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
- name: Build and Test
id: build
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
file: ${{ matrix.dockerfile }}
push: false
load: true
cache-from: |
type=gha,scope=${{ github.ref_name }}-playwright-base
type=gha,scope=main-playwright-base
cache-to: |
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
push_pr_to_registry:
name: Push PR
if: github.event.pull_request.head.repo.fork == false
name: Build docker image
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -56,10 +136,12 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
file: ./_docker/Dockerfile.slim
push: false # Do not push the image for now
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
REVISION=${{ steps.meta.outputs.revision }}
REVISION=${{ steps.meta.outputs.revision }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -0,0 +1,110 @@
name: Promote Beta to Stable
on:
workflow_dispatch:
inputs:
version:
description: "Version to promote (format: 0.0.0)"
required: true
type: string
jobs:
promote-beta-to-stable:
name: Promote Beta to Stable
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }} # Uses Personal Access Token for branch operations
- name: Validate version format
id: validate_version
run: |
VERSION="${{ github.event.inputs.version }}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ ERROR: Version must be in '0.0.0' format."
exit 1
fi
echo "✅ Version format validated: $VERSION"
- name: Check if beta branch exists
id: check_beta_branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
if ! git ls-remote --exit-code origin "$BETA_BRANCH"; then
echo "❌ ERROR: Beta branch '$BETA_BRANCH' does not exist!"
exit 1
fi
echo "✅ Beta branch '$BETA_BRANCH' exists."
- name: Check if stable branch already exists
id: check_stable_branch
run: |
VERSION="${{ github.event.inputs.version }}"
STABLE_BRANCH="stable/v$VERSION"
if git ls-remote --exit-code origin "$STABLE_BRANCH"; then
echo "❌ ERROR: Stable branch '$STABLE_BRANCH' already exists!"
exit 1
fi
echo "✅ Stable branch '$STABLE_BRANCH' does not exist."
- name: Create and push new stable branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
STABLE_BRANCH="stable/v$VERSION"
# Fetch latest changes
git fetch origin "$BETA_BRANCH"
# Create new stable branch from beta branch
git checkout -b "$STABLE_BRANCH" origin/"$BETA_BRANCH"
# Push new stable branch to remote
git push origin "$STABLE_BRANCH"
echo "✅ Successfully created stable branch '$STABLE_BRANCH' from '$BETA_BRANCH'."
- name: Merge beta into stable
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
STABLE_BRANCH="stable/v$VERSION"
# Checkout stable branch
git checkout "$STABLE_BRANCH"
# Merge beta into stable
git merge --no-ff "origin/$BETA_BRANCH" -m "Merge $BETA_BRANCH into $STABLE_BRANCH"
# Push merge changes
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
@@ -0,0 +1,99 @@
name: Promote Dev to Beta
on:
workflow_dispatch:
inputs:
version:
description: "Version to promote (format: 0.0.0)"
required: true
type: string
jobs:
promote-dev-to-beta:
name: Promote Dev to Beta
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }} # Uses the Personal Access Token
- name: Validate version format
id: validate_version
run: |
VERSION="${{ github.event.inputs.version }}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ ERROR: Version must be in 'v0.0.0' format."
exit 1
fi
echo "✅ Version format validated: $VERSION"
- name: Check if dev branch exists
id: check_dev_branch
run: |
VERSION="${{ github.event.inputs.version }}"
DEV_BRANCH="dev/v$VERSION"
if ! git ls-remote --exit-code origin "$DEV_BRANCH"; then
echo "❌ ERROR: Dev branch '$DEV_BRANCH' does not exist!"
exit 1
fi
echo "✅ Dev branch '$DEV_BRANCH' exists."
- name: Check if beta branch already exists
id: check_beta_branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
if git ls-remote --exit-code origin "$BETA_BRANCH"; then
echo "❌ ERROR: Beta branch '$BETA_BRANCH' already exists!"
exit 1
fi
echo "✅ Beta branch '$BETA_BRANCH' does not exist."
- name: Create and push new beta branch
run: |
VERSION="${{ github.event.inputs.version }}"
DEV_BRANCH="dev/v$VERSION"
BETA_BRANCH="beta/v$VERSION"
# Fetch latest changes
git fetch origin "$DEV_BRANCH"
# Create new beta branch from dev branch
git checkout -b "$BETA_BRANCH" origin/"$DEV_BRANCH"
# Push new beta branch to remote
git push origin "$BETA_BRANCH"
echo "✅ Successfully created beta branch '$BETA_BRANCH' from '$DEV_BRANCH'."
- name: Merge dev into beta
run: |
VERSION="${{ github.event.inputs.version }}"
DEV_BRANCH="dev/v$VERSION"
BETA_BRANCH="beta/v$VERSION"
# Checkout beta branch
git checkout "$BETA_BRANCH"
# Merge dev into beta
git merge --no-ff "origin/$DEV_BRANCH" -m "Merge $DEV_BRANCH into $BETA_BRANCH"
# Push merge changes
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'."
+4 -3
View File
@@ -1,7 +1,7 @@
name: regular tests
on:
push:
pull_request:
branches:
- "**"
@@ -22,10 +22,11 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- uses: golangci/golangci-lint-action@v5
- uses: golangci/golangci-lint-action@v7
with:
version: v1.60
version: 'v2.1.6'
working-directory: backend
install-mode: goinstall
format-backend:
runs-on: ubuntu-latest
steps:
+5 -46
View File
@@ -9,8 +9,8 @@ permissions:
contents: write
jobs:
test_frontend:
name: Test Playwright
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -31,10 +31,10 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_frontend ]
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-latest
steps:
@@ -56,7 +56,7 @@ jobs:
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ steps.extract_branch.outputs.branch_name }}
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
@@ -64,44 +64,3 @@ jobs:
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
push_release_to_registry:
needs: [ test_frontend ]
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: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
- name: Strip v from version number
id: modify-json
run: |
JSON="${{ steps.meta.outputs.tags }}"
# Use jq to remove 'v' from the version field
JSON=$(echo "$JSON" | sed 's/filebrowser:beta\/v/filebrowser:beta_v/')
echo "cleaned_tag=$JSON" >> $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,linux/arm64,linux/arm/v7
file: ./Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+21 -9
View File
@@ -24,18 +24,30 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- 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
- name: Strip v from version number
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify version names
id: modify-json
run: |
JSON="${{ steps.meta.outputs.tags }}"
# Use jq to remove 'v' from the version field
JSON=$(echo "$JSON" | sed 's/filebrowser:dev\/v/filebrowser:dev_v/')
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
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:
@@ -43,8 +55,8 @@ jobs:
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: ./Dockerfile
platforms: linux/amd64,linux/arm64
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+4 -45
View File
@@ -9,7 +9,7 @@ permissions:
contents: write
jobs:
test_frontend:
test_playwright:
name: Test Playwright
runs-on: ubuntu-latest
steps:
@@ -31,10 +31,10 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_frontend ]
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-latest
steps:
@@ -56,51 +56,10 @@ jobs:
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ steps.extract_branch.outputs.branch_name }}
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
push_release_to_registry:
needs: [ test_frontend ]
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: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
- name: Strip v from version number
id: modify-json
run: |
JSON="${{ steps.meta.outputs.tags }}"
# Use jq to remove 'v' from the version field
JSON=$(echo "$JSON" | sed 's/filebrowser:stable\/v/filebrowser:/')
echo "cleaned_tag=$JSON" >> $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,linux/arm64,linux/arm/v7
file: ./Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+138 -2
View File
@@ -20,8 +20,9 @@ jobs:
- name: Install dependencies and build frontend
run: npm i && npm run build
working-directory: frontend
- name: Install UPX
run: sudo apt-get install -y upx
- name: Install OS Requirements
run: sudo apt update && sudo apt install -y upx build-essential musl-tools
- uses: goto-bus-stop/setup-zig@v2
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -29,3 +30,138 @@ jobs:
workdir: backend
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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 tags (strip 'v' and add 'beta'/'stable' if needed)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not)
) |
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end |
if any(.tags[]; test("stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end
')
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
echo "cleaned_tag=$TAGS_CSV" >> $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,linux/arm64
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
push_slim_release_to_registry:
name: Push slim 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 tags (strip 'v' and add 'slim' if needed)
id: modify-json-slim
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not) # Remove the "latest" tag
| . + "-slim" # Append "-slim" to the versioned tag
) |
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta-slim",
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
]
else . end |
if any(.tags[]; test("stable")) then
.tags += [
"gtstef/filebrowser:stable-slim",
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
]
else . end
')
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push slim image
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,linux/arm64,linux/arm/v7
file: ./_docker/Dockerfile.slim
push: true
tags: ${{ steps.modify-json-slim.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+6
View File
@@ -9,14 +9,18 @@ rice-box.go
/backend/filebrowser
/backend/filebrowser.exe
/backend/backend.exe
/backend/tmp
/backend/dist
/frontend/dist
/frontend/pkg
/frontend/test-results
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/frontend/package-lock.json
/backend/vendor
/backend/*.cov
/backend/test_config.yaml
/backend/generated.yaml
/backend/srv
/backend/http/dist
/backend/http/embed/*
@@ -43,3 +47,5 @@ yarn-error.log*
*.sw*
bin/
build/
*__debug*
backend/custom.css
+795 -7
View File
@@ -2,9 +2,797 @@
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).
## v0.8.4-beta
**New Features**:
- Remember folder view type (and sorting) for each folder #966
- heic is supported in preview and viewer (via conversion) when on non-safari browser #1191
**Notes**:
**BugFixes**:
- Access Management: issue with access settings #1195
- fix shutdown panic related to sse connection
## v0.8.4-beta
**New Features**:
- New media player styles and features
- Custom Media Player: enhanced media player using plyr thanks @Kurami32 (see #1160)
- Custom Media Player: also adds support for metadata
- added embeded video subtitle support (for both native and custom player). @maxbin123 #1072 #1157
- Users can disable the customer player and opt of native in profile settings.
- Option to disable backend update check via `server.disableUpdateCheck` #1134
- added `frontend.favicon` and `frontend.description` for html overrides
- onlyoffice is now supported in shares. Both viewing and editing can be configured per-share.
- Added only office debug view and wiki to assist with debugging issues #1068 #911 #1074
- Dark mode enforcement possible for shared links #1029
- added `System & Admin` section to settings
- includes a new config viewer to see current running config (hides secrets) #838
- added `server.minSearchLength` to allow adjusting the length requirement for search #1174
**Notes**:
- access management: specific folders/files with access are shown instead permission denied for parent folder
- navigation no longer appends last location hash which should fix some unwanted navation behavior #1070
- altered the context menu style and behavior.
- documentation update: comma or Space separated extensions #1138
- Files and folders can be created with "/" or "\" on the name #1126
- Share management should not be allowed without authentication #1163
- Question about customizing session timeout #1184
**BugFixes**:
- access management: delay showing rule changes in the list fixed. #1131
- Color names are not localized #1159
- rename issues #1170 #1171
- some shortcuts not working #1056
- Can't copy/paste text on mobile #1168
- Can't change between images inside of the share image viewer. #1144
- fixed and updated translations with variables always showing english.
## v0.8.3-beta
**BugFixes**:
- fixed search bar style bug in mobile #1147
## v0.8.2-beta
**New Features**:
- added `source.config.denyByDefault` configuration to enable a deny-by-default access rule. A source enabled with this will deny access unless an "allow" rule was specifically created. (Similar to creating a root-level denyAll rule)
- allow oidc user source access and permission based on username and groups is fulfilled by denyByDefault source with access rules https://github.com/gtsteffaniak/filebrowser/issues/824
- "open parent folder" in context menu and search results https://github.com/gtsteffaniak/filebrowser/issues/1121
- added friendly "share not found" page.
**Notes**:
- 8.0 ffmpeg version bundled with docker
- go 1.25 upgrade with green tea GC enabled
- totp secrets accept non-secure strings, only throwing warning
- adjusted download limit so it also counts viewing text "content" of files (like in editor). You can also "disable file viewing" to stop the editor from showing. lower quality file image previews are not counted as downloads.
- updated invalid share message to be more clear https://github.com/gtsteffaniak/filebrowser/issues/1120
**BugFixes**:
- fixed /public/static routes issue
- shares redirect to login - https://github.com/gtsteffaniak/filebrowser/issues/1109
- some static assets not available to anonymous user - https://github.com/gtsteffaniak/filebrowser/issues/1102
- more safari style issues https://github.com/gtsteffaniak/filebrowser/issues/1110
- fix public share download issues https://github.com/gtsteffaniak/filebrowser/issues/1118 https://github.com/gtsteffaniak/filebrowser/issues/1089
- fixed disable file viewer setting and enforced on backend
## v0.8.1-beta
**New Features**:
- api for generate download link (see swagger) https://github.com/gtsteffaniak/filebrowser/issues/1007
- added `source.config.disabled` option to disable a source without removing it from config file.
- added `source.config.private` option to designate as private -- currently just means no sharing permitted.
- hide share card in share
- download count for a share shows up on share management
**Notes**:
- updated description for indexingIntervalMinutes https://github.com/gtsteffaniak/filebrowser/issues/1067
**BugFixes**:
- fixed styling issues https://github.com/gtsteffaniak/filebrowser/issues/1086 https://github.com/gtsteffaniak/filebrowser/issues/1081 https://github.com/gtsteffaniak/filebrowser/issues/1082 https://github.com/gtsteffaniak/filebrowser/issues/1098
- fix download limit issue https://github.com/gtsteffaniak/filebrowser/issues/1085
- fixed oidc user defaults for new user https://github.com/gtsteffaniak/filebrowser/issues/1071
- shares get updated when files moved in ui https://github.com/gtsteffaniak/filebrowser/issues/760
- click listing behavior doesn't clear (introduced in 0.8.0) https://github.com/gtsteffaniak/filebrowser/issues/1101
- show download count and limit in share list in settings https://github.com/gtsteffaniak/filebrowser/issues/1103
- fix windows alt+arrow movement issue https://github.com/gtsteffaniak/filebrowser/issues/1094
- nav memory issue for filenames with brackets https://github.com/gtsteffaniak/filebrowser/issues/1092
- files with "+"" in name issue https://github.com/gtsteffaniak/filebrowser/issues/1089
- fixed editor bug in share view https://github.com/gtsteffaniak/filebrowser/issues/1084
- other share related issues https://github.com/gtsteffaniak/filebrowser/issues/1087 https://github.com/gtsteffaniak/filebrowser/issues/1064
## v0.8.0-beta
This is a major release, new features and changes could introduce breaking behavior. Here are the known potentially breaking changes:
- all public api and share url's get a `/public` prefix, making it easier to use with a reverse proxy. Any existing share link will still work but get redirected.
- a small change to styling you may need to update your custom styling, for example the id `#input` was renamed `#search-input`
**New Features**:
- New access control system. You can add new allow / deny / denyAll rules for users/groups for specific paths on specific sources.
- groups currently only works with provided oidc groups, but will add a full group management option for manual creation. https://github.com/gtsteffaniak/filebrowser/issues/545
- share view changes -- now aligns with the standard listing view. This means files can be viewed and edited (if permission allows) just like a normal listing.
- many share links customization enhancements
- only share to certain authenticated users https://github.com/gtsteffaniak/filebrowser/issues/656 https://github.com/gtsteffaniak/filebrowser/issues/985
- one-time download links
- customize share theme https://github.com/gtsteffaniak/filebrowser/issues/827 https://github.com/gtsteffaniak/filebrowser/issues/1029
- share link public changes https://github.com/gtsteffaniak/filebrowser/issues/473
- shares can be modified/configured after creation.
- download throttling for shares
**Notes**:
- hover effect on list/compact view https://github.com/gtsteffaniak/filebrowser/issues/1036
**BugFixes**:
- fix new file "true" content issue https://github.com/gtsteffaniak/filebrowser/issues/1048
- editor allows device default popup https://github.com/gtsteffaniak/filebrowser/issues/1049
## v0.7.18-beta
**Notes**:
- desktop context menu "select multiple" enabled as optional user default (#1000)
- onlyoffice readonly document types (".pages", ".numbers", ".key") list (#1018)
- onlyoffice tweaks to make more consistent, added logging (#1015)
**BugFixes**:
- fix lightBackground issue (#1021)
- fix user save issues (#1020, #1027)
- fix image preview cache issue (#989)
- fix file/folder count issue (#989)
- only first file was upload on drag-n-drop (#1024)
## v0.7.17-beta
See an example of custom css styling that uses the reduce-rounded-corners.css by default and allows users to choose other themes. You can add your own themes as well that users can choose from in profile settings:
```
frontend:
styling:
lightBackground: "#f0f0f0" # or names of css colors
darkBackground: "#121212"
customCSS: "custom.css" # custom css file always applies first, then user themes on top of that.
customThemes:
"default": # if "default" is specified as the name, it will be the default option
description: "Reduce rounded corners"
css: "reduce-rounded-corners.css" # path to css file to use
"original":
description: "Original rounded theme"
css: "" # you could default to no styling changes this way.
```
**New Features**:
- more custom styling options (thanks @mordilloSan for #997)
- background colors can be easily set in config
- provided an example `reduce-rounded-corners.css` available by default in docker. (#986, #837)
- added feature to specify multiple css themes that users can choose from in profile settings
- swipe between photos on mobile (#825)
**Notes**:
- changed partition calculations on linux for total disk size (#982)
- upload conflict detection for folders offers "replace all" if the folder already exists in target location.
**BugFixes**:
- TOTP prompt not showing generated code issue https://github.com/gtsteffaniak/filebrowser/issues/996
- select mulitple deselect on mobile (#1002)
- viewing svg images.
## v0.7.16-beta
**Notes**:
- more server logging for uploads when debug logging is enabled
**BugFixes**:
- fix onlyoffice integration viewing bug (#990)
- fix uploading files with exec permissions (#984)
- fix redirect on no source path (#989)
- refresh file info on rename (#989)
- listing refreshes when uploads finish (#989)
- disable edit mode for certain onlyoffice files (#971)
## v0.7.15-beta
**New Features**:
- added userDefault `disableViewingExt`. The new properties apply to all files, not just office.
- code blocks in markdown viewer have line numbers and each line is highlightable
**Notes**:
- replaced `disableOfficePreviewExt` with more generally applicable `disablePreviewExt` to disable preview for any specific file type.
- more tooltip descriptions for settings options
**BugFixes**:
- fix chinese and other language error (#972, #969)
- fix docker dockerfile for `docker run` (#973)
- fix double slash href on single source (#968)
- fix sources named "files" or "share" issue (#949, #574)
- focus input field on popups (#976)
- hopeful fix for size calculation (#982)
- edit button is not working on .md files (#983)
## v0.7.14-beta
**Notes**:
- Updated translations https://github.com/gtsteffaniak/filebrowser/issues/957
- enabled more doc types for onlyoffice https://github.com/gtsteffaniak/filebrowser/discussions/945
**BugFixes**:
- noauth user issue https://github.com/gtsteffaniak/filebrowser/issues/955
- error 403 on source name with special characters https://github.com/gtsteffaniak/filebrowser/issues/952
- delete pictures in previewer issue https://github.com/gtsteffaniak/filebrowser/issues/456
- trailing slash source name issue https://github.com/gtsteffaniak/filebrowser/issues/920
- image lazy loading issue causing all items to get previews at one time, not just whats in view.
## v0.7.13-beta
**New Features**:
- copy and Move files between sources https://github.com/gtsteffaniak/filebrowser/issues/689
- new enhanced upload prompt
- uses chunked uploads https://github.com/gtsteffaniak/filebrowser/issues/770
- all or individual uploads can be paused/resumed
- individual uploads can be retried
- individual file upload progress https://github.com/gtsteffaniak/filebrowser/issues/871
- keeps screen on https://github.com/gtsteffaniak/filebrowser/issues/900
**Notes**:
- lots of UI improvements
- reworked a lot of the frontend path/source logic to be more consistent.
- updated sort behavior to be natural sort https://github.com/gtsteffaniak/filebrowser/issues/551
- optional quick save icon https://github.com/gtsteffaniak/filebrowser/issues/918
- improved language support: zh-tw chinese traditional (tawain)
**BugFixes**:
- more accurate disk used calculation -- accounting for hard links and sparse files. https://github.com/gtsteffaniak/filebrowser/issues/921
- fix api key revoking mechanism
- fixed shift-select https://github.com/gtsteffaniak/filebrowser/issues/929
- video preview images on safari https://github.com/gtsteffaniak/filebrowser/issues/932
- sticky mode isn't sticky https://github.com/gtsteffaniak/filebrowser/issues/916
## v0.7.12-beta
Happy 4th of July!
The most noteworthy change is that no sources will be automatically enabled for any user. In order for a user to use a source, it needs to be added for that user. Or to keep a source available for all users, you can specify `defaultEnabled` in the source config to maintain the same behavior. See the wiki
**New Features**:
- setting added `deleteWithoutConfirming`, useful for quickly deleting files -- does not apply to folders.
- more options for minimal UI https://github.com/gtsteffaniak/filebrowser/issues/745
- dedicated section for sidebar customization in profile settings https://github.com/gtsteffaniak/filebrowser/issues/437
**Notes**:
- Filebrowser no longer requires a default source, users can be created without any sources.
- Disables changing login type fallback behavior https://github.com/gtsteffaniak/filebrowser/issues/620
- Uses calculated index size as "used" and total partition size as "total" https://github.com/gtsteffaniak/filebrowser/issues/875
- Select multiple won't show up in context menu when using a desktop browser (with keyboard), opting for keyboard shortcuts
- Updated translations that were not complete, such as simplified chinese https://github.com/gtsteffaniak/filebrowser/issues/895
- larger min drop target size https://github.com/gtsteffaniak/filebrowser/issues/902
- refresh page after file actions https://github.com/gtsteffaniak/filebrowser/issues/894
- improved user PUT handler for easier user modification via API https://github.com/gtsteffaniak/filebrowser/issues/897
- optional sidebar actions for upload/create https://github.com/gtsteffaniak/filebrowser/issues/885
**BugFixes**:
- fix delete in preview when moving between pictures. https://github.com/gtsteffaniak/filebrowser/issues/456
- getting file info issue when indexing is disabled.
- fixed initial sort order https://github.com/gtsteffaniak/filebrowser/issues/551
- incorrect filename Drag and Drop fixes https://github.com/gtsteffaniak/filebrowser/issues/880
- fix share duration always showing just now https://github.com/gtsteffaniak/filebrowser/issues/896
## v0.7.11-beta
**Breaking Changes**:
- `auth.resetAdminOnStart` has been removed. Instead, if you have `auth.adminPassword` set it will always be reset on startup. If you want to change your default admin password afterwards, make sure to unset `auth.adminPassword` so it doesn't get reset on startup.
- renamed include/exclude rules see [updated example wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration-And-Examples#example-advanced-source-config)!
**New Features**:
- more comprehensive exclude/include rules (see example wiki above).
- include/exclude parts of folder names as well https://github.com/gtsteffaniak/filebrowser/issues/854
- include/exclude file or folder names globally.
- `source.config.neverWatchPaths` is now functional -- a list of paths that get indexed initially, but skips re-indexing. Useful for directories you don't expect to change ever, still show up in search but get don't contribute to indexing time after initial indexing.
**Notes**:
- updated swagger docs https://github.com/gtsteffaniak/filebrowser/issues/849
**BugFixes**:
- fix version update notification for binary https://github.com/gtsteffaniak/filebrowser/issues/836
- ctrl-click cache issue https://github.com/gtsteffaniak/filebrowser/issues/735
- fix admin user reset OIDC user https://github.com/gtsteffaniak/filebrowser/issues/811 https://github.com/gtsteffaniak/filebrowser/issues/851
- fix windows and binary muPdf issue https://github.com/gtsteffaniak/filebrowser/issues/744
- fix logout oidc issue https://github.com/gtsteffaniak/filebrowser/issues/829 https://github.com/gtsteffaniak/filebrowser/issues/662
- file name upload bug https://github.com/gtsteffaniak/filebrowser/issues/662
- could not create share with absolute timestamps enabled https://github.com/gtsteffaniak/filebrowser/issues/764
- context menu off screen issue https://github.com/gtsteffaniak/filebrowser/issues/828
## v0.7.10-beta
**OIDC change**: if you specify `oidc.userIdentifier: "username"`, originally this would map to `preferred_username` but now it maps to `username` explicitly. To maintain the same behavior update your config to `userIdentifier: "preferred_username"`. This was updated to allow for `username` to work as [some might need](https://github.com/gtsteffaniak/filebrowser/pull/789).
**New Features**:
- Added settings option to stop sidebar from automatically hiding on editor and previews. https://github.com/gtsteffaniak/filebrowser/issues/744
- Added more secrets loadable from environment variables. https://github.com/gtsteffaniak/filebrowser/issues/790
- Include/exclude files are checked for existence to assist with configuration, will show as warning if something is configured but doesn't exist.
- Added open in new tab link for preview items to view the raw picture, pdf, etc. Especially helpful for safari viewing PDF documents. https://github.com/gtsteffaniak/filebrowser/issues/734
- Added autoplay media toggle in user profile, to automatically play videos and audio.
**Notes**:
- Allowed to delete default admin user https://github.com/gtsteffaniak/filebrowser/issues/811 https://github.com/gtsteffaniak/filebrowser/issues/762
- Better try/catch error handling for user feedback for shares https://github.com/gtsteffaniak/filebrowser/issues/732
**BugFixes**:
- Fix share scope creation issue https://github.com/gtsteffaniak/filebrowser/issues/809
- Fix oidc token logout issue https://github.com/gtsteffaniak/filebrowser/issues/791
- Non-admin users OTP issue https://github.com/gtsteffaniak/filebrowser/issues/815
- Linewrap issue for a few cases https://github.com/gtsteffaniak/filebrowser/issues/810
- BaseUrl redirect issue with proxies https://github.com/gtsteffaniak/filebrowser/issues/796
- Fix exclude still shows up in ui issue https://github.com/gtsteffaniak/filebrowser/issues/797
- Copy/move functions are async https://github.com/gtsteffaniak/filebrowser/issues/812
- fix subtitle fetch issue https://github.com/gtsteffaniak/filebrowser/issues/766
- fix location memory issue for url encoded file names
## v0.7.9-beta
**New Features**:
- Admin users will get a small notification banner for available update in sidebar with link to new release.
**Notes**:
- docker now defaults to ./data/databse.db as the database path allowing a simplified initial docker-compose.yaml. Existing configurations do not need updating.
- oidc groups header updates admin permission of existing user (either add/remove if role exists)'
- builds amd64 binary with musl for compatibility (glic error) https://github.com/gtsteffaniak/filebrowser/issues/755
- renamed `server.sources.config.disabled` to `server.sources.config.disableIndexing`
- better support for running with disabled index.
- small indexing behavior tweaks.
- markdown viewer hides sidebar https://github.com/gtsteffaniak/filebrowser/issues/744
- quick download only applies to files
**BugFixes**:
- subtitles filename issue https://github.com/gtsteffaniak/filebrowser/issues/678
- search result links not working with custom baseUrl https://github.com/gtsteffaniak/filebrowser/issues/746
- preview error for office native preview https://github.com/gtsteffaniak/filebrowser/issues/744
- more source name safety for special characters.
- shares with special character errors https://github.com/gtsteffaniak/filebrowser/issues/753
- backspace navigates back a page when typing https://github.com/gtsteffaniak/filebrowser/issues/663
- markdown viewer scrolling https://github.com/gtsteffaniak/filebrowser/issues/767
- fix user permissions updated when modifying api key permissions
- fix language change issue https://github.com/gtsteffaniak/filebrowser/issues/768 https://github.com/gtsteffaniak/filebrowser/issues/487
## v0.7.8-beta
Note: if using oidc, please update from 0.7.7 to resolve invalid_grant issue. Also - oidc no longer creates users automatically by default -- must be enabled.
**New Features**:
- More oidc user creation options https://github.com/gtsteffaniak/filebrowser/issues/685
- `auth.methods.oidc.createUser` must be true to automatically create user, defaults to false.
- `auth.methods.oidc.adminGroup` allows using oidc provider group name to enable admin user creation.
**BugFixes**:
- fix save editor info sometimes saves wrong file. https://github.com/gtsteffaniak/filebrowser/issues/701
- make ctrl select work on mac or windows. https://github.com/gtsteffaniak/filebrowser/issues/739
- oidc login failures introduced in 0.7.6 https://github.com/gtsteffaniak/filebrowser/issues/731
- oidc respects non-default baseURL
## v0.7.7-beta
This release cleans up some of the native preview (image preview) feature logic. And adds simple docx and epub viewers as well. Going through all of this, I think I know how I can add full-fledge google doc and microsoft office viewer support (no edit). But, for now "onlyOffice" remains the most comprehensive solution with most compatibility and ability to fully edit. One day, I think I will be able to integrate a minimal license-free server into the docker image. But that's something for another time.
Native preview (image preview) support is also available for linux arm64 and amd64 binaries, and windows exe.
**New Features**:
- since theres a wider kind of document preview types, a new disableOfficePreviewExt option has been added.
- native (and simple) docx and epub viewers.
- Other documents like xlsx get full size image preview when opened and no onlyoffice support.
**Notes**:
- all text mimetype files have preview support.
- high-quality preview image sizes bumped from 512x512 to 640x640 to help make text previews readable.
- no config is allowed and defaults to on source at current directory.
**BugFixes**:
- fix otp clearing on user save https://github.com/gtsteffaniak/filebrowser/issues/699
- admin special characters and general login improvements https://github.com/gtsteffaniak/filebrowser/issues/594
- updated editor caching behavior https://github.com/gtsteffaniak/filebrowser/issues/701
- move/copy file path issue and overwrite https://github.com/gtsteffaniak/filebrowser/issues/687
- fix popup preview loading on safari
- `preview.highQuality` only affects gallery view mode. popop preview is always high quality, and icons are always low quality.
## v0.7.6-beta
**New Features**:
- native document preview generation enabled for certain document types on the regular docker image (no office integration needed)
- supported native document preview types:
- ".pdf", // PDF
- ".xps", // XPS
- ".epub", // EPUB
- ".mobi", // MOBI
- ".fb2", // FB2
- ".cbz", // CBZ
- ".svg", // SVG
- ".txt", // TXT
- ".docx", // DOCX
- ".ppt", // PPT
- ".pptx", // PPTX
- ".xlsx", // exel XLSX
- ".hwp", // HWP
- ".hwp", // HWPX
- proxy logout redirectUrl support via `auth.methods.proxy.logoutRedirectUrl` https://github.com/gtsteffaniak/filebrowser/issues/684
**Notes**:
- image loading placeholders added and remain if image can't be loaded.
- no more arm32 support on main image -- use a `slim` tagged image.
**BugFixes**:
- onlyoffice and other cache issues https://github.com/gtsteffaniak/filebrowser/issues/686
- gallery size indicator centering https://github.com/gtsteffaniak/filebrowser/issues/652
## v0.7.5-beta
**New Features**
- new `./filebrowser.exe setup` command for creating a config.yaml on first run. https://github.com/gtsteffaniak/filebrowser/issues/675
- new 2FA/OTP support for password-based users.
- `auth.password.enforcedOtp` option to enforce 2FA usage for password users.
**Notes**:
- logging uses localtime, optional UTC config added https://github.com/gtsteffaniak/filebrowser/issues/665
- generated config example now includes defaults https://github.com/gtsteffaniak/filebrowser/issues/590
- `server.debugMedia` config option added to help debug ffmpeg issues in the future (don't enable unless debugging an issue)
- more translations additions from english settings https://github.com/gtsteffaniak/filebrowser/issues/653
- visual tweaks https://github.com/gtsteffaniak/filebrowser/issues/652
- enhanced markdown viewer with code view spec
**BugFixes**:
- long video names ffmpeg issue fixed https://github.com/gtsteffaniak/filebrowser/issues/669
- certain files not passing content https://github.com/gtsteffaniak/filebrowser/issues/657
- https://github.com/gtsteffaniak/filebrowser/issues/668
- allow edit markdown files
- rename button doesn't close prompt https://github.com/gtsteffaniak/filebrowser/issues/664
- webm video preview issue https://github.com/gtsteffaniak/filebrowser/issues/673
- fix signup issue https://github.com/gtsteffaniak/filebrowser/issues/648
- fix default source bug
- https://github.com/gtsteffaniak/filebrowser/issues/666
- fix 500 error for subtitle videos https://github.com/gtsteffaniak/filebrowser/issues/678
- spaces and special characters in source name issue https://github.com/gtsteffaniak/filebrowser/issues/679
![image](https://github.com/user-attachments/assets/28e4e67e-31a1-4107-9294-0e715e87b558)
## v0.7.4-beta
**Notes**:
- Updated German translation. https://github.com/gtsteffaniak/filebrowser/pull/644
**BugFixes**:
- windows control click https://github.com/gtsteffaniak/filebrowser/issues/642
- create user issue https://github.com/gtsteffaniak/filebrowser/issues/647
## v0.7.3-beta
Note: OIDC changes require config update.
**New Features**
- Added code highlights to text editor and enabled text editor for all asci files under 25MB
- Motion previews for videos -- cycles screenshots of vidoes. https://github.com/gtsteffaniak/filebrowser/issues/588
- Optionally reset default admin username/password on startup, to guarentee a username/password on startup if needed. Use by setting `auth.resetAdminOnStart` true https://github.com/gtsteffaniak/filebrowser/issues/625
**Notes**:
- Updated translations everywhere. https://github.com/gtsteffaniak/filebrowser/issues/627
- Office viewer is now full-screen with floating close button. https://github.com/gtsteffaniak/filebrowser/issues/542
- OIDC config additions
- `issuerUrl` required now to get relevant oidc configurations.
- `disableVerifyTLS` optionally, disable verifying HTTPS provider endpoints.
- `logoutRedirectUrl` optionally, redirect the user to this URL on logout.
- other URL config parameters are no longer accepted -- replace with issuerUrl.
- Aadmins allowed to change user login methods in user settings when creating or updating users.
- https://github.com/gtsteffaniak/filebrowser/issues/618
- https://github.com/gtsteffaniak/filebrowser/issues/617
- Hide header when showing only office https://github.com/gtsteffaniak/filebrowser/issues/542
**BugFixes**:
- Editor save shows notification
- Preview settings resetting on startup
- Not all languages show correctly https://github.com/gtsteffaniak/filebrowser/issues/623
- scopes sometimes reset on startup https://github.com/gtsteffaniak/filebrowser/issues/636
- Update save password option
- https://github.com/gtsteffaniak/filebrowser/issues/587
- https://github.com/gtsteffaniak/filebrowser/issues/619
- https://github.com/gtsteffaniak/filebrowser/issues/615
## v0.7.2-beta
The `media` tags introduced in 0.7.0 have been removed -- all docker images have media enabled now.
**Notes**:
- Reverts enforced user login methods types -- until suitable methods to alter are available.
- When updating a user, updating scope always sets to the exact scope specified on updated.
- Redirect api messages are INFO instead of WARN
- Settings has close button instead of back https://github.com/gtsteffaniak/filebrowser/issues/583
**Bug Fixes**:
- Hover bug when exact timestamp setting enabled https://github.com/gtsteffaniak/filebrowser/issues/585
## v0.7.1-beta
The `media` tags introduced in 0.7.0 have been removed -- all docker images have media enabled now.
**Notes**:
- changes to support jwks url needed for authelia - still needs testing to ensure it works https://github.com/gtsteffaniak/filebrowser/issues/575, added debug logs to help identify any further issues.
- added apache license file back https://github.com/gtsteffaniak/filebrowser/discussions/599
- updated toggle view icons to better match.
- adjusted popup preview position on mobile.
- updated createUserDir logic, https://github.com/gtsteffaniak/filebrowser/issues/541
- it always creats user dir (even for admins)
- scope path must exist if it doesn't end in username, and if it does, the parent dir must exist
- enforced user login methods types -- can't be changed. a password user cannot login as oidc, etc.
**Bug Fixes**:
- right click context menu issue https://github.com/gtsteffaniak/filebrowser/issues/598
- upload file issue https://github.com/gtsteffaniak/filebrowser/issues/597
- defaultUserScope is not respected https://github.com/gtsteffaniak/filebrowser/issues/589
- defaultEnabled is not respected https://github.com/gtsteffaniak/filebrowser/issues/603
- user has weird navigation barhttps://github.com/gtsteffaniak/filebrowser/issues/593
- fix multibutton state issue for close overlay https://github.com/gtsteffaniak/filebrowser/issues/596
## v0.7.0-beta
**New Features**:
- New authentication method: OIDC (OpenID Connect)
- UI refresh
- Refreshed icons and styles to provide more contrast https://github.com/gtsteffaniak/filebrowser/issues/493
- New scrollbar which includes information about the listing https://github.com/gtsteffaniak/filebrowser/issues/304
- User-configurable popup previewer and user can control preview size of images.
- Enhanced user settings page with more toggle options.
- Replaced checkboxes with toggles switches https://github.com/gtsteffaniak/filebrowser/issues/461
- Refreshed Breadcrumbs style.
- Main navbar icon is multipurpose menu, close, back and animates
- Enhanced source info on the UI
- User must have permission `realtime: true` property to get realtime events.
- Sources shows status of the directory `ready`, `indexing`, and `unavailable`
- Top-right overflow menu for deleting / editing files in peview https://github.com/gtsteffaniak/filebrowser/issues/456
- Helpful UI animation for drag and drop files, to get feedback where the drop target is.
- More consistent theme color https://github.com/gtsteffaniak/filebrowser/issues/538
- New file preview types:
- Video thumbnails available via new media integration (see configuration wiki for help) https://github.com/gtsteffaniak/filebrowser/issues/351
- Office file previews if you have office integration enabled. https://github.com/gtsteffaniak/filebrowser/issues/460
**Notes**:
- sesssionId is now unique per window. Previously it was shared accross browser tabs.
- DisableUsedPercentage is a backend property now, so users can't "hack" the information to be shown.
- Updated documentation for resources api https://github.com/gtsteffaniak/filebrowser/issues/560
- Updated placeholder for scopes https://github.com/gtsteffaniak/filebrowser/issues/475
- When user's API permissions are removed, any api keys the user had will be revoked.
- `server.enableThumbnails` moved to `server.disablePreviews` defaulting to false.
- `server.resizePreview` moved to `server.resizePreviews` (with an "s" at the end)
**Bug Fixes**:
- Nil pointer error when source media is disconnected while running.
- Source selection buggy https://github.com/gtsteffaniak/filebrowser/issues/537
- Upload folder structure https://github.com/gtsteffaniak/filebrowser/issues/539
- Editing files on multiple sources https://github.com/gtsteffaniak/filebrowser/issues/535
- Prevent the user from changing the password https://github.com/gtsteffaniak/filebrowser/issues/550
- Links in setting page does not navigate to correct location https://github.com/gtsteffaniak/filebrowser/issues/474
- Url encoding issue https://github.com/gtsteffaniak/filebrowser/issues/530
- Certain file types being treated as folders https://github.com/gtsteffaniak/filebrowser/issues/555
- Source name with special characters https://github.com/gtsteffaniak/filebrowser/issues/557
- Onlyoffice support on proxy auth https://github.com/gtsteffaniak/filebrowser/issues/559
- Downloading with user scope https://github.com/gtsteffaniak/filebrowser/issues/564
- User disableSettings property to be respected.
- Non admin users updating admin settings.
- Right click context issue on safari desktop.
- office save file issue.
## v0.6.8-beta
**New Features**
- environment variables are available for certain secrets.
- see wiki https://github.com/gtsteffaniak/filebrowser/wiki/Environment-Variables
- thanks @aaronkyriesenbach https://github.com/gtsteffaniak/filebrowser/pull/511
**Notes**:
- config validation (see https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example)
- fails when config file contains unknown fields (helps spot typos)
- some light value validation on certain fields
- removed recaptcha -- was disabled and not used before.
- moved `recaptcha` and `signup` configs to `auth.methods.password`
**BugFixes**:
- fix scope reset on restart https://github.com/gtsteffaniak/filebrowser/issues/515
- Clicking empty space to deselect https://github.com/gtsteffaniak/filebrowser/issues/492
## v0.6.7-beta
**Notes**:
- added full tests for single source example.
- adds descriptive error if temp dir can't be created on fatal startup
- clears temp directory on shutdown.
- removed put settings api (unused)
- removed more unused config properties.
**BugFixes**:
- fix url encoding issue for search links when theres only one source https://github.com/gtsteffaniak/filebrowser/issues/501
- files with # could have problems, double encoded.
## v0.6.6-beta
**New Feature**:
- limit tar size creation to limit server burden. For example, don't let customers try to download the entire filesystem as a zip. see `server.maxArchiveSize` on config wiki.
**Notes**:
- disableUsedPercentage also hides text and source bar.
- share errors show up in logs in more verbose way.
- archive creation occurs on disk rather than in memory, use `server.cacheDir` to determine where temp files are stored.
- automatically ensures leading slash for scope
- https://github.com/gtsteffaniak/filebrowser/issues/472
- https://github.com/gtsteffaniak/filebrowser/issues/476
**BugFixes**:
- fix proxy user creation issue https://github.com/gtsteffaniak/filebrowser/issues/478
- externalUrl prefix issue fixed for shares. https://github.com/gtsteffaniak/filebrowser/issues/465
- fix File Opens Instead of Just Downloading https://github.com/gtsteffaniak/filebrowser/issues/480
- fix Download file name https://github.com/gtsteffaniak/filebrowser/issues/481
## v0.6.5-beta
**Notes**:
- added more share and download tests
**BugFixes**:
- fix share download issue https://github.com/gtsteffaniak/filebrowser/issues/465
- fix content length size calculation issue when downloading multiple files.
## v0.6.4-beta
**BugFixes**:
- fix preview arow issue. https://github.com/gtsteffaniak/filebrowser/issues/457
- fix password change issue.
- apply user defaults to publi user on startup https://github.com/gtsteffaniak/filebrowser/issues/451
## v0.6.3-beta
**Notes**:
- windows directories get better naming, root directories like "D:\ get named "D", otherwise base filepath is the name when unselected "D:\path\to\folder" gets named "folder" (just like linux)
- `.pdf` files added to default onlyoffice exclusion list.
**BugFixes**:
- windows would not refresh file info automatically when viewing because of path issue.
- windows paths without name for "D:\" would cause issues.
- share path error https://github.com/gtsteffaniak/filebrowser/issues/429
- fix bug where resource content flag would load entire file into memory.
## v0.6.2-beta
**Notes**:
- Added playwright tests for bugfixes for permantent fix for stability.
(except onlyoffice since it requires integrations)
**BugFixes**:
- Context menu should only be available inside the folder/files container https://github.com/gtsteffaniak/filebrowser/issues/430
- drag and drop files from desktop to browser is fixed.
- replace prompt cancel button didn't work.
- key events on listing page not working (like delete key)
- fixed share viewing issue https://github.com/gtsteffaniak/filebrowser/issues/429
- disableUsedPercentage hides entire source https://github.com/gtsteffaniak/filebrowser/issues/438
- createUserDir fix for proxy users and new users https://github.com/gtsteffaniak/filebrowser/issues/440
## v0.6.1-beta
**New Feature**:
- download size information is added, including when downloding multiple files in zip/tar.gz. The browser will see the XMB of X GB and will show browser native progress.
**BugFixes**:
- fixed onlyoffice bug https://github.com/gtsteffaniak/filebrowser/issues/418
- fixed breadcrumbs bug https://github.com/gtsteffaniak/filebrowser/issues/419
- fixed search context bug https://github.com/gtsteffaniak/filebrowser/issues/417
- fixed sessionID for search
## v0.6.0-beta
> [!WARNING]
> This release includes several config changes that could cause issues. Please backup your database file before upgrading.
This release has several changes that should work without issues... however, still backup your database file first and proceed with caution. User permissions and source config changes have been updated -- and the `server.root` paramter is no longer used.
This is a significant step towards a stable release. There shouldn't be any major breaking config changes after this.
**New Features**:
- multiple sources support https://github.com/gtsteffaniak/filebrowser/issues/360
- listing view keeps them independant, you switch between the two and the url address will have a prefix `/files/<sourcename>/path/to/file` when there is more than 1 source.
- search also happens independantly, with a selection toggle per source. searching current source searches the current scope in the listing view, if you toggle to an alternative source it will search from the source root.
- copy/moving is currently only supported within the same source -- that will come in a future release.
- `FILEBROWSER_CONFIG` environment variable is respected if no CLI config parameter is provided. https://github.com/gtsteffaniak/filebrowser/issues/413
**Notes**:
- downloads no longer open new window.
- swagger updated with auth api help for things like api token.
- GET api keys now uses `name` query instead of `key`. eg `GET /api/auth/tokens?name=apikeyname`
- user permissions simplified to four permission groups (no config change required):
- **removed** : create, rename, delete, download
- **remaining**: admin, modify, share, api
- `scope` is deprecated, but still supported, applies to default source. if using multiple sources, set `defaultUserScope` at the [source config](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration#default-source-configuration) instead.
- **removed** user rules and commands.
- commands feature has never been enabled so just removing the references.
- rules will come back in a different form (not applied to the user).
- `server.root` is completely removed in favor of `server.sources`
**BugFixes**:
- fix conflict resolution issue https://github.com/gtsteffaniak/filebrowser/issues/384
- many user creation page bugfixes.
- fix share delete issue https://github.com/gtsteffaniak/filebrowser/issues/408
## v0.5.4-beta
**BugFixes**:
- default scope share issue. @theryecatcher https://github.com/gtsteffaniak/filebrowser/pull/387
- drag and drop on empty folders https://github.com/gtsteffaniak/filebrowser/issues/361
- preview navigation issue https://github.com/gtsteffaniak/filebrowser/issues/372
- auth proxy password length error https://github.com/gtsteffaniak/filebrowser/issues/375
<img width="294" alt="image" src="https://github.com/user-attachments/assets/669bca75-98d4-47c1-838b-1ffee2967d7d" />
## v0.5.3-beta
**New Features**:
- onlyoffice disable filetypes for user specified file types. https://github.com/gtsteffaniak/filebrowser/issues/346
**Notes**:
- navbar/sidebar lightmode style tweaks.
- any item that has utf formatted text will get editor.
- tweaks to create options on context menu.
- removed small delay on preview before detecting the file.
**BugFixes**:
- fix `/files/` prefix loading issue https://github.com/gtsteffaniak/filebrowser/issues/362
- fix special characters in filename issue https://github.com/gtsteffaniak/filebrowser/issues/357
- fix drag and drop issue https://github.com/gtsteffaniak/filebrowser/issues/361
- fix conflict issue with creating same file after deletion.
- fix mimetype detection https://github.com/gtsteffaniak/filebrowser/issues/327
- subtitles for videos https://github.com/gtsteffaniak/filebrowser/issues/358
- supports caption sidecar files : ".vtt", ".srt", ".lrc", ".sbv", ".ass", ".ssa", ".sub", ".smi"
- embedded subtitles not yet supported.
## v0.5.2-beta
**New Features**:
- Markdown file preview https://github.com/gtsteffaniak/filebrowser/issues/343
- Easy access download button https://github.com/gtsteffaniak/filebrowser/issues/341
**Notes**:
- Adds message about what sharing means when creating a link.
- api log duration is now always in milliseconds for consistency.
- advanced index config option `fileEndsWith` is now respected.
- Added Informative error for missing files for certificate load https://github.com/gtsteffaniak/filebrowser/issues/354
**BugFixes**:
- onlyoffice close window missing files issue https://github.com/gtsteffaniak/filebrowser/issues/345
- fixed download link inside file preview
## v0.5.1-beta
> Note: I changed the [config](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration#example-auth-config) for password auth again... It was a mistake just to make it a boolean, so now you can provide options, going forward this allows for more.
**New Features**:
- password length requirement config via `auth.methods.password.minLength` as a number of characters required.
**Bugfixes**:
- NoAuth error message "resource not found"
- CLI user configuration works and simplified see examples in the [Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
## v0.5.0-beta
> Note: This Beta release includes a configuration change: `auth.method` is now deprecated. This is done to allow multiple login methods at once. Auth methods are specified via `auth.methods` instead. see [example on the wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration#example-auth-config).
**New Features**:
- Upload progress notification https://github.com/gtsteffaniak/filebrowser/issues/303
- proxy auth auto create user when `auth.methods.proxy.createUser: true` while using proxy auth.
**Notes**:
- Context menu positioning tweaks.
- using /tmp cachedir is disabled by default, cache dir can be specified via `server.cacheDir: /tmp` to enable it. https://github.com/gtsteffaniak/filebrowser/issues/326
**Bugfixes**:
- Gracefully shutdown to protect database. https://github.com/gtsteffaniak/filebrowser/issues/317
- validates auth method provided before server startup.
- fix sidebar disk space usage calculation. https://github.com/gtsteffaniak/filebrowser/issues/315
- Fixed proxy auth header support (make sure your proxy and server are secure!). https://github.com/gtsteffaniak/filebrowser/issues/322
## v0.4.2-beta
**New Features**
**New Features**:
- Hidden files changes
- windows hidden file properties are respected -- when running on windows binary (not docker) with NTFS filesystem.
- windows "system" files are considered hidden.
@@ -19,10 +807,10 @@ All notable changes to this project will be documented in this file. For commit
## v0.4.1-beta
**New Features**
**New Features**:
- right-click actions are available on search. https://github.com/gtsteffaniak/filebrowser/issues/273
**Notes**
**Notes**:
- delete prompt now lists all items that will be affected by delete
- Debug and logger output tweaks.
@@ -34,13 +822,13 @@ All notable changes to this project will be documented in this file. For commit
## v0.4.0-beta
**New Features**
**New Features**:
- Better logging https://github.com/gtsteffaniak/filebrowser/issues/288
- highly configurable
- api logs include user
- onlyOffice support for editing only office files (inspired from https://github.com/filebrowser/filebrowser/pull/2954)
**Notes**
**Notes**:
- Breadcrumbs will only show on file listing (not on previews or editors)
- Config file is now optional. It will run with default settings without one and throw a `[WARN ]` message.
- Added more descriptions to swagger API
@@ -58,7 +846,7 @@ All notable changes to this project will be documented in this file. For commit
## v0.3.6-beta
**New Features**
**New Features**:
- Adds "externalUrl" server config https://github.com/gtsteffaniak/filebrowser/issues/272
**Notes**:
@@ -74,7 +862,7 @@ All notable changes to this project will be documented in this file. For commit
## v0.3.5
**New Features**
**New Features**:
- More indexing configuration options possible. However consider waiting on using this feature, because I will soon have a full onboarding experience in the UI to manage sources instead.
- added config file options "sources" in the server config.
- can enable/disable indexing a specified list of directories/files
+208
View File
@@ -0,0 +1,208 @@
# Contributing to FileBrowser Quantum
Thank you for your interest in contributing to FileBrowser Quantum! This guide will help you get started with development.
## Prerequisites
- **Go 1.25+** (see `backend/go.mod`)
- **Node.js 18.0.0+** with npm 7.0.0+ (see `frontend/package.json`)
- **Docker** (optional, for containerized development)
- **Git**
### Optional Tools
- **ffmpeg**: For media features (subtitles, thumbnails, heic)
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
- **mupdf-tools**: For PDF preview generation
- Ubuntu/Debian: `sudo apt-get install mupdf-tools`
- macOS: `brew install mupdf-tools`
- Windows: Download from [mupdf.com](https://mupdf.com/downloads/)
## Quick Start
```bash
# 1. Clone the repository
git clone https://github.com/gtsteffaniak/filebrowser.git
cd filebrowser
# 2. Initial setup - installs dependencies and creates test config
make setup
# 3. Run in development mode with hot-reloading
make dev
```
`make setup` installs all dependencies and creates a test configuration file.
`make run` starts the development server. Note: changes require ctrl+c and re-run.
## Project Architecture
### Backend (Go)
- **Entry Point**: `backend/main.go` → `backend/cmd/`
- **HTTP Server**: `backend/http/` - API routes, middleware, auth
- **Storage**: BoltDB via `backend/database/storage/`
- **Authentication**: Multiple providers in `backend/auth/`
- **Indexing**: Real-time search in `backend/indexing/`
- **Previews**: Image/video/document generation in `backend/preview/`
### Frontend (Vue.js + TypeScript)
- **Framework**: Vue 3 + Vite + TypeScript
- **State**: Custom store in `frontend/src/store/`
- **API Client**: Axios-based in `frontend/src/api/`
- **i18n**: 25+ languages with English as main
- **Components**: Feature-based organization
## Development Commands
### Essential Commands
```bash
make dev # Start development server with hot-reloading
make test # Run all tests
make lint # Check code quality
make check-all # Lint + tests
make build-frontend # Build frontend only
make build-backend # Build backend only
make build # Build Docker image
```
### Frontend Development
Note: consider using make commands above instead.
```bash
cd frontend
npm run lint:fix # Auto-fix linting issues
npm run i18n:sync # Sync translations changes
```
## Testing
### Running Tests
```bash
make test # All tests
make test-backend # Go tests with race detection
make test-frontend # Frontend unit tests
make test-playwright # E2E tests in Docker
```
E2E tests run with three authentication modes: standard auth, no auth, and proxy auth.
### Coverage & Performance
```bash
cd backend
./run_check_coverage.sh # Coverage report with HTML output
./run_benchmark.sh # Benchmarks
```
**Code Coverage:**
- View report: Open `backend/coverage.html` after running coverage script
- CI enforces coverage for critical packages
- Use `go test -cover` for quick package coverage
## Code Standards
### Backend (Go)
- **Linting**: `backend/.golangci.yml` with 30+ checks
- **Format**: Use `gofmt` (automated in CI)
- **Testing**: Maintain 80%+ coverage
- **Errors**: Handle all errors explicitly
### Frontend (Vue.js)
- **Linting**: ESLint with Vue 3 + TypeScript rules
- **i18n**: English is master locale, all text must use `$t('key')`
- **Types**: Use TypeScript everywhere
- **Fix**: Run `npm run lint:fix` before committing
## Build & Deployment
### Single Binary Build
The project builds into a single binary with embedded frontend:
```bash
make build-frontend # Build Vue.js app
make build-backend # Build Go binary with embedded assets
```
### Docker
```bash
make build # Full image with ffmpeg and muPDF
```
### Configuration
- **Test Config**: `backend/test_config.yaml` Auto-generated after running `make setup`
## Contributing
### Pull Request Process
1. Fork and create a feature branch
2. Make your changes following the code standards
3. Run `make dev` to build and run with your changes. Supports hot-reloading frontend and backend changes.
4. When ready, run `make check-all` to verify tests and linting
5. Submit PR with a clear description
### PR Requirements
- Clear description of changes
- All tests must pass
- Follow existing code patterns
- Update documentation if needed
### Commit Format
```
type(scope): description
Types: feat, fix, docs, refactor, test, chore
```
## Roadmap
Check the [Project Roadmap](https://github.com/users/gtsteffaniak/projects/4/views/2) to see issues sorted by priority. This helps you understand what features are planned and where you can contribute most effectively.
## Troubleshooting
### Common Issues
**Build failures:**
```bash
# Frontend
cd frontend && rm -rf node_modules && npm install
# Backend
cd backend && go mod tidy && go clean -modcache
```
**Authentication Issues:**
Always first enable debug logging in your config file if you have issues.
1. **OIDC Login Fails:**
- Check redirect URLs match your config
- Verify OIDC provider settings
- Enable debug logs to see auth flow
- Common issue: mismatched callback URLs
2. **Proxy Auth Not Working:**
- Verify header names match config
- Check nginx/reverse proxy passes headers
- Test with: `curl -H "X-Auth: username" localhost:8080`
- Enable `--log-level debug` to see headers
3. **TOTP Issues:**
- Ensure server time is synchronized
- Check QR code generation in logs
- Test with authenticator app time settings
- Database might have stale TOTP secrets
4. **Session/Cookie Problems:**
- Clear browser cookies and localStorage
- Verify cookie domain settings
- Try incognito/private browsing mode
### Getting Help
- **Wiki**: [Project Wiki](https://github.com/gtsteffaniak/filebrowser/wiki)
- **Issues**: [GitHub Issues](https://github.com/gtsteffaniak/filebrowser/issues)
- **PR Template**: See `.github/PULL_REQUEST_TEMPLATE.md`
-31
View File
@@ -1,31 +0,0 @@
FROM golang:1.23-alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
#RUN swag init --output swagger/docs
RUN ln -s swagger /usr/local/go/src/
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
ENV FILEBROWSER_NO_EMBEDED="true"
RUN apk --no-cache add ca-certificates mailcap
WORKDIR /home/filebrowser
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
USER root
# exposing default port for auto discovery.
EXPOSE 80
ENTRYPOINT [ "./filebrowser" ]
-6
View File
@@ -1,6 +0,0 @@
FROM gtstef/playwright-base
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN ./filebrowser -c filebrowser-playwright.yaml & sleep 2 && cd ../frontend && npx playwright test
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 File Browser contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+104 -98
View File
@@ -1,89 +1,94 @@
<p align="center">
<a href="https://opensource.org/license/apache-2-0/"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg" alt="License: Apache-2.0"></a>
</p>
<p align="center">
<img src="frontend/public/img/icons/favicon-256x256.png" width="100" title="Login With Custom URL">
</p>
<h3 align="center">FileBrowser Quantum - A modern web-based file manager</h3>
<p align="center">
<img width="800" src="https://github.com/user-attachments/assets/c991fc69-a05b-4f34-b915-0d3cded887a7" title="Main Screenshot">
</p>
<div align="center">
[![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)
[![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)
[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A#is-there-a-way-to-donate-or-support-this-project)
<img width="150" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" title="Logo">
<h3>FileBrowser Quantum</h3>
The best free self-hosted web-based file manager.
<br/><br/>
<img width="800" src="https://github.com/user-attachments/assets/95af22fa-7760-4f7b-9c20-51fdcfe8f4ea" title="Main Screenshot">
</div>
> [!WARNING]
> There is no stable version yet planned 2025.
> There is no stable version -- :construction: planned for 2025. ([Read more](https://github.com/gtsteffaniak/filebrowser/discussions/628))
FileBrowser Quantum is a fork of the file browser open-source project with the following changes:
1. ✅ Indexes files efficiently. (See [indexing Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.)
- Real-time search results as you type
- Search supports file/folder sizes and many file type filters.
- Enhanced interactive results that show file/folder sizes.
2. ✅ Revamped and simplified GUI navbar and sidebar menu.
- Additional compact view mode as well as refreshed view mode styles.
- Many graphical and user experience improvements.
- right-click context menu
3. ✅ Revamped and simplified configuration via `config.yaml` config file.
4. ✅ Better listing browsing
- Instantly Switches view modes and sort order without reloading data.
- Folder sizes are displayed
- Navigating remembers the scroll position, navigating back keeps the last scroll position.
5. ✅ Developer API support
- Ability to create long-live API Tokens.
- Helpful Swagger page available at `/swagger` endpoint.
Notable features that this fork *does not* have (removed):
- ❌ jobs/runners are not supported yet (planned).
- ❌ per-user rules are not supported yet (planned).
- ❌ pagination for directory items for extremely large directories.
- ❌ shell commands are completely removed and will not be returned.
- see feature matrix below for more.
[FYI: how much RAM does it require?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
## About
FileBrowser Quantum provides a file-managing interface within a specified directory
and can be used to upload, delete, preview, rename, and edit your files.
It allows the creation of multiple users and each user can have its directory.
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has has a modern responsive interface that has many advanced features to manage users, access, sharing, and file preview and editing.
This repository is a fork of the original [filebrowser](https://github.com/filebrowser/filebrowser)
with a collection of changes that make this program work better in terms of
aesthetics and performance. Improved search, simplified UI
(without removing features) and more secure and up-to-date
build are just a few examples.
This version is called "Quantum" because it packs tons of advanced features into a tiny easy to run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
FileBrowser Quantum differs significantly from the original.
There are hundreds of thousands of lines changed and they are generally
no longer compatible with each other. This has been intentional -- the
focus of this fork is on a few key principles:
- Simplicity and improved user experience
- Improving performance and faster feedback when making changes.
- Minimize external dependencies and standard library usage.
- Of course -- adding much-needed features.
The goal for this repo is to become the best open-source self-hosted file browsing application that exists -- **all for free**. This repo will always be free and open-source.
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
Ready to try it out? See [Getting Started Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Getting-Started).
## Look
## How its different
The UI has a simple three-component navigation system :
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
1. (Left) The slide-out action panel button
1. ✅ Multiple sources support
2. ✅ Login support for OIDC, password + 2FA, and proxy.
3. ✅ Beautiful, Responsive, and Customizable user interface.
4. ✅ Simplified 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.
- Navigating remembers the last scroll position.
7. ✅ Highly configurable and customizable sharing options, incuding:
- 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
- Ability to create long-lived API Tokens.
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
Notable features that this fork *does not* have (removed):
- :construction: jobs are not supported yet.
- ❌ shell commands are completely removed and will not be returned.
> [!WARNING]
> Every file and directory in the source gets indexed (by default). This enables powerful features such as instant search, but large source filesystems can increase your system requirements. [See indexing wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.
FileBrowser Quantum differs significantly from the original version. Many of these changes required a significant overhaul. Creating a fork was a necessary process to make the program better. There have been many growing pains, but a stable release is planned and coming soon.
## The UI
The UI has a simple three-component navigation system:
1. (Left) Multi-action button with slide-out panel.
2. (Middle) The powerful search bar.
3. (Right) The view change toggle.
All other functions are moved either into the action menu or popup menus.
All other functions are moved either into the action menu or pop-up menus.
If the action does not depend on context, it will exist in the slide-out
action panel. If the action is available based on context, it will show up as
a popup menu.
a pop-up menu.
<p align="center">
<img width="800" src="https://github.com/user-attachments/assets/2be7a6c5-0f95-4d9f-bc05-484ee71246d8" title="Search GIF">
<img width="800" src="https://github.com/user-attachments/assets/f55a6f1f-b930-4399-98b5-94da6e90527a" title="Navigation GIF">
<img width="800" src="https://github.com/user-attachments/assets/93b019de-d38f-4aaa-bde3-3ba4e99ecd25" title="Main Screenshot">
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
</p>
## Install and Configuration
See the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration)
Check out the [Getting Started Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Getting-Started)
For help configuring your filebrowser see [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration-And-Examples)
## Command Line Usage
@@ -93,67 +98,68 @@ See the [CLI Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
See the [API Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/API)
## Configuration
Configuration is done via the `config.yaml`, see the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration) for available configuration options and other help.
## Office File Support
See [Office Support Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Office-Support#adding-open-office-integration-for-docker) on how to enable office file editing.
See [Office Support Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Office-Support#adding-open-office-integration-for-docker) on how to enable office file editing and office-related features.
## Migration from the original filebrowser
See the [Migration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Migration)
## Other Questions
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
## Comparison Chart
Application Name | <img width="48" src="frontend/public/img/icons/favicon-256x256.png" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
Application Name | <img width="48" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | :construction: | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 31 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 128 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
Dedicated docs site? | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
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 |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
price | free | free | free | free tier | free tier | $99+ |
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
Custom job support | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
Custom job support | :construction: | ✅ | ❌ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
2FA sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
sharable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
tags support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ❌ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
activity log | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
activity log | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
trash support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Content preview icons | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
+47
View File
@@ -0,0 +1,47 @@
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
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
ENTRYPOINT [ "./filebrowser" ]
@@ -1,4 +1,4 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps firefox
+18
View File
@@ -0,0 +1,18 @@
FROM gtstef/playwright-base
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*", "./"]
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
+16
View File
@@ -0,0 +1,16 @@
FROM gtstef/playwright-base
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/noauth/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+13
View File
@@ -0,0 +1,13 @@
FROM gtstef/playwright-base
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
+14
View File
@@ -0,0 +1,14 @@
FROM gtstef/playwright-base
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
+17
View File
@@ -0,0 +1,17 @@
FROM gtstef/playwright-base
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
@@ -0,0 +1 @@
FROM gtstef/playwright-base
+40
View File
@@ -0,0 +1,40 @@
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
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
ENTRYPOINT [ "./filebrowser" ]
+17
View File
@@ -0,0 +1,17 @@
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
+21
View File
@@ -0,0 +1,21 @@
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:
defaultEnabled: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
@@ -0,0 +1,47 @@
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"] },
},
],
});
+26
View File
@@ -0,0 +1,26 @@
server:
port: 80
baseURL: "/files/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
exclude:
folderPaths:
- "excluded"
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
@@ -0,0 +1,48 @@
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"] },
},
],
});
+38
View File
@@ -0,0 +1,38 @@
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:
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: false
api: false
@@ -0,0 +1,60 @@
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('---');
@@ -0,0 +1,48 @@
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
View File
@@ -0,0 +1 @@
demo-127.0.0.1:$apr1$j5FRA4Bc$RYdNFuM7YAAo9Qtie8w4b/
+36
View File
@@ -0,0 +1,36 @@
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:
proxy:
enabled: true
header: "X-Username"
createUser: true
logoutRedirectUrl: "https://localhost/api/auth/logout2"
userDefaults:
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: true
api: false
+31
View File
@@ -0,0 +1,31 @@
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;
}
# 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;
}
}
@@ -0,0 +1,47 @@
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"] },
},
],
});
+22
View File
@@ -0,0 +1,22 @@
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"
@@ -0,0 +1,47 @@
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"] },
},
],
});
+17
View File
@@ -0,0 +1,17 @@
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'\" ."
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"]
log = "air.log"
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = true
+5
View File
@@ -0,0 +1,5 @@
tmp/
air.log
filebrowser
generated.yaml
test_config.yaml
+145 -231
View File
@@ -1,232 +1,146 @@
# This code is licensed under the terms of the MIT license https://opensource.org/license/mit
# Copyright (c) 2021 Marat Reymers
## Golden config for golangci-lint v1.59.1
#
# This is the best config for golangci-lint based on my experience and opinion.
# It is very strict, but not extremely strict.
# Feel free to adapt and change it for your needs.
run:
# Timeout for analysis, e.g. 30s, 5m.
# Default: 1m
timeout: 3m
# This file contains only configs which differ from defaults.
# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml
linters-settings:
cyclop:
# The maximal code complexity to report.
# Default: 10
max-complexity: 30
# The maximal average package complexity.
# If it's higher than 0.0 (float) the check is enabled
# Default: 0.0
package-average: 10.0
errcheck:
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
# Such cases aren't reported by default.
# Default: false
check-type-assertions: true
exhaustive:
# Program elements to check for exhaustiveness.
# Default: [ switch ]
check:
- switch
- map
exhaustruct:
# List of regular expressions to exclude struct packages and their names from checks.
# Regular expressions must match complete canonical struct package/name/structname.
# Default: []
exclude:
# std libs
- "^net/http.Client$"
- "^net/http.Cookie$"
- "^net/http.Request$"
- "^net/http.Response$"
- "^net/http.Server$"
- "^net/http.Transport$"
- "^net/url.URL$"
- "^os/exec.Cmd$"
- "^reflect.StructField$"
# public libs
- "^github.com/Shopify/sarama.Config$"
- "^github.com/Shopify/sarama.ProducerMessage$"
- "^github.com/mitchellh/mapstructure.DecoderConfig$"
- "^github.com/prometheus/client_golang/.+Opts$"
- "^github.com/spf13/cobra.Command$"
- "^github.com/spf13/cobra.CompletionOptions$"
- "^github.com/stretchr/testify/mock.Mock$"
- "^github.com/testcontainers/testcontainers-go.+Request$"
- "^github.com/testcontainers/testcontainers-go.FromDockerfile$"
- "^golang.org/x/tools/go/analysis.Analyzer$"
- "^google.golang.org/protobuf/.+Options$"
- "^gopkg.in/yaml.v3.Node$"
funlen:
# Checks the number of lines in a function.
# If lower than 0, disable the check.
# Default: 60
lines: 100
# Checks the number of statements in a function.
# If lower than 0, disable the check.
# Default: 40
statements: 50
# Ignore comments when counting lines.
# Default false
ignore-comments: true
gocognit:
# Minimal code complexity to report.
# Default: 30 (but we recommend 10-20)
min-complexity: 20
gocritic:
# Settings passed to gocritic.
# The settings key is the name of a supported gocritic checker.
# The list of supported checkers can be find in https://go-critic.github.io/overview.
settings:
captLocal:
# Whether to restrict checker to params only.
# Default: true
paramsOnly: false
underef:
# Whether to skip (*x).method() calls where x is a pointer receiver.
# Default: true
skipRecvDeref: false
gomodguard:
blocked:
# List of blocked modules.
# Default: []
modules:
- github.com/golang/protobuf:
recommendations:
- google.golang.org/protobuf
reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules"
- github.com/satori/go.uuid:
recommendations:
- github.com/google/uuid
reason: "satori's package is not maintained"
- github.com/gofrs/uuid:
recommendations:
- github.com/gofrs/uuid/v5
reason: "gofrs' package was not go module before v5"
govet:
# Enable all analyzers.
# Default: false
enable-all: true
# Disable analyzers by name.
# Run `go tool vet help` to see all analyzers.
# Default: []
disable:
- fieldalignment # too strict
# Settings per analyzer.
settings:
shadow:
# Whether to be strict about shadowing; can be noisy.
# Default: false
strict: false
inamedparam:
# Skips check for interface methods with only a single parameter.
# Default: false
skip-single-param: true
mnd:
# List of function patterns to exclude from analysis.
# Values always ignored: `time.Date`,
# `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`,
# `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`.
# Default: []
ignored-functions:
- args.Error
- flag.Arg
- flag.Duration.*
- flag.Float.*
- flag.Int.*
- flag.Uint.*
- os.Chmod
- os.Mkdir.*
- os.OpenFile
- os.WriteFile
- prometheus.ExponentialBuckets.*
- prometheus.LinearBuckets
nakedret:
# Make an issue if func has more lines of code than this setting, and it has naked returns.
# Default: 30
max-func-lines: 0
nolintlint:
# Exclude following linters from requiring an explanation.
# Default: []
allow-no-explanation: [ funlen, gocognit, lll ]
# Enable to require an explanation of nonzero length after each nolint directive.
# Default: false
require-explanation: true
# Enable to require nolint directives to mention the specific linter being suppressed.
# Default: false
require-specific: true
perfsprint:
# Optimizes into strings concatenation.
# Default: true
strconcat: false
rowserrcheck:
# database/sql is always checked
# Default: []
packages:
- github.com/jmoiron/sqlx
sloglint:
# Enforce not using global loggers.
# Values:
# - "": disabled
# - "all": report all global loggers
# - "default": report only the default slog logger
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global
# Default: ""
no-global: "all"
# Enforce using methods that accept a context.
# Values:
# - "": disabled
# - "all": report all contextless calls
# - "scope": report only if a context exists in the scope of the outermost function
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only
# Default: ""
context: "scope"
tenv:
# The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures.
# Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked.
# Default: false
all: true
issues:
# Maximum count of issues with the same text.
# Set to 0 to disable.
# Default: 3
max-same-issues: 50
exclude-rules:
- source: "(noinspection|TODO)"
linters: [ godot ]
- source: "//noinspection"
linters: [ gocritic ]
- path: "_test\\.go"
linters:
- bodyclose
- dupl
version: "2"
linters:
settings:
staticcheck:
checks:
- all
- '-QF1001'
- '-ST1003'
- '-ST1016'
- '-QF1004'
- '-QF1003'
cyclop:
max-complexity: 30
package-average: 10
errcheck:
check-type-assertions: true
exhaustive:
check:
- switch
- map
exhaustruct:
exclude:
- ^net/http.Client$
- ^net/http.Cookie$
- ^net/http.Request$
- ^net/http.Response$
- ^net/http.Server$
- ^net/http.Transport$
- ^net/url.URL$
- ^os/exec.Cmd$
- ^reflect.StructField$
- ^github.com/Shopify/sarama.Config$
- ^github.com/Shopify/sarama.ProducerMessage$
- ^github.com/mitchellh/mapstructure.DecoderConfig$
- ^github.com/prometheus/client_golang/.+Opts$
- ^github.com/spf13/cobra.Command$
- ^github.com/spf13/cobra.CompletionOptions$
- ^github.com/stretchr/testify/mock.Mock$
- ^github.com/testcontainers/testcontainers-go.+Request$
- ^github.com/testcontainers/testcontainers-go.FromDockerfile$
- ^golang.org/x/tools/go/analysis.Analyzer$
- ^google.golang.org/protobuf/.+Options$
- ^gopkg.in/yaml.v3.Node$
funlen:
lines: 100
statements: 50
ignore-comments: true
gocognit:
min-complexity: 20
gocritic:
settings:
captLocal:
paramsOnly: false
underef:
skipRecvDeref: false
gomodguard:
blocked:
modules:
- github.com/golang/protobuf:
recommendations:
- google.golang.org/protobuf
reason: see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules
- github.com/satori/go.uuid:
recommendations:
- github.com/google/uuid
reason: satori's package is not maintained
- github.com/gofrs/uuid:
recommendations:
- github.com/gofrs/uuid/v5
reason: gofrs' package was not go module before v5
govet:
disable:
- fieldalignment
enable-all: true
settings:
shadow:
strict: false
inamedparam:
skip-single-param: true
mnd:
ignored-functions:
- args.Error
- flag.Arg
- flag.Duration.*
- flag.Float.*
- flag.Int.*
- flag.Uint.*
- os.Chmod
- os.Mkdir.*
- os.OpenFile
- os.WriteFile
- prometheus.ExponentialBuckets.*
- prometheus.LinearBuckets
nakedret:
max-func-lines: 0
nolintlint:
require-explanation: true
require-specific: true
allow-no-explanation:
- funlen
- goconst
- gosec
- noctx
- wrapcheck
- gocognit
- lll
perfsprint:
strconcat: false
rowserrcheck:
packages:
- github.com/jmoiron/sqlx
sloglint:
no-global: all
context: scope
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- godot
source: (noinspection|TODO)
- linters:
- gocritic
source: //noinspection
- linters:
- bodyclose
- dupl
- funlen
- goconst
- gosec
- noctx
- wrapcheck
path: _test\.go
paths:
- third_party$
- builtin$
- examples$
issues:
max-same-issues: 50
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+57 -7
View File
@@ -3,18 +3,54 @@ project_name: filebrowser
version: 2
builds:
# Build configuration for darwin and linux
- id: default
# Build configuration for linux amd64
- id: linux-amd64
env:
- CGO_ENABLED=1
- CC=zig cc -target x86_64-linux-musl
tags:
- mupdf
- musl
ldflags: &ldflags
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA={{ .ShortCommit }}
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/common/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
goos:
- linux
goarch:
- amd64
- arm
hooks:
post:
- upx {{ .Path }}
# Build configuration for linux arm64
- id: linux-arm64
ldflags: *ldflags
main: main.go
binary: filebrowser
env:
- CGO_ENABLED=1
- CC=zig cc -target aarch64-linux-musl
tags:
- mupdf
- musl
goos:
- linux
goarch:
- arm64
hooks:
post:
- upx {{ .Path }}
# Build configuration for linux
- id: linux-arm32
ldflags: *ldflags
main: main.go
binary: filebrowser
goos:
- linux
goarch:
- arm
goarm:
- "6"
- "7"
@@ -24,6 +60,11 @@ builds:
# Build configuration for windows without arm
- id: windows
env:
- CGO_ENABLED=1
- CC=zig cc -target x86_64-windows
tags:
- mupdf
ldflags: *ldflags
main: main.go
binary: filebrowser
@@ -35,7 +76,17 @@ builds:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm & upx
# Build configuration for freebsd without arm & upx
- id: freeBSD and freebsd-arm
ldflags: *ldflags
main: main.go
binary: filebrowser
goos:
- freebsd
goarch:
- amd64
# Build configuration for macos without upx
- id: macos
ldflags: *ldflags
main: main.go
@@ -43,8 +94,8 @@ builds:
goos:
- darwin
goarch:
- amd64
- arm64
- amd64
archives:
- name_template: >
@@ -57,4 +108,3 @@ archives:
checksum:
disable: true
File renamed without changes.
@@ -31,9 +31,6 @@ type FileCache struct {
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("can't make directory %s: %v", dir, err)
}
return &FileCache{dir: dir}, nil
}
File renamed without changes.
@@ -7,7 +7,9 @@ import (
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func Test_GetRealPath(t *testing.T) {
@@ -64,7 +66,7 @@ func Test_GetRealPath(t *testing.T) {
},
},
}
idx := Index{
idx := indexing.Index{
Source: settings.Source{
Path: trimPrefix,
},
@@ -84,33 +86,33 @@ func Test_GetRealPath(t *testing.T) {
func TestSortItems(t *testing.T) {
tests := []struct {
name string
input FileInfo
expected FileInfo
input iteminfo.FileInfo
expected iteminfo.FileInfo
}{
{
name: "Numeric and Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "10.txt"},
{Name: "2.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "File2.txt"},
{Name: "File10.txt"},
{Name: "File1"},
{Name: "banana"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "2.txt"},
{Name: "10.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "banana"},
{Name: "File1"},
{Name: "File10.txt"},
@@ -120,25 +122,25 @@ func TestSortItems(t *testing.T) {
},
{
name: "Only Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "dog.txt"},
{Name: "Cat.txt"},
{Name: "apple"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "Zebra"},
{Name: "apple"},
{Name: "cat"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "Cat.txt"},
{Name: "dog.txt"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "cat"},
{Name: "Zebra"},
@@ -151,7 +153,7 @@ func TestSortItems(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
test.input.SortItems()
getNames := func(items []ItemInfo) []string {
getNames := func(items []iteminfo.ItemInfo) []string {
names := []string{}
for _, folder := range items {
names = append(names, folder.Name)
+453
View File
@@ -0,0 +1,453 @@
package files
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
"unicode"
"unicode/utf8"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"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/share"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
func FileInfoFaster(opts iteminfo.FileOptions) (*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)
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
return response, fmt.Errorf("could not get real path for requested path: %v", opts.Path)
}
opts.IsDir = isDir
var info *iteminfo.FileInfo
var exists bool
err = index.RefreshFileInfo(opts)
if err != nil {
if err == errors.ErrNotIndexed && index.Config.DisableIndexing {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
} else if err == errors.ErrNotIndexed {
return response, fmt.Errorf("could not refresh file info: %v", err)
}
} else {
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
}
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
if opts.Access != nil && !opts.Access.Permitted(index.Path, opts.Path, opts.Username) {
// check if any subpath is permitted
// keep track of permitted paths and only show them at the end
subFolders := info.Folders
subFiles := info.Files
response.Folders = make([]iteminfo.ItemInfo, 0)
response.Files = make([]iteminfo.ItemInfo, 0)
hasPermittedPaths := false
for _, subFolder := range subFolders {
indexPath := info.Path + subFolder.Name
if opts.Access.Permitted(index.Path, indexPath, opts.Username) {
hasPermittedPaths = true
response.Folders = append(response.Folders, subFolder)
}
}
for _, subFile := range subFiles {
indexPath := info.Path + subFile.Name
if opts.Access.Permitted(index.Path, indexPath, opts.Username) {
hasPermittedPaths = true
response.Files = append(response.Files, subFile)
}
}
if !hasPermittedPaths {
return response, errors.ErrPermissionDenied
}
}
if opts.Content {
processContent(response, index)
}
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
return response, nil
}
func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index) {
isVideo := strings.HasPrefix(info.Type, "video")
if isVideo {
parentInfo, exists := idx.GetReducedMetadata(filepath.Dir(info.Path), true)
if exists {
info.DetectSubtitles(parentInfo)
err := info.LoadSubtitleContent()
if err != nil {
logger.Debug("failed to load subtitle content: " + err.Error())
}
}
return
}
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(info.RealPath)
if err != nil {
logger.Debugf("could not get content for file: "+info.RealPath, info.Name, err)
return
}
info.Content = content
} else {
logger.Debug("skipping large text file contents (20MB limit): "+info.Path, info.Name)
}
}
func generateOfficeId(realPath string) string {
key, ok := utils.OnlyOfficeCache.Get(realPath).(string)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
utils.OnlyOfficeCache.Set(realPath, documentKey)
return documentKey
}
return key
}
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func GetChecksum(fullPath, algo string) (map[string]string, error) {
subs := map[string]string{}
reader, err := os.Open(fullPath)
if err != nil {
return subs, err
}
defer reader.Close()
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
h, ok := hashFuncs[algo]
if !ok {
return subs, errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return subs, err
}
subs[algo] = hex.EncodeToString(h.Sum(nil))
return subs, nil
}
func DeleteFiles(source, absPath string, absDirPath string) error {
err := os.RemoveAll(absPath)
if err != nil {
return err
}
index := indexing.GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
if index.Config.DisableIndexing {
return nil
}
refreshConfig := iteminfo.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return err
}
return nil
}
func RefreshIndex(source string, path string, isDir bool) error {
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", source)
}
if idx.Config.DisableIndexing {
return nil
}
path = idx.MakeIndexPath(path)
return idx.RefreshFileInfo(iteminfo.FileOptions{Path: path, IsDir: isDir})
}
// validateMoveDestination checks if a move/rename operation is valid
// It prevents moving a directory into itself or its subdirectories
func validateMoveDestination(src, dst string, isSrcDir bool) error {
// Clean and normalize paths
src = filepath.Clean(src)
dst = filepath.Clean(dst)
// If source is a directory, check if destination is within source
if isSrcDir {
// Get the parent directory of the destination
dstParent := filepath.Dir(dst)
// Check if destination parent is the source directory or a subdirectory of it
if strings.HasPrefix(dstParent+string(filepath.Separator), src+string(filepath.Separator)) || dstParent == src {
return fmt.Errorf("cannot move directory '%s' to a location within itself: '%s'", src, dst)
}
}
// Check if destination parent directory exists
dstParent := filepath.Dir(dst)
if dstParent != "." && dstParent != "/" {
if _, err := os.Stat(dstParent); os.IsNotExist(err) {
return fmt.Errorf("destination directory does not exist: '%s'", dstParent)
}
}
return nil
}
func MoveResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage) error {
// 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
}
go RefreshIndex(sourceIndex, realsrc, isSrcDir) //nolint:errcheck
go RefreshIndex(destIndex, realdst, isDestDir) //nolint:errcheck
// update shares
idx := indexing.GetIndex(sourceIndex)
if idx == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idx2 := indexing.GetIndex(destIndex)
if idx2 == nil {
return fmt.Errorf("could not get index: %v ", destIndex)
}
// Use backend source paths to match how shares are stored
go s.UpdateShares(idx.Path, idx.MakeIndexPath(realsrc), idx2.Path, idx2.MakeIndexPath(realdst)) //nolint:errcheck
return nil
}
func CopyResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, realdst string) error {
// Validate the copy operation before executing
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
return err
}
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
go RefreshIndex(sourceIndex, realsrc, isSrcDir) //nolint:errcheck
go RefreshIndex(destIndex, realdst, isDestDir) //nolint:errcheck
// update shares
return nil
}
func WriteDirectory(opts iteminfo.FileOptions) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(realPath, fileutils.PermDir)
if err != nil {
return err
}
return RefreshIndex(idx.Name, opts.Path, true)
}
func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
dst, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(filepath.Dir(dst), fileutils.PermDir)
if err != nil {
return err
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
file, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
defer file.Close()
// Copy the contents from the reader to the file
_, err = io.Copy(file, in)
if err != nil {
return err
}
return RefreshIndex(opts.Source, opts.Path, false)
}
// getContent reads and returns the file content if it's considered an editable text file.
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)
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
}
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)
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
// Handle empty file (original logic - returns specific 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
}
func IsNamedPipe(mode os.FileMode) bool {
return mode&os.ModeNamedPipe != 0
}
func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0
}
func Exists(path string) bool {
_, err := os.Stat(path)
if err == nil {
return true
}
if os.IsNotExist(err) {
return false
}
return false
}
+612
View File
@@ -0,0 +1,612 @@
package files
// This file contains code primarily sourced from::
// github.com/kataras/iris
import (
"mime"
)
const (
// ContentBinaryHeaderValue header value for binary data.
ContentBinaryHeaderValue = "application/octet-stream"
// ContentWebassemblyHeaderValue header value for web assembly files.
ContentWebassemblyHeaderValue = "application/wasm"
// ContentHTMLHeaderValue is the string of text/html response header's content type value.
ContentHTMLHeaderValue = "text/html"
// ContentJSONHeaderValue header value for JSON data.
ContentJSONHeaderValue = "application/json"
// ContentJSONProblemHeaderValue header value for JSON API problem error.
// Read more at: https://tools.ietf.org/html/rfc7807
ContentJSONProblemHeaderValue = "application/problem+json"
// ContentXMLProblemHeaderValue header value for XML API problem error.
// Read more at: https://tools.ietf.org/html/rfc7807
ContentXMLProblemHeaderValue = "application/problem+xml"
// ContentJavascriptHeaderValue header value for JSONP & Javascript data.
ContentJavascriptHeaderValue = "text/javascript"
// ContentTextHeaderValue header value for Text data.
ContentTextHeaderValue = "text/plain"
// ContentXMLHeaderValue header value for XML data.
ContentXMLHeaderValue = "text/xml"
// ContentXMLUnreadableHeaderValue obsolete header value for XML.
ContentXMLUnreadableHeaderValue = "application/xml"
// ContentMarkdownHeaderValue custom key/content type, the real is the text/html.
ContentMarkdownHeaderValue = "text/markdown"
// ContentYAMLHeaderValue header value for YAML data.
ContentYAMLHeaderValue = "application/x-yaml"
// ContentYAMLTextHeaderValue header value for YAML plain text.
ContentYAMLTextHeaderValue = "text/yaml"
// ContentProtobufHeaderValue header value for Protobuf messages data.
ContentProtobufHeaderValue = "application/x-protobuf"
// ContentMsgPackHeaderValue header value for MsgPack data.
ContentMsgPackHeaderValue = "application/msgpack"
// ContentMsgPack2HeaderValue alternative header value for MsgPack data.
ContentMsgPack2HeaderValue = "application/x-msgpack"
// ContentFormHeaderValue header value for post form data.
ContentFormHeaderValue = "application/x-www-form-urlencoded"
// ContentFormMultipartHeaderValue header value for post multipart form data.
ContentFormMultipartHeaderValue = "multipart/form-data"
// ContentMultipartRelatedHeaderValue header value for multipart related data.
ContentMultipartRelatedHeaderValue = "multipart/related"
// ContentGRPCHeaderValue Content-Type header value for gRPC.
ContentGRPCHeaderValue = "application/grpc"
)
var types = map[string]string{
".3dm": "x-world/x-3dmf",
".3dmf": "x-world/x-3dmf",
".7z": "application/x-7z-compressed",
".a": "application/octet-stream",
".aab": "application/x-authorware-bin",
".aam": "application/x-authorware-map",
".aas": "application/x-authorware-seg",
".abc": "text/vndabc",
".ace": "application/x-ace-compressed",
".acgi": "text/html",
".afl": "video/animaflex",
".ai": "application/postscript",
".aif": "audio/aiff",
".aifc": "audio/aiff",
".aiff": "audio/aiff",
".aim": "application/x-aim",
".aip": "text/x-audiosoft-intra",
".alz": "application/x-alz-compressed",
".ani": "application/x-navi-animation",
".aos": "application/x-nokia-9000-communicator-add-on-software",
".aps": "application/mime",
".apk": "application/vnd.android.package-archive",
".arc": "application/x-arc-compressed",
".arj": "application/arj",
".art": "image/x-jg",
".asf": "video/x-ms-asf",
".asm": "text/x-asm",
".asp": "text/asp",
".asx": "application/x-mplayer2",
".au": "audio/basic",
".avi": "video/x-msvideo",
".avs": "video/avs-video",
".bcpio": "application/x-bcpio",
".bin": "application/mac-binary",
".bmp": "image/bmp",
".boo": "application/book",
".book": "application/book",
".boz": "application/x-bzip2",
".bsh": "application/x-bsh",
".bz2": "application/x-bzip2",
".bz": "application/x-bzip",
".c++": ContentTextHeaderValue,
".c": "text/x-c",
".cab": "application/vnd.ms-cab-compressed",
".cat": "application/vndms-pkiseccat",
".cc": "text/x-c",
".ccad": "application/clariscad",
".cco": "application/x-cocoa",
".cdf": "application/cdf",
".cer": "application/pkix-cert",
".cha": "application/x-chat",
".chat": "application/x-chat",
".chrt": "application/vnd.kde.kchart",
".class": "application/java",
".com": ContentTextHeaderValue,
".conf": ContentTextHeaderValue,
".cpio": "application/x-cpio",
".cpp": "text/x-c",
".cpt": "application/mac-compactpro",
".crl": "application/pkcs-crl",
".crt": "application/pkix-cert",
".crx": "application/x-chrome-extension",
".csh": "text/x-scriptcsh",
".css": "text/css",
".csv": "text/csv",
".cxx": ContentTextHeaderValue,
".dar": "application/x-dar",
".dcr": "application/x-director",
".deb": "application/x-debian-package",
".deepv": "application/x-deepv",
".def": ContentTextHeaderValue,
".der": "application/x-x509-ca-cert",
".dif": "video/x-dv",
".dir": "application/x-director",
".divx": "video/divx",
".dl": "video/dl",
".dmg": "application/x-apple-diskimage",
".doc": "application/msword",
".dot": "application/msword",
".dp": "application/commonground",
".drw": "application/drafting",
".dump": "application/octet-stream",
".dv": "video/x-dv",
".dvi": "application/x-dvi",
".dwf": "drawing/x-dwf=(old)",
".dwg": "application/acad",
".dxf": "application/dxf",
".dxr": "application/x-director",
".el": "text/x-scriptelisp",
".elc": "application/x-bytecodeelisp=(compiled=elisp)",
".eml": "message/rfc822",
".env": "application/x-envoy",
".eps": "application/postscript",
".es": "application/x-esrehber",
".etx": "text/x-setext",
".evy": "application/envoy",
".exe": "application/octet-stream",
".f77": "text/x-fortran",
".f90": "text/x-fortran",
".f": "text/x-fortran",
".fdf": "application/vndfdf",
".fif": "application/fractals",
".fli": "video/fli",
".flo": "image/florian",
".flv": "video/x-flv",
".flx": "text/vndfmiflexstor",
".fmf": "video/x-atomic3d-feature",
".for": "text/x-fortran",
".fpx": "image/vndfpx",
".frl": "application/freeloader",
".funk": "audio/make",
".g3": "image/g3fax",
".g": ContentTextHeaderValue,
".gif": "image/gif",
".gl": "video/gl",
".gsd": "audio/x-gsm",
".gsm": "audio/x-gsm",
".gsp": "application/x-gsp",
".gss": "application/x-gss",
".gtar": "application/x-gtar",
".gz": "application/x-compressed",
".gzip": "application/x-gzip",
".h": "text/x-h",
".hdf": "application/x-hdf",
".help": "application/x-helpfile",
".hgl": "application/vndhp-hpgl",
".hh": "text/x-h",
".hlb": "text/x-script",
".hlp": "application/hlp",
".hpg": "application/vndhp-hpgl",
".hpgl": "application/vndhp-hpgl",
".hqx": "application/binhex",
".hta": "application/hta",
".htc": "text/x-component",
".htm": "text/html",
".html": "text/html",
".htmls": "text/html",
".htt": "text/webviewhtml",
".htx": "text/html",
".ice": "x-conference/x-cooltalk",
".ico": "image/x-icon",
".ics": "text/calendar",
".icz": "text/calendar",
".idc": ContentTextHeaderValue,
".ief": "image/ief",
".iefs": "image/ief",
".iges": "application/iges",
".igs": "application/iges",
".ima": "application/x-ima",
".imap": "application/x-httpd-imap",
".inf": "application/inf",
".ins": "application/x-internett-signup",
".ip": "application/x-ip2",
".isu": "video/x-isvideo",
".it": "audio/it",
".iv": "application/x-inventor",
".ivr": "i-world/i-vrml",
".ivy": "application/x-livescreen",
".jam": "audio/x-jam",
".jav": "text/x-java-source",
".java": "text/x-java-source",
".jcm": "application/x-java-commerce",
".jfif-tbnl": "image/jpeg",
".jfif": "image/jpeg",
".jnlp": "application/x-java-jnlp-file",
".jpe": "image/jpeg",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".jps": "image/x-jps",
".js": ContentJavascriptHeaderValue,
".mjs": ContentJavascriptHeaderValue,
".json": ContentJSONHeaderValue,
".vue": ContentJavascriptHeaderValue,
".jut": "image/jutvision",
".kar": "audio/midi",
".karbon": "application/vnd.kde.karbon",
".kfo": "application/vnd.kde.kformula",
".flw": "application/vnd.kde.kivio",
".kml": "application/vnd.google-earth.kml+xml",
".kmz": "application/vnd.google-earth.kmz",
".kon": "application/vnd.kde.kontour",
".kpr": "application/vnd.kde.kpresenter",
".kpt": "application/vnd.kde.kpresenter",
".ksp": "application/vnd.kde.kspread",
".kwd": "application/vnd.kde.kword",
".kwt": "application/vnd.kde.kword",
".ksh": "text/x-scriptksh",
".la": "audio/nspaudio",
".lam": "audio/x-liveaudio",
".latex": "application/x-latex",
".lha": "application/lha",
".lhx": "application/octet-stream",
".list": ContentTextHeaderValue,
".lma": "audio/nspaudio",
".log": ContentTextHeaderValue,
".lsp": "text/x-scriptlisp",
".lst": ContentTextHeaderValue,
".lsx": "text/x-la-asf",
".ltx": "application/x-latex",
".lzh": "application/octet-stream",
".lzx": "application/lzx",
".m1v": "video/mpeg",
".m2a": "audio/mpeg",
".m2v": "video/mpeg",
".m3u": "audio/x-mpegurl",
".m": "text/x-m",
".man": "application/x-troff-man",
".manifest": "text/cache-manifest",
".map": "application/x-navimap",
".mar": ContentTextHeaderValue,
".mbd": "application/mbedlet",
".mc$": "application/x-magic-cap-package-10",
".mcd": "application/mcad",
".mcf": "text/mcf",
".mcp": "application/netmc",
".me": "application/x-troff-me",
".mht": "message/rfc822",
".mhtml": "message/rfc822",
".mid": "application/x-midi",
".midi": "application/x-midi",
".mif": "application/x-frame",
".mime": "message/rfc822",
".mjf": "audio/x-vndaudioexplosionmjuicemediafile",
".mjpg": "video/x-motion-jpeg",
".mm": "application/base64",
".mme": "application/base64",
".mod": "audio/mod",
".moov": "video/quicktime",
".mov": "video/quicktime",
".movie": "video/x-sgi-movie",
".mp2": "audio/mpeg",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".mpa": "audio/mpeg",
".mpc": "application/x-project",
".mpe": "video/mpeg",
".mpeg": "video/mpeg",
".mpg": "video/mpeg",
".mpga": "audio/mpeg",
".mpp": "application/vndms-project",
".mpt": "application/x-project",
".mpv": "application/x-project",
".mpx": "application/x-project",
".mrc": "application/marc",
".ms": "application/x-troff-ms",
".mv": "video/x-sgi-movie",
".my": "audio/make",
".mzz": "application/x-vndaudioexplosionmzz",
".nap": "image/naplps",
".naplps": "image/naplps",
".nc": "application/x-netcdf",
".ncm": "application/vndnokiaconfiguration-message",
".nif": "image/x-niff",
".niff": "image/x-niff",
".nix": "application/x-mix-transfer",
".nsc": "application/x-conference",
".nvd": "application/x-navidoc",
".o": "application/octet-stream",
".oda": "application/oda",
".odb": "application/vnd.oasis.opendocument.database",
".odc": "application/vnd.oasis.opendocument.chart",
".odf": "application/vnd.oasis.opendocument.formula",
".odg": "application/vnd.oasis.opendocument.graphics",
".odi": "application/vnd.oasis.opendocument.image",
".odm": "application/vnd.oasis.opendocument.text-master",
".odp": "application/vnd.oasis.opendocument.presentation",
".ods": "application/vnd.oasis.opendocument.spreadsheet",
".odt": "application/vnd.oasis.opendocument.text",
".oga": "audio/ogg",
".ogg": "audio/ogg",
".ogv": "video/ogg",
".omc": "application/x-omc",
".omcd": "application/x-omcdatamaker",
".omcr": "application/x-omcregerator",
".otc": "application/vnd.oasis.opendocument.chart-template",
".otf": "application/vnd.oasis.opendocument.formula-template",
".otg": "application/vnd.oasis.opendocument.graphics-template",
".oth": "application/vnd.oasis.opendocument.text-web",
".oti": "application/vnd.oasis.opendocument.image-template",
".otm": "application/vnd.oasis.opendocument.text-master",
".otp": "application/vnd.oasis.opendocument.presentation-template",
".ots": "application/vnd.oasis.opendocument.spreadsheet-template",
".ott": "application/vnd.oasis.opendocument.text-template",
".p10": "application/pkcs10",
".p12": "application/pkcs-12",
".p7a": "application/x-pkcs7-signature",
".p7c": "application/pkcs7-mime",
".p7m": "application/pkcs7-mime",
".p7r": "application/x-pkcs7-certreqresp",
".p7s": "application/pkcs7-signature",
".p": "text/x-pascal",
".part": "application/pro_eng",
".pas": "text/pascal",
".pbm": "image/x-portable-bitmap",
".pcl": "application/vndhp-pcl",
".pct": "image/x-pict",
".pcx": "image/x-pcx",
".pdb": "chemical/x-pdb",
".pdf": "application/pdf",
".pfunk": "audio/make",
".pgm": "image/x-portable-graymap",
".pic": "image/pict",
".pict": "image/pict",
".pkg": "application/x-newton-compatible-pkg",
".pko": "application/vndms-pkipko",
".pl": "text/x-scriptperl",
".plx": "application/x-pixclscript",
".pm4": "application/x-pagemaker",
".pm5": "application/x-pagemaker",
".pm": "text/x-scriptperl-module",
".png": "image/png",
".pnm": "application/x-portable-anymap",
".pot": "application/mspowerpoint",
".pov": "model/x-pov",
".ppa": "application/vndms-powerpoint",
".ppm": "image/x-portable-pixmap",
".pps": "application/mspowerpoint",
".ppt": "application/mspowerpoint",
".ppz": "application/mspowerpoint",
".pre": "application/x-freelance",
".prt": "application/pro_eng",
".ps": "application/postscript",
".psd": "application/octet-stream",
".pvu": "paleovu/x-pv",
".pwz": "application/vndms-powerpoint",
".py": "text/x-scriptphyton",
".pyc": "application/x-bytecodepython",
".qcp": "audio/vndqcelp",
".qd3": "x-world/x-3dmf",
".qd3d": "x-world/x-3dmf",
".qif": "image/x-quicktime",
".qt": "video/quicktime",
".qtc": "video/x-qtc",
".qti": "image/x-quicktime",
".qtif": "image/x-quicktime",
".ra": "audio/x-pn-realaudio",
".ram": "audio/x-pn-realaudio",
".rar": "application/x-rar-compressed",
".ras": "application/x-cmu-raster",
".rast": "image/cmu-raster",
".rexx": "text/x-scriptrexx",
".rf": "image/vndrn-realflash",
".rgb": "image/x-rgb",
".rm": "application/vndrn-realmedia",
".rmi": "audio/mid",
".rmm": "audio/x-pn-realaudio",
".rmp": "audio/x-pn-realaudio",
".rng": "application/ringing-tones",
".rnx": "application/vndrn-realplayer",
".roff": "application/x-troff",
".rp": "image/vndrn-realpix",
".rpm": "audio/x-pn-realaudio-plugin",
".rt": "text/vndrn-realtext",
".rtf": "text/richtext",
".rtx": "text/richtext",
".rv": "video/vndrn-realvideo",
".s": "text/x-asm",
".s3m": "audio/s3m",
".s7z": "application/x-7z-compressed",
".saveme": "application/octet-stream",
".sbk": "application/x-tbook",
".scm": "text/x-scriptscheme",
".sdml": ContentTextHeaderValue,
".sdp": "application/sdp",
".sdr": "application/sounder",
".sea": "application/sea",
".set": "application/set",
".sgm": "text/x-sgml",
".sgml": "text/x-sgml",
".sh": "text/x-scriptsh",
".shar": "application/x-bsh",
".shtml": "text/x-server-parsed-html",
".sid": "audio/x-psid",
".skd": "application/x-koan",
".skm": "application/x-koan",
".skp": "application/x-koan",
".skt": "application/x-koan",
".sit": "application/x-stuffit",
".sitx": "application/x-stuffitx",
".sl": "application/x-seelogo",
".smi": "application/smil",
".smil": "application/smil",
".snd": "audio/basic",
".sol": "application/solids",
".spc": "text/x-speech",
".spl": "application/futuresplash",
".spr": "application/x-sprite",
".sprite": "application/x-sprite",
".spx": "audio/ogg",
".src": "application/x-wais-source",
".srt": "text/plain",
".sbv": "text/plain",
".ssa": "text/plain",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
".sst": "application/vndms-pkicertstore",
".step": "application/step",
".stl": "application/sla",
".stp": "application/step",
".sv4cpio": "application/x-sv4cpio",
".sv4crc": "application/x-sv4crc",
".svf": "image/vnddwg",
".svg": "image/svg+xml",
".svr": "application/x-world",
".swf": "application/x-shockwave-flash",
".t": "application/x-troff",
".talk": "text/x-speech",
".tar": "application/x-tar",
".tbk": "application/toolbook",
".tcl": "text/x-scripttcl",
".tcsh": "text/x-scripttcsh",
".tex": "application/x-tex",
".texi": "application/x-texinfo",
".texinfo": "application/x-texinfo",
".text": ContentTextHeaderValue,
".tgz": "application/gnutar",
".tif": "image/tiff",
".tiff": "image/tiff",
".tr": "application/x-troff",
".tsi": "audio/tsp-audio",
".tsp": "application/dsptype",
".tsv": "text/tab-separated-values",
".turbot": "image/florian",
".txt": ContentTextHeaderValue,
".uil": "text/x-uil",
".uni": "text/uri-list",
".unis": "text/uri-list",
".unv": "application/i-deas",
".uri": "text/uri-list",
".uris": "text/uri-list",
".ustar": "application/x-ustar",
".uu": "text/x-uuencode",
".uue": "text/x-uuencode",
".vcd": "application/x-cdlink",
".vcf": "text/x-vcard",
".vcard": "text/x-vcard",
".vcs": "text/x-vcalendar",
".vda": "application/vda",
".vdo": "video/vdo",
".vew": "application/groupwise",
".viv": "video/vivo",
".vivo": "video/vivo",
".vmd": "application/vocaltec-media-desc",
".vmf": "application/vocaltec-media-file",
".voc": "audio/voc",
".vos": "video/vosaic",
".vox": "audio/voxware",
".vqe": "audio/x-twinvq-plugin",
".vqf": "audio/x-twinvq",
".vql": "audio/x-twinvq-plugin",
".vrml": "application/x-vrml",
".vrt": "x-world/x-vrt",
".vsd": "application/x-visio",
".vst": "application/x-visio",
".vsw": "application/x-visio",
".w60": "application/wordperfect60",
".w61": "application/wordperfect61",
".w6w": "application/msword",
".wav": "audio/wav",
".wb1": "application/x-qpro",
".wbmp": "image/vnd.wap.wbmp",
".web": "application/vndxara",
".wiz": "application/msword",
".wk1": "application/x-123",
".wmf": "windows/metafile",
".wml": "text/vnd.wap.wml",
".wmlc": "application/vnd.wap.wmlc",
".wmls": "text/vnd.wap.wmlscript",
".wmlsc": "application/vnd.wap.wmlscriptc",
".word": "application/msword",
".wp5": "application/wordperfect",
".wp6": "application/wordperfect",
".wp": "application/wordperfect",
".wpd": "application/wordperfect",
".wq1": "application/x-lotus",
".wri": "application/mswrite",
".wrl": "application/x-world",
".wrz": "model/vrml",
".wsc": "text/scriplet",
".wsrc": "application/x-wais-source",
".wtk": "application/x-wintalk",
".x-png": "image/png",
".xbm": "image/x-xbitmap",
".xdr": "video/x-amt-demorun",
".xgz": "xgl/drawing",
".xif": "image/vndxiff",
".xl": "application/excel",
".xla": "application/excel",
".xlb": "application/excel",
".xlc": "application/excel",
".xld": "application/excel",
".xlk": "application/excel",
".xll": "application/excel",
".xlm": "application/excel",
".xls": "application/excel",
".xlt": "application/excel",
".xlv": "application/excel",
".xlw": "application/excel",
".xm": "audio/xm",
".xml": ContentXMLHeaderValue,
".xmz": "xgl/movie",
".xpix": "application/x-vndls-xpix",
".xpm": "image/x-xpixmap",
".xsr": "video/x-amt-showrun",
".xwd": "image/x-xwd",
".xyz": "chemical/x-pdb",
".z": "application/x-compress",
".zip": "application/zip",
".zoo": "application/octet-stream",
".zsh": "text/x-scriptzsh",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".docm": "application/vnd.ms-word.document.macroEnabled.12",
".dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
".dotm": "application/vnd.ms-word.template.macroEnabled.12",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
".xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template",
".xltm": "application/vnd.ms-excel.template.macroEnabled.12",
".xlsb": "application/vnd.ms-excel.sheet.binary.macroEnabled.12",
".xlam": "application/vnd.ms-excel.addin.macroEnabled.12",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".pptm": "application/vnd.ms-powerpoint.presentation.macroEnabled.12",
".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
".ppsm": "application/vnd.ms-powerpoint.slideshow.macroEnabled.12",
".potx": "application/vnd.openxmlformats-officedocument.presentationml.template",
".potm": "application/vnd.ms-powerpoint.template.macroEnabled.12",
".ppam": "application/vnd.ms-powerpoint.addin.macroEnabled.12",
".sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide",
".sldm": "application/vnd.ms-powerpoint.slide.macroEnabled.12",
".thmx": "application/vnd.ms-officetheme",
".onetoc": "application/onenote",
".onetoc2": "application/onenote",
".onetmp": "application/onenote",
".onepkg": "application/onenote",
".xpi": "application/x-xpinstall",
".wasm": "application/wasm",
".m4a": "audio/mp4",
".flac": "audio/x-flac",
".amr": "audio/amr",
".aac": "audio/aac",
".opus": "video/ogg",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".caf": "audio/x-caf",
".m3u8": "application/x-mpegURL",
".mpd": "application/dash+xml",
".webp": "image/webp",
".epub": "application/epub+zip",
}
//nolint:gochecknoinits
func init() {
for ext, typ := range types {
// skip errors
_ = mime.AddExtensionType(ext, typ)
}
}
+64
View File
@@ -0,0 +1,64 @@
package files
import (
"fmt"
"os"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MakeUserDir makes the user directory according to settings.
func MakeUserDir(fullPath string) error {
if err := os.MkdirAll(fullPath, os.ModePerm); err != nil {
return err
}
return nil
}
func MakeUserDirs(u *users.User, disableScopeChange bool) error {
cleanedUserName := users.CleanUsername(u.Username)
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
}
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)
}
// 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 !Exists(parentDir) {
return fmt.Errorf("MakeUserDirs: scope path does not exist: %s", scope.Scope)
}
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
}
} 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)
}
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: failed to create user home dir: %s", err)
}
} 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)
}
}
u.Scopes[i] = scope
}
return nil
}
File renamed without changes.
File renamed without changes.
@@ -1,35 +1,41 @@
package fileutils
import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
const PermFile = 0644
const PermDir = 0755
// MoveFile moves a file from src to dst.
// By default, the rename system call is used. If src and dst point to different volumes,
// the file copy is used as a fallback.
func MoveFile(src, dst string) error {
fmt.Println("moving", src, dst)
if os.Rename(src, dst) == nil {
err := os.Rename(src, dst)
if err == nil {
return nil
}
fmt.Println("copyfile instead", src, dst)
// fallback
err := CopyFile(src, dst)
err = CopyFile(src, dst)
if err != nil {
fmt.Println("ok it errored too", err)
logger.Errorf("CopyFile failed %v %v %v ", src, dst, err)
return err
}
go func() {
err = os.RemoveAll(src)
if err != nil {
logger.Errorf("os.Remove failed %v %v ", src, err)
}
}()
_ = os.Remove(dst)
return err
}
fmt.Println("removing", src)
if err := os.Remove(src); err != nil {
return err
}
return nil
}
@@ -60,13 +66,13 @@ func copySingleFile(source, dest string) error {
defer src.Close()
// Create the destination directory if needed.
err = os.MkdirAll(filepath.Dir(dest), 0775) //nolint:gomnd
err = os.MkdirAll(filepath.Dir(dest), PermDir)
if err != nil {
return err
}
// Create the destination file.
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) //nolint:gomnd
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, PermFile)
if err != nil {
return err
}
@@ -94,7 +100,7 @@ func copySingleFile(source, dest string) error {
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
// Create the destination directory.
err := os.MkdirAll(dest, 0775) //nolint:gomnd
err := os.MkdirAll(dest, PermDir)
if err != nil {
return err
}
@@ -172,3 +178,20 @@ func CommonPrefix(sep byte, paths ...string) string {
return string(c)
}
func ClearCacheDir() {
cacheDir := settings.Config.Server.CacheDir
entries, err := os.ReadDir(cacheDir)
if err != nil {
logger.Errorf("failed clear cache dir: %v", err)
}
for _, entry := range entries {
path := filepath.Join(cacheDir, entry.Name())
err = os.RemoveAll(path)
if err != nil {
logger.Errorf("failed clear cache dir: %v", err)
}
}
}
File renamed without changes.
+22 -1
View File
@@ -2,10 +2,20 @@ package auth
import (
"net/http"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var (
revokedApiKeyList map[string]struct{}
revokeMu sync.Mutex
)
func init() {
revokedApiKeyList = make(map[string]struct{})
}
// Auther is the authentication interface.
type Auther interface {
// Auth is called to authenticate a request.
@@ -13,3 +23,14 @@ type Auther interface {
// LoginPage indicates if this auther needs a login page.
LoginPage() bool
}
func IsRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
}
func RevokeAPIKey(key string) {
revokeMu.Lock()
defer revokeMu.Unlock()
revokedApiKeyList[key] = struct{}{}
}
-282
View File
@@ -1,282 +0,0 @@
package auth
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type hookCred struct {
Password string `json:"password"`
Username string `json:"username"`
}
// HookAuth is a hook implementation of an Auther.
type HookAuth struct {
Users users.Store `json:"-"`
Settings *settings.Settings `json:"-"`
Server *settings.Server `json:"-"`
Cred hookCred `json:"-"`
Fields hookFields `json:"-"`
Command string `json:"command"`
}
// Auth authenticates the user via a json in content body.
func (a *HookAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
var cred hookCred
if r.Body == nil {
return nil, os.ErrPermission
}
err := json.NewDecoder(r.Body).Decode(&cred)
if err != nil {
return nil, os.ErrPermission
}
a.Users = usr
a.Settings = &settings.Config
a.Server = &settings.Config.Server
a.Cred = cred
action, err := a.RunCommand()
if err != nil {
return nil, err
}
switch action {
case "auth":
u, err := a.SaveUser()
if err != nil {
return nil, err
}
return u, nil
case "block":
return nil, os.ErrPermission
case "pass":
u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
if err != nil || !users.CheckPwd(a.Cred.Password, u.Password) {
return nil, os.ErrPermission
}
return u, nil
default:
return nil, fmt.Errorf("invalid hook action: %s", action)
}
}
// LoginPage tells that hook auth requires a login page.
func (a *HookAuth) LoginPage() bool {
return true
}
// RunCommand starts the hook command and returns the action
func (a *HookAuth) RunCommand() (string, error) {
command := strings.Split(a.Command, " ")
envMapping := func(key string) string {
switch key {
case "USERNAME":
return a.Cred.Username
case "PASSWORD":
return a.Cred.Password
default:
return os.Getenv(key)
}
}
for i, arg := range command {
if i == 0 {
continue
}
command[i] = os.Expand(arg, envMapping)
}
cmd := exec.Command(command[0], command[1:]...) //nolint:gosec
cmd.Env = append(os.Environ(), fmt.Sprintf("USERNAME=%s", a.Cred.Username))
cmd.Env = append(cmd.Env, fmt.Sprintf("PASSWORD=%s", a.Cred.Password))
out, err := cmd.Output()
if err != nil {
return "", err
}
a.GetValues(string(out))
return a.Fields.Values["hook.action"], nil
}
// GetValues creates a map with values from the key-value format string
func (a *HookAuth) GetValues(s string) {
m := map[string]string{}
// make line breaks consistent on Windows platform
s = strings.ReplaceAll(s, "\r\n", "\n")
// iterate input lines
for _, val := range strings.Split(s, "\n") {
v := strings.SplitN(val, "=", 2) //nolint: gomnd
// skips non key and value format
if len(v) != 2 { //nolint: gomnd
continue
}
fieldKey := strings.TrimSpace(v[0])
fieldValue := strings.TrimSpace(v[1])
if a.Fields.IsValid(fieldKey) {
m[fieldKey] = fieldValue
}
}
a.Fields.Values = m
}
// SaveUser updates the existing user or creates a new one when not found
func (a *HookAuth) SaveUser() (*users.User, error) {
u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
if err != nil && err != errors.ErrNotExist {
return nil, err
}
if u == nil {
// create user with the provided credentials
d := &users.User{
Username: a.Cred.Username,
Password: a.Cred.Password,
Scope: a.Settings.UserDefaults.Scope,
Locale: a.Settings.UserDefaults.Locale,
ViewMode: a.Settings.UserDefaults.ViewMode,
SingleClick: a.Settings.UserDefaults.SingleClick,
Sorting: a.Settings.UserDefaults.Sorting,
Perm: a.Settings.UserDefaults.Perm,
Commands: a.Settings.UserDefaults.Commands,
ShowHidden: a.Settings.UserDefaults.ShowHidden,
}
u = a.GetUser(d)
userHome, err := a.Settings.MakeUserDir(u.Username, u.Scope, a.Server.Root)
if err != nil {
return nil, fmt.Errorf("user: failed to mkdir user home dir: [%s]", userHome)
}
u.Scope = userHome
logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", u.Username, userHome))
err = a.Users.Save(u)
if err != nil {
return nil, err
}
} else if p := !users.CheckPwd(a.Cred.Password, u.Password); len(a.Fields.Values) > 1 || p {
u = a.GetUser(u)
// update user with provided fields
err := a.Users.Update(u)
if err != nil {
return nil, err
}
}
return u, nil
}
// GetUser returns a User filled with hook values or provided defaults
func (a *HookAuth) GetUser(d *users.User) *users.User {
// adds all permissions when user is admin
isAdmin := d.Perm.Admin
perms := users.Permissions{
Admin: isAdmin,
Execute: isAdmin || d.Perm.Execute,
Create: isAdmin || d.Perm.Create,
Rename: isAdmin || d.Perm.Rename,
Modify: isAdmin || d.Perm.Modify,
Delete: isAdmin || d.Perm.Delete,
Share: isAdmin || d.Perm.Share,
Download: isAdmin || d.Perm.Download,
}
user := users.User{
ID: d.ID,
Username: d.Username,
Password: d.Password,
Scope: d.Scope,
Locale: d.Locale,
ViewMode: d.ViewMode,
SingleClick: d.SingleClick,
Sorting: users.Sorting{
Asc: d.Sorting.Asc,
By: d.Sorting.By,
},
Commands: d.Commands,
ShowHidden: d.ShowHidden,
Perm: perms,
LockPassword: true,
}
return &user
}
// hookFields is used to access fields from the hook
type hookFields struct {
Values map[string]string
}
// validHookFields contains names of the fields that can be used
var validHookFields = []string{
"hook.action",
"user.scope",
"user.locale",
"user.viewMode",
"user.singleClick",
"user.sorting.by",
"user.sorting.asc",
"user.commands",
"user.showHidden",
"user.perm.admin",
"user.perm.execute",
"user.perm.create",
"user.perm.rename",
"user.perm.modify",
"user.perm.delete",
"user.perm.share",
"user.perm.download",
}
// IsValid checks if the provided field is on the valid fields list
func (hf *hookFields) IsValid(field string) bool {
for _, val := range validHookFields {
if field == val {
return true
}
}
return false
}
// GetString returns the string value or provided default
func (hf *hookFields) GetString(k, dv string) string {
val, ok := hf.Values[k]
if ok {
return val
}
return dv
}
// GetBoolean returns the bool value or provided default
func (hf *hookFields) GetBoolean(k string, dv bool) bool {
val, ok := hf.Values[k]
if ok {
return val == "true"
}
return dv
}
// GetArray returns the array value or provided default
func (hf *hookFields) GetArray(k string, dv []string) []string {
val, ok := hf.Values[k]
if ok && strings.TrimSpace(val) != "" {
return strings.Split(val, " ")
}
return dv
}
+35 -24
View File
@@ -2,41 +2,31 @@ package auth
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type jsonCred struct {
Password string `json:"password"`
Username string `json:"username"`
ReCaptcha string `json:"recaptcha"`
}
// JSONAuth is a json implementation of an Auther.
type JSONAuth struct {
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
}
// Auth authenticates the user via a json in content body.
func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
var cred jsonCred
if r.Body == nil {
return nil, os.ErrPermission
}
err := json.NewDecoder(r.Body).Decode(&cred)
if err != nil {
return nil, os.ErrPermission
}
func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
password := r.Header.Get("X-Password")
totpCode := r.Header.Get("X-Secret")
// If ReCaptcha is enabled, check the code.
if a.ReCaptcha != nil && len(a.ReCaptcha.Secret) > 0 {
ok, err := a.ReCaptcha.Ok(cred.ReCaptcha) //nolint:govet
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
ok, err := auther.ReCaptcha.Ok(recaptcha) //nolint:govet
if err != nil {
return nil, err
@@ -46,12 +36,33 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
return nil, os.ErrPermission
}
}
u, err := userStore.Get(files.RootPaths["default"], cred.Username)
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
return nil, os.ErrPermission
user, err := userStore.Get(username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
err = users.CheckPwd(password, user.Password)
if err != nil {
return nil, err
}
return u, nil
// check for OTP for password
if user.TOTPSecret != "" {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
}
err = VerifyTotpCode(user, totpCode, userStore)
if err != nil {
return nil, err
}
}
if user.LoginMethod != users.LoginMethodPassword {
return nil, errors.ErrWrongLoginMethod
}
return user, nil
}
// LoginPage tells that json auth doesn't require a login page.
+2 -3
View File
@@ -3,8 +3,7 @@ package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodNoAuth is used to identify no auth.
@@ -15,7 +14,7 @@ type NoAuth struct{}
// Auth uses authenticates user 1.
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return usr.Get(files.RootPaths["default"], uint(1))
return usr.Get(uint(1))
}
// LoginPage tells that no auth doesn't require a login page.
+3 -4
View File
@@ -4,9 +4,8 @@ import (
"net/http"
"os"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodProxyAuth is used to identify no auth.
@@ -20,7 +19,7 @@ type ProxyAuth struct {
// Auth authenticates the user via an HTTP header.
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
username := r.Header.Get(a.Header)
user, err := usr.Get(files.RootPaths["default"], username)
user, err := usr.Get(username)
if err == errors.ErrNotExist {
return nil, os.ErrPermission
}
+37 -3
View File
@@ -1,7 +1,14 @@
package auth
import (
"github.com/gtsteffaniak/filebrowser/backend/users"
"encoding/base64"
"log"
"crypto/sha256"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"golang.org/x/crypto/pbkdf2"
)
// StorageBackend is a storage backend for auth storage.
@@ -17,8 +24,35 @@ type Storage struct {
}
// NewStorage creates a auth storage from a backend.
func NewStorage(back StorageBackend, userStore *users.Storage) *Storage {
return &Storage{back: back, users: userStore}
func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error) {
store := &Storage{back: back, users: userStore}
err := store.Save(&JSONAuth{})
if err != nil {
return nil, err
}
err = store.Save(&ProxyAuth{})
if err != nil {
return nil, err
}
err = store.Save(&NoAuth{})
if err != nil {
return nil, err
}
if settings.Config.Auth.TotpSecret != "" {
key, err := base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
keyLen := len(key)
if err == nil && (keyLen == 16 || keyLen == 24 || keyLen == 25) {
// Use the user-provided key if it's valid
encryptionKey = key
} else {
// Otherwise, derive the key from the provided secret string
log.Println("[WARN] totpSecret is not a valid Base64 encoded key. Deriving a key from it. For better security, generate a secret with 'openssl rand -base64 32'.")
salt := []byte{0xda, 0x90, 0x45, 0xc3, 0x06, 0xb5, 0x99, 0x9f, 0xb6, 0xae, 0xfc, 0x14, 0xef, 0x27, 0x6e, 0x6a}
encryptionKey = pbkdf2.Key([]byte(settings.Config.Auth.TotpSecret), salt, 4096, 32, sha256.New)
}
}
return store, nil
}
// Get wraps a StorageBackend.Get.
+203
View File
@@ -0,0 +1,203 @@
package auth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
// Constants for TOTP (Time-based One-Time Password) configuration.
const (
// IssuerName is the name of the application or service.
IssuerName = "FileBrowser Quantum"
// TokenValidTime defines the total duration a token is considered valid.
// Note: The actual validation window might be slightly longer depending on the period.
TokenValidTime = time.Minute * 2
// TOTPPeriod is the standard duration in seconds that a TOTP code is valid.
TOTPPeriod uint = 30
// TOTPSecretSize is the byte length of the shared secret.
TOTPSecretSize uint = 20
// TOTPDigits specifies the number of digits in the OTP code.
TOTPDigits = otp.DigitsSix
// TOTPAlgorithm is the hashing algorithm to use.
TOTPAlgorithm = otp.AlgorithmSHA1
)
var (
// TOTPSkew allows for a certain number of periods of clock drift.
// We calculate it to best match the TokenValidTime.
// (2 * Skew + 1) * Period >= TokenValidTime
// For a 2-minute (120s) window with a 30s period, we need a Skew of 2.
// (2*2 + 1) * 30s = 150s (2.5 minutes). This is the closest we can get.
TOTPSkew uint = uint(TokenValidTime.Seconds()) / (2 * uint(TOTPPeriod))
encryptionKey []byte
TotpCache = cache.NewCache(5 * time.Minute)
)
func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, error) {
// Generate a new TOTP key using the defined constants.
key, err := totp.Generate(totp.GenerateOpts{
Issuer: IssuerName,
AccountName: user.Username,
Period: TOTPPeriod,
SecretSize: TOTPSecretSize,
Digits: TOTPDigits,
Algorithm: TOTPAlgorithm,
})
if err != nil {
return "", fmt.Errorf("error generating TOTP key: %w", err)
}
secretText := key.Secret()
nonce := ""
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is provided, encrypt the secret.
secretText, 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)
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
return url, nil
}
// encryptSecret uses AES-GCM to encrypt a plaintext secret.
// It returns the base64-encoded ciphertext and nonce, or an error.
func encryptSecret(secret string, key []byte) (string, string, error) {
if len(key) != 32 {
return "", "", fmt.Errorf("invalid encryption key length: must be 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", "", err
}
ciphertext := gcm.Seal(nil, nonce, []byte(secret), nil)
return base64.StdEncoding.EncodeToString(ciphertext), base64.StdEncoding.EncodeToString(nonce), nil
}
// decryptSecret uses AES-GCM to decrypt a ciphertext using its key and nonce.
// It returns the plaintext secret or an error.
func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
if len(encryptionKey) != 32 {
return "", fmt.Errorf("invalid encryption key length: must be 32 bytes")
}
ciphertext, err := base64.StdEncoding.DecodeString(b64Ciphertext)
if err != nil {
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
}
nonce, err := base64.StdEncoding.DecodeString(b64Nonce)
if err != nil {
return "", fmt.Errorf("failed to decode nonce: %w", err)
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
// This error often means the key is wrong or the data is corrupt
return "", fmt.Errorf("failed to decrypt secret: %w", err)
}
return string(plaintext), nil
}
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
// get data from cache
cachedSecret, found := TotpCache.Get(user.Username).(string)
if !found && user.TOTPSecret == "" {
return fmt.Errorf("OTP token not found in cache, please generate a new one")
}
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
if found {
splitSecret := strings.Split(cachedSecret, "||")
if len(splitSecret) < 2 {
return fmt.Errorf("invalid cached OTP token format")
}
totpSecret = splitSecret[0]
totpNonce = splitSecret[1]
}
secretToValidate := totpSecret
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is configured, we must decrypt the secret first.
if totpNonce == "" {
return fmt.Errorf("secret is encrypted but nonce is missing")
}
decryptedSecret, err := decryptSecret(totpSecret, totpNonce)
if err != nil {
return fmt.Errorf("failed to decrypt TOTP secret: %w", err)
}
secretToValidate = decryptedSecret
}
// --- END: ADD THIS DECRYPTION LOGIC ---
// Validate the token using the (now plaintext) secret.
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
Period: TOTPPeriod,
Skew: TOTPSkew,
Digits: TOTPDigits,
Algorithm: TOTPAlgorithm,
})
if err != nil {
logger.Errorf("error during TOTP validation: %v", err)
}
if !valid {
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" {
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
// save user
if err := userStore.Update(user, true, "TOTPSecret", "TOTPNonce"); err != nil {
logger.Error("error updating user with OTP token:", err)
return fmt.Errorf("error updating user with OTP token: %w", err)
}
} else {
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
}
return nil
}
+69 -52
View File
@@ -2,65 +2,82 @@
== 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/diskcache 0.005s
? github.com/gtsteffaniak/filebrowser/backend/errors [no test files]
/home/graham/git/filebrowser /home/graham/git/filebrowser
/home/graham/git/filebrowser/files/file.go /home/graham/git/filebrowser
/home/graham/git/filebrowser/mnt/doesnt/exist /home/graham/git/filebrowser
2025/01/04 14:04:55 Initializing index and assessing file system complexity
2025/01/04 14:04:55 Starting full scan
2025/01/04 14:04:55 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:55 Time Spent Indexing : 0 seconds
2025/01/04 14:04:55 Next scan in 1m0s
2025/01/04 14:04:56 Initializing index and assessing file system complexity
2025/01/04 14:04:56 Starting full scan
2025/01/04 14:04:56 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:56 Time Spent Indexing : 0 seconds
2025/01/04 14:04:56 Next scan in 1m0s
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/files
pkg: github.com/gtsteffaniak/filebrowser/backend/indexing
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 2025/01/04 14:04:57 Initializing index and assessing file system complexity
2025/01/04 14:04:57 Starting full scan
2025/01/04 14:04:57 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:57 Time Spent Indexing : 0 seconds
2025/01/04 14:04:57 Next scan in 1m0s
10 3515090 ns/op 34273 B/op 451 allocs/op
BenchmarkCheckIndexExclude-8 10 156.2 ns/op 0 B/op 0 allocs/op
BenchmarkCheckIndexConditionsInclude-8 10 98.00 ns/op 0 B/op 0 allocs/op
2025/01/04 14:04:58 Initializing index and assessing file system complexity
2025/01/04 14:04:58 Starting full scan
2025/01/04 14:04:58 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:58 Time Spent Indexing : 0 seconds
2025/01/04 14:04:58 Next scan in 1m0s
BenchmarkSearchAllIndexes-8 2025/01/04 14:04:59 Initializing index and assessing file system complexity
2025/01/04 14:04:59 Starting full scan
2025/01/04 14:04:59 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:59 Time Spent Indexing : 0 seconds
2025/01/04 14:04:59 Next scan in 1m0s
10 766822 ns/op 34230 B/op 900 allocs/op
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/files 5.094s
ok github.com/gtsteffaniak/filebrowser/backend/indexing 5.097s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/fileutils 0.002s
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.005s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/http 0.184s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/img 0.123s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/runner 0.004s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/settings 0.005s
? github.com/gtsteffaniak/filebrowser/backend/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/storage [no test files]
? github.com/gtsteffaniak/filebrowser/backend/storage/bolt [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.128s
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/users 0.003s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/utils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/version [no test files]
? github.com/gtsteffaniak/filebrowser/backend/tools [no test files]
-87
View File
@@ -1,87 +0,0 @@
package cache
import (
"sync"
"time"
)
var (
DiskUsage = NewCache(30*time.Second, 24*time.Hour)
RealPath = NewCache(48*time.Hour, 72*time.Hour)
SearchResults = NewCache(15*time.Second, time.Hour)
OnlyOffice = NewCache(48*time.Hour, 1*time.Hour)
)
func NewCache(expires time.Duration, cleanup time.Duration) *KeyCache {
newCache := KeyCache{
data: make(map[string]cachedValue),
expiresAfter: expires, // default
}
go newCache.cleanupExpiredJob(cleanup)
return &newCache
}
type KeyCache struct {
data map[string]cachedValue
mu sync.RWMutex
expiresAfter time.Duration
}
type cachedValue struct {
value interface{}
expiresAt time.Time
}
func (c *KeyCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cachedValue{
value: value,
expiresAt: time.Now().Add(c.expiresAfter),
}
}
func (c *KeyCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
func (c *KeyCache) SetWithExp(key string, value interface{}, exp time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cachedValue{
value: value,
expiresAt: time.Now().Add(exp),
}
}
func (c *KeyCache) Get(key string) interface{} {
c.mu.RLock()
defer c.mu.RUnlock()
cached, ok := c.data[key]
if !ok || time.Now().After(cached.expiresAt) {
return nil
}
return cached.value
}
func (c *KeyCache) cleanupExpired() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, cached := range c.data {
if now.After(cached.expiresAt) {
delete(c.data, key)
}
}
}
// should automatically run for all cache types as part of init.
func (c *KeyCache) cleanupExpiredJob(frequency time.Duration) {
ticker := time.NewTicker(frequency)
defer ticker.Stop()
for range ticker.C {
c.cleanupExpired()
}
}
+369
View File
@@ -0,0 +1,369 @@
package cmd
import (
"bufio"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
var (
configPath string
)
// return bool to indicate if the program should continue running
func runCLI() bool {
generateYaml()
// Global flags
var help bool
// Override the default usage output to use generalUsage()
flag.Usage = generalUsage
flag.StringVar(&configPath, "c", "", "Path to the config file, default: config.yaml")
flag.BoolVar(&help, "h", false, "Get help about commands")
if configPath == "" {
configPath = os.Getenv("FILEBROWSER_CONFIG")
// backwards compatibility in docker, prefer config.yaml if it exists
if configPath != "" {
_, err := os.Stat(configPath)
if err != nil {
logger.Fatalf("config file %v does not exist, please create it or set the FILEBROWSER_CONFIG environment variable to a valid config file path", configPath)
}
} else {
configPath = "config.yaml"
}
}
// Parse global flags (before subcommands)
flag.Parse() // print generalUsage on error
// Show help if requested
if help {
generalUsage()
return false
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, dbConfig string
var asAdmin 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")
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "setup":
createConfig(configPath)
return false
case "set":
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
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,
})
}
}
// 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)
if err != nil {
logger.Errorf("could not create user: %v", err)
}
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)
if err != nil {
logger.Errorf("could not update user: %v", err)
}
fmt.Printf("successfully updated user: %s\n", username)
return false
case "version":
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return false
}
}
return true
}
// UserDefaults defines default settings for new users.
type UserDefaults struct {
Permissions users.Permissions `yaml:"permissions"`
}
// Frontend defines settings related to the web interface.
type Frontend struct {
Name string `yaml:"name,omitempty"`
}
// Source defines a directory to be served.
type Source struct {
Name string `yaml:"name,omitempty"`
Path string `yaml:"path"`
}
// Server defines server-specific configurations.
type Server struct {
Port int `yaml:"port"`
Database string `yaml:"database"`
Sources []Source `yaml:"sources"`
Logging []LogConfig `json:"logging"`
}
type LogConfig struct {
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Utc bool `json:"utc"` // use UTC time in the output instead of local time
}
type Auth struct {
AdminUsername string `yaml:"adminUsername"`
AdminPassword string `yaml:"adminPassword"`
}
// Settings is the top-level configuration structure.
type Settings struct {
Server Server `yaml:"server"`
Frontend Frontend `yaml:"frontend,omitempty"`
Auth Auth `yaml:"auth"`
UserDefaults UserDefaults `yaml:"userDefaults"`
}
// askQuestion displays a prompt and reads a line of input from the user.
// It returns the defaultValue if the user's input is empty.
func askQuestion(reader *bufio.Reader, prompt string, defaultValue string) string {
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return defaultValue
}
return input
}
// askYesNoQuestion prompts the user with a yes/no question and returns a boolean.
// It retries until valid input ("yes", "y", "no", "n") is received.
// The defaultValue must be "yes" or "no".
func askYesNoQuestion(reader *bufio.Reader, prompt string, defaultValue string) bool {
for {
answer := askQuestion(reader, prompt, defaultValue)
answer = strings.ToLower(answer)
if answer == "yes" || answer == "y" {
return true
}
if answer == "no" || answer == "n" {
return false
}
fmt.Println("Error: Invalid input. Please enter 'yes' or 'no'.")
}
}
// createConfig orchestrates the configuration process by asking the user a series of questions.
func createConfig(configpath string) {
// check if config file exists
if _, err := os.Stat("config.yaml"); err == nil {
fmt.Println("Config file 'config.yaml' already exists, skipping setup.")
return
}
reader := bufio.NewReader(os.Stdin)
config := Settings{
Server: Server{
Logging: []LogConfig{
{
Levels: "info|warning|error",
ApiLevels: "info|warning|error",
Output: "stdout",
NoColors: false,
Utc: false,
},
},
Sources: []Source{
{
Path: "",
},
},
},
}
fmt.Println("--- Starting Configuration Setup ---")
realPath := ""
// 1. Ask for the source filesystem path (with validation)
for {
config.Server.Sources[0].Path = askQuestion(reader, "What is the source filesystem path?", "./")
// Convert relative path to absolute path
absolutePath, err := filepath.Abs(config.Server.Sources[0].Path)
if err == nil {
var isDir bool
// Resolve symlinks and get the real path
realPath, isDir, _ = iteminfo.ResolveSymlinks(absolutePath)
if realPath != "" && isDir {
break // Valid path found, exit loop
}
}
fmt.Printf("Error: The path '%s' does not exist or isn't valid. Please try again.\n", config.Server.Sources[0].Path)
}
// 2. Ask for the source name
defaultSourceName := filepath.Base(realPath)
sourceName := askQuestion(reader, "What should the first source name be?", defaultSourceName)
if sourceName != defaultSourceName {
config.Server.Sources[0].Name = sourceName
}
// 3. Ask for server port (with validation)
for {
portStr := askQuestion(reader, "What port should the server listen on?", "80")
port, err := strconv.Atoi(portStr)
if err == nil && (port >= 1 && port <= 65535) {
config.Server.Port = port
break // Port is valid, exit loop
}
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
}
for {
levels := askQuestion(reader, "What should the log levels be?", "info|warning|error")
checkLevels := SplitByMultiple(levels)
invalidOptions := []string{}
for _, level := range checkLevels {
if !(level == "info" || level == "warning" || level == "error" || level == "debug") {
invalidOptions = append(invalidOptions, level)
}
}
if len(invalidOptions) == 0 {
break
}
fmt.Printf("Error: invalid options given '%s'. valid options: 'info|warning|error|debug'.\n", invalidOptions)
}
for {
config.Server.Database = askQuestion(reader, "What should the file name and path be for the database?", "./database.db")
if strings.HasSuffix(config.Server.Database, ".db") {
break // Valid path found, exit loop
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .db", config.Server.Database)
}
// 4. Ask for the application brand name
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
// 5. Ask for admin username and password
config.Auth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
config.Auth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
// 6. Ask boolean (Yes/No) questions using the helper
config.UserDefaults.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.UserDefaults.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
fmt.Println("--- Configuration Complete ---")
// marshall yaml and write to file
// Marshal the struct to YAML bytes
yamlData, err := yaml.Marshal(&config)
if err != nil {
return
}
// Write the YAML data to the file
err = os.WriteFile("config.yaml", yamlData, 0644) // 0644 provides read/write for owner, read for others
if err != nil {
return
}
// cleanup database if it exists
if _, err := os.Stat(config.Server.Database); err == nil {
response := askYesNoQuestion(reader, "Database specified already exists. Move databse file to backup to start fresh?", "no")
if !response {
return
}
// move database file to backup
backupPath := config.Server.Database + ".bak"
err = os.Rename(config.Server.Database, backupPath)
if err != nil {
fmt.Printf("Error moving database file to backup: %v\n", err)
} else {
fmt.Printf("Database file moved to backup: %s\n", backupPath)
}
}
}
func generateYaml() {
if os.Getenv("FILEBROWSER_GENERATE_CONFIG") != "" {
logger.Info("Generating config.yaml")
settings.GenerateYaml()
os.Exit(0)
}
}
func SplitByMultiple(str string) []string {
delimiters := []rune{'|', ',', ' '}
return strings.FieldsFunc(str, func(r rune) bool {
for _, d := range delimiters {
if r == d {
return true
}
}
return false
})
}
+21
View File
@@ -0,0 +1,21 @@
package cmd
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
// healthcheck attempt to save test file against configured url
func validateOfficeIntegration() {
if settings.Config.Integrations.OnlyOffice.Url != "" {
// get url health
// get request against the url
_, err := http.NewRequest("GET", settings.Config.Integrations.OnlyOffice.Url, nil)
if err != nil {
logger.Warning("Could not create request to only office Url, make sure its valid and running")
return
}
}
}
+82 -116
View File
@@ -1,33 +1,39 @@
package cmd
import (
"flag"
"context"
"fmt"
"os"
"strings"
"os/signal"
"syscall"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
_ "net/http/pprof"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/swaggo/swag"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
func getStore(config string) (*storage.Storage, bool) {
var store *bolt.BoltStore
func getStore(configFile string) bool {
// Use the config file (global flag)
settings.Initialize(config)
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
settings.Initialize(configFile)
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
logger.Fatal(fmt.Sprintf("could not load db info: %v", err))
logger.Fatalf("could not load db info: %v", err)
}
return store, hasDB
store = s
return hasDB
}
func generalUsage() {
@@ -44,130 +50,90 @@ commands:
}
func StartFilebrowser() {
// Global flags
var configPath string
var help bool
// Override the default usage output to use generalUsage()
flag.Usage = generalUsage
flag.StringVar(&configPath, "c", "config.yaml", "Path to the config file, default: config.yaml")
flag.BoolVar(&help, "h", false, "Get help about commands")
// Parse global flags (before subcommands)
flag.Parse() // print generalUsage on error
// Show help if requested
if help {
generalUsage()
keepGoing := runCLI()
if !keepGoing {
return
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, scope, dbConfig string
var asAdmin bool
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
setCmd.BoolVar(&asAdmin, "a", false, "Create user as admin user, used in combination with -u")
setCmd.StringVar(&scope, "s", "", "Specify a user scope, otherwise default user config scope is used")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "set":
err := setCmd.Parse(os.Args)
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
userInfo := strings.Split(user, ",")
if len(userInfo) < 2 {
fmt.Println("not enough info to create user: \"set -u username,password\"")
setCmd.PrintDefaults()
os.Exit(1)
}
username := userInfo[0]
password := userInfo[1]
getStore(dbConfig)
// Create the user logic
if asAdmin {
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
} else {
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
}
newUser := users.User{
Username: username,
Password: password,
}
if scope != "" {
newUser.Scope = scope
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
logger.Fatal(fmt.Sprintf("could not create user: %v", err))
}
return
case "version":
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return
if !settings.Config.Server.DisableUpdateCheck {
info, _ := utils.CheckForUpdates()
if info.LatestVersion != "" {
logger.Infof("A new version is available: %s (current: %s)", info.LatestVersion, info.CurrentVersion)
logger.Infof("Release notes: %s", info.ReleaseNotes)
}
go utils.StartCheckForUpdates()
}
store, dbExists := getStore(configPath)
// Create context and channels for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
dbExists := getStore(configPath)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
sources := []string{}
for _, v := range settings.Config.Server.Sources {
sources = append(sources, v.Name+": "+v.Path)
sourceList := []string{}
for path, source := range settings.Config.Server.SourceMap {
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
}
logger.Info(fmt.Sprintf("Initializing FileBrowser Quantum (%v)", version.Version))
logger.Info(fmt.Sprintf("Using Config file : %v", configPath))
logger.Debug(fmt.Sprintf("Embeded frontend : %v", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true"))
logger.Infof("Initializing FileBrowser Quantum (%v)", version.Version)
logger.Infof("Using Config file : %v", configPath)
logger.Infof("Auth Methods : %v", settings.Config.Auth.AuthMethods)
logger.Info(database)
logger.Info(fmt.Sprintf("Sources : %v", sources))
logger.Infof("Sources : %v", sourceList)
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL
swag.Register(docs.SwaggerInfo.InstanceName(), swagInfo)
// initialize indexing and schedule indexing ever n minutes (default 5)
sourceConfigs := settings.Config.Server.Sources
if len(sourceConfigs) == 0 {
if len(settings.Config.Server.SourceMap) == 0 {
logger.Fatal("No sources configured, exiting...")
}
for _, source := range sourceConfigs {
go files.Initialize(source)
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false)
}
if err := rootCMD(store, &serverConfig); err != nil {
logger.Fatal(fmt.Sprintf("Error starting filebrowser: %v", err))
validateUserInfo()
validateOfficeIntegration()
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
logger.Fatalf("Error starting filebrowser: %v", err)
}
close(done) // Signal that the server has stopped
}()
// Wait for a shutdown signal or the server to stop
select {
case <-signalChan:
logger.Info("Received shutdown signal. Shutting down gracefully...")
cancel() // Trigger context cancellation
case <-done:
logger.Info("Server stopped unexpectedly. Shutting down...")
}
fileutils.ClearCacheDir()
<-shutdownComplete // Ensure we don't exit prematurely
// Wait for the server to stop
logger.Info("Shutdown complete.")
}
func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
if serverConfig.NumImageProcessors < 1 {
logger.Fatal("Image resize workers count could not be < 1")
}
imgSvc := img.New(serverConfig.NumImageProcessors)
cacheDir := "/tmp"
var fileCache diskcache.Interface
// Use file cache if cacheDir is specified
if cacheDir != "" {
var err error
fileCache, err = diskcache.NewFileCache(cacheDir)
if err != nil {
logger.Fatal(fmt.Sprintf("failed to create file cache: %v", err))
}
} else {
// No-op cache if no cacheDir is specified
fileCache = diskcache.NewNoOp()
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)
if err != nil {
logger.Fatalf("Error starting preview service: %v", err)
}
fbhttp.StartHttp(imgSvc, store, fileCache)
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+154
View File
@@ -0,0 +1,154 @@
package cmd
import (
"fmt"
"reflect"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
var createBackup = []bool{}
func validateUserInfo() {
// update source info for users if names/sources/paths might have changed
usersList, err := store.Users.Gets()
if err != nil {
logger.Fatalf("could not load users: %v", err)
}
for _, user := range usersList {
changePass := false
updateUser := false
if updateUserScopes(user) {
updateUser = true
}
if updatePermissions(user) {
updateUser = true
}
if updatePreviewSettings(user) {
updateUser = true
}
if updateLoginType(user) {
updateUser = true
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
if user.Username == adminUser && adminPass != "" && passwordEnabled {
logger.Info("Resetting admin user to default username and password.")
user.Permissions.Admin = true
user.Password = settings.Config.Auth.AdminPassword
updateUser = true
changePass = true
}
if updateUser {
if len(createBackup) == 1 {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
err = fileutils.CopyFile(settings.Config.Server.Database, fmt.Sprintf("%s.bak", settings.Config.Server.Database))
if err != nil {
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
}
}
err := store.Users.Save(user, changePass, true)
if err != nil {
logger.Errorf("could not update user: %v", err)
}
}
}
}
func updateUserScopes(user *users.User) bool {
newScopes := []users.SourceScope{}
seen := make(map[string]struct{})
// Build map for existing scopes by Name
existing := make(map[string]users.SourceScope)
for _, s := range user.Scopes {
existing[s.Name] = s
}
// 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]
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 {
continue
}
newScopes = append(newScopes, users.SourceScope{
Name: realsource.Path,
Scope: existingScope.Scope,
})
seen[realsource.Path] = struct{}{}
}
// Preserve user-defined scopes not matching current sources, append to end
for _, s := range user.Scopes {
if _, ok := seen[s.Name]; !ok {
newScopes = append(newScopes, s)
}
}
changed := !reflect.DeepEqual(user.Scopes, newScopes)
user.Scopes = newScopes
return changed
}
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
updateUser := false
// if any keys are true, set the permissions to true
if user.Perm.Api {
user.Permissions.Api = true
user.Perm.Api = false
updateUser = true
}
if user.Perm.Admin {
user.Permissions.Admin = true
user.Perm.Admin = false
updateUser = true
}
if user.Perm.Modify {
user.Permissions.Modify = true
user.Perm.Modify = false
updateUser = true
}
if user.Perm.Share {
user.Permissions.Share = true
user.Perm.Share = false
updateUser = true
}
if updateUser {
createBackup = append(createBackup, true)
}
return updateUser
}
func updateLoginType(user *users.User) bool {
if user.LoginMethod == "" {
user.LoginMethod = users.LoginMethodPassword
return true
}
return false
}
func updatePreviewSettings(user *users.User) bool {
// if user hasn't been updated yet
if user.LoginMethod == "" {
user.Preview.Image = true
user.Preview.PopUp = true
return true
}
return false
}
+182
View File
@@ -0,0 +1,182 @@
package cmd
import (
"reflect"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/stretchr/testify/assert"
)
func TestUpdateUserScopes_Phases(t *testing.T) {
sourceA := settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
DefaultEnabled: false,
DefaultUserScope: "/defaultA",
}}
sourceB := settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []settings.Source{sourceA, sourceB}
settings.Config.Server.SourceMap = map[string]settings.Source{
"/pathA": sourceA,
"/pathB": sourceB,
}
settings.Config.Server.NameToSource = map[string]settings.Source{
"sourceA": sourceA,
"sourceB": sourceB,
}
// ---------------------
// Test Cases Definition
// ---------------------
testCases := []struct {
name string
user *users.User
expectedPhase1 []users.SourceScope
expectedPhase2 []users.SourceScope
}{
{
name: "Single valid scope",
user: &users.User{
Scopes: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
},
},
{
name: "Single empty scope path",
user: &users.User{
Scopes: []users.SourceScope{
{Scope: "", Name: "/pathB"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultB", Name: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
},
},
{
name: "Two scopes, one includes username in path",
user: &users.User{
Username: "user123",
Scopes: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
},
},
{
name: "Two scopes, one with empty name",
user: &users.User{
Scopes: []users.SourceScope{
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
},
},
{
name: "No scopes at all",
user: &users.User{
Scopes: []users.SourceScope{},
},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultB", Name: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
},
},
{
name: "All user Scope and source change",
user: &users.User{
Username: "user123",
Scopes: []users.SourceScope{
{Scope: "/defaultC/user123", Name: "/pathC"},
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
{Scope: "/defaultC/user123", Name: "/pathC"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
{Scope: "/defaultC/user123", Name: "/pathC"},
},
},
}
// ---------------------
// Phase 1 Tests
// ---------------------
for _, tc := range testCases {
t.Run(tc.name+"_Phase1", func(t *testing.T) {
originalScopes := tc.user.Scopes
updated := updateUserScopes(tc.user)
assert.Equal(t, tc.expectedPhase1, tc.user.Scopes, "Phase1 scope mismatch for test case: %s", tc.name)
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.Scopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.Scopes, tc.expectedPhase1)
})
}
// ---------------------
// Phase 2: rename sources but keep same paths
// ---------------------
sourceA = settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
DefaultEnabled: true,
DefaultUserScope: "/defaultA",
CreateUserDir: true,
}}
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []settings.Source{sourceA, sourceB}
settings.Config.Server.SourceMap = map[string]settings.Source{
"/pathA": sourceA,
"/pathB": sourceB,
}
settings.Config.Server.NameToSource = map[string]settings.Source{
"sourceA": sourceA,
"sourceB": sourceB,
}
// Run again without resetting user objects to test idempotency + renaming
for _, tc := range testCases {
t.Run(tc.name+"_Phase2", func(t *testing.T) {
originalScopes := tc.user.Scopes
updated := updateUserScopes(tc.user)
assert.Equal(t, tc.expectedPhase2, tc.user.Scopes, "Phase2 scope mismatch for test case: %s", tc.name)
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.Scopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.Scopes, tc.expectedPhase1)
})
}
}
@@ -17,5 +17,9 @@ var (
ErrPermissionDenied = errors.New("permission denied")
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrRootUserDeletion = errors.New("user with id 1 can't be deleted")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
)
+111
View File
@@ -0,0 +1,111 @@
package settings
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gtsteffaniak/go-logger/logger"
)
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
Methods LoginMethods `json:"methods"`
Key string `json:"key"` // secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
AdminUsername string `json:"adminUsername"` // secret: the username of the admin user. If not set, the default is "admin".
AdminPassword string `json:"adminPassword"` // secret: the password of the admin user. If not set, the default is "admin".
TotpSecret string `json:"totpSecret"` // secret: secret used to encrypt TOTP secrets
AuthMethods []string `json:"-"`
}
type LoginMethods struct {
ProxyAuth ProxyAuthConfig `json:"proxy" validate:"omitempty"`
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
}
type PasswordAuthConfig struct {
Enabled bool `json:"enabled"`
MinLength int `json:"minLength" validate:"omitempty"` // minimum pasword length required, default is 5.
Signup bool `json:"signup" validate:"omitempty"` // allow signups on login page if enabled -- not secure.
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
EnforcedOtp bool `json:"enforcedOtp"` // if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"` // create user if not exists
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
}
type Recaptcha struct {
Host string `json:"host" validate:"required"`
Key string `json:"key" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
// OpenID OAuth2.0
type OidcConfig struct {
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
ClientID string `json:"clientId"` // secret: client id of the OIDC application
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
CreateUser bool `json:"createUser"` // create user if not exists
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges.
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
Provider *oidc.Provider `json:"-"` // OIDC provider
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
}
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
func validateOidcAuth() error {
oidcCfg := &Config.Auth.Methods.OidcAuth // Use a pointer to modify the original config
if oidcCfg.GroupsClaim == "" {
oidcCfg.GroupsClaim = "groups"
}
if oidcCfg.UserIdentifier == "" {
oidcCfg.UserIdentifier = "preferred_username"
}
if oidcCfg.Scopes == "" {
oidcCfg.Scopes = "openid email profile"
}
if !oidcCfg.Enabled {
return errors.New("OIDC is not enabled")
}
ctx := context.Background()
// If disableVerifyTLS is true, create a custom HTTP client
// and set it in the context for the OIDC provider.
if oidcCfg.DisableVerifyTLS {
// Create a custom transport with InsecureSkipVerify set to true
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
customClient := &http.Client{
Transport: transport,
}
// Use oidc.ClientContext to pass the custom client to the OIDC library
ctx = oidc.ClientContext(ctx, customClient)
// Log that TLS verification is disabled (important for security awareness)
logger.Warning("OIDC TLS verification is disabled.")
}
provider, err := oidc.NewProvider(ctx, oidcCfg.IssuerUrl)
if err != nil {
return fmt.Errorf("url '%v' failed to create OIDC provider: %w", oidcCfg.IssuerUrl, err)
}
oidcCfg.Provider = provider
oidcCfg.Verifier = provider.Verifier(&oidc.Config{ClientID: oidcCfg.ClientID})
return nil
}
+594
View File
@@ -0,0 +1,594 @@
package settings
import (
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
var Config Settings
const (
generatorPath = "/relative/or/absolute/path"
)
func Initialize(configFile string) {
err := loadConfigWithDefaults(configFile, false)
if err != nil {
logger.Error("unable to load config, waiting 5 seconds before exiting...")
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
logger.Fatal(err.Error())
}
err = ValidateConfig(Config)
if err != nil {
errmsg := "The provided config file failed validation. "
errmsg += "If you are seeing this on a config that worked before, "
errmsg += "then check the latest releases for breaking changes. "
errmsg += "visit https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example for more information."
logger.Error(errmsg)
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
logger.Fatal(err.Error())
}
setupLogging()
setupAuth(false)
setupSources(false)
setupUrls()
setupFrontend(false)
}
func setupFrontend(generate bool) {
if Config.Server.MinSearchLength == 0 {
Config.Server.MinSearchLength = 3
}
if !Config.Frontend.DisableDefaultLinks {
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: fmt.Sprintf("(%v)", version.Version),
Title: version.CommitSHA,
Url: "https://github.com/gtsteffaniak/filebrowser/releases/",
})
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: "Help",
Url: "help prompt",
})
}
if Config.Frontend.Description == "" {
Config.Frontend.Description = "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server"
}
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)
Config.Frontend.Styling.CustomThemeOptions = map[string]CustomTheme{}
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
for name, theme := range Config.Frontend.Styling.CustomThemes {
addCustomTheme(name, theme.Description, theme.CSS)
}
noThemes := len(Config.Frontend.Styling.CustomThemes) == 0
if noThemes {
addCustomTheme("default", "The default theme", "")
// check if file exists
if _, err := os.Stat("reduce-rounded-corners.css"); err == nil {
addCustomTheme("alternative", "Reduce rounded corners", "reduce-rounded-corners.css")
if generate {
Config.Frontend.Styling.CustomThemes["alternative"] = CustomTheme{
Description: "Reduce rounded corners",
CSS: "reduce-rounded-corners.css",
}
}
}
}
_, ok := Config.Frontend.Styling.CustomThemes["default"]
if !ok {
addCustomTheme("default", "The default theme", "")
}
// Load custom favicon if configured
loadCustomFavicon()
}
func getRealPath(path string) string {
realPath, err := filepath.Abs(path)
if err != nil {
logger.Fatalf("could not find configured source path: %v", err)
}
// check path exists
if _, err = os.Stat(realPath); os.IsNotExist(err) {
logger.Fatalf("configured source path does not exist: %v", realPath)
}
return realPath
}
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.")
} else {
for k, source := range Config.Server.Sources {
if source.Config.Disabled {
continue
}
realPath := getRealPath(source.Path)
name := filepath.Base(realPath)
if name == "\\" {
name = strings.Split(realPath, ":")[0]
}
if generate {
source.Path = generatorPath // use placeholder path
} else {
source.Path = realPath // use absolute path
}
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
if ok {
source.Name = name + fmt.Sprintf("-%v", k)
} else {
source.Name = name
}
}
modifyExcludeInclude(&source)
if source.Config.DefaultUserScope == "" {
source.Config.DefaultUserScope = "/"
}
Config.Server.SourceMap[source.Path] = source
Config.Server.NameToSource[source.Name] = source
}
}
// clean up the in memory source list to be accurate and unique
sourceList := []Source{}
defaultScopes := []users.SourceScope{}
allSourceNames := []string{}
for _, sourcePathOnly := range Config.Server.Sources {
realPath := getRealPath(sourcePathOnly.Path)
if generate {
realPath = generatorPath // use placeholder path
}
source, ok := Config.Server.SourceMap[realPath]
if ok && !slices.Contains(allSourceNames, source.Name) {
sourceList = append(sourceList, source)
if source.Config.DefaultEnabled {
defaultScopes = append(defaultScopes, users.SourceScope{
Name: source.Path,
Scope: source.Config.DefaultUserScope,
})
}
allSourceNames = append(allSourceNames, source.Name)
} else {
logger.Warningf("source %v is not configured correctly, skipping", sourcePathOnly.Path)
}
}
Config.UserDefaults.DefaultScopes = defaultScopes
Config.Server.Sources = sourceList
}
func setupUrls() {
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Server.BaseURL = "/"
} else {
Config.Server.BaseURL = "/" + baseurl + "/"
}
if Config.Server.BaseURL != "/" {
if Config.Server.ExternalUrl != "" {
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, "/") + "/"
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, Config.Server.BaseURL)
}
if Config.Server.InternalUrl != "" {
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, "/") + "/"
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, Config.Server.BaseURL)
}
}
Config.Integrations.OnlyOffice.Url = strings.Trim(Config.Integrations.OnlyOffice.Url, "/")
Config.Integrations.OnlyOffice.InternalUrl = strings.Trim(Config.Integrations.OnlyOffice.InternalUrl, "/")
}
func setupAuth(generate bool) {
if generate {
Config.Auth.AdminPassword = "admin"
}
if Config.Auth.Methods.PasswordAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
if Config.Auth.Methods.ProxyAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
}
if Config.Auth.Methods.OidcAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
}
if Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
Config.Auth.AuthMethods = []string{"disabled"}
}
if Config.Auth.Methods.OidcAuth.Enabled || generate {
err := validateOidcAuth()
if err != nil && !generate {
logger.Fatalf("Error validating OIDC auth: %v", err)
}
logger.Info("OIDC Auth configured successfully")
}
// use password auth as default if no auth methods are set
if len(Config.Auth.AuthMethods) == 0 {
Config.Auth.Methods.PasswordAuth.Enabled = true
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
Config.UserDefaults.LoginMethod = Config.Auth.AuthMethods[0]
}
func setupLogging() {
if len(Config.Server.Logging) == 0 {
Config.Server.Logging = []LogConfig{
{
Output: "stdout",
},
}
}
for _, logConfig := range Config.Server.Logging {
// Enable debug logging automatically in dev mode
levels := logConfig.Levels
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
levels = "info|warning|error|debug"
}
logConfig := logger.JsonConfig{
Levels: levels,
ApiLevels: logConfig.ApiLevels,
Output: logConfig.Output,
Utc: logConfig.Utc,
NoColors: logConfig.NoColors,
Json: logConfig.Json,
}
err := logger.SetupLogger(logConfig)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
}
}
func loadConfigWithDefaults(configFile string, generate bool) error {
Config = setDefaults(generate)
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
if configFile != "" {
logger.Errorf("could not open config file '%v', using default settings.", configFile)
}
Config.Server.Sources = []Source{
{
Path: ".",
},
}
loadEnvConfig()
return nil
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
if err != nil {
return err
}
yamlData := make([]byte, stat.Size())
_, err = yamlFile.Read(yamlData)
if err != nil && configFile != "config.yaml" {
return fmt.Errorf("could not load specified config file: %v", err.Error())
}
if err != nil {
logger.Warningf("Could not load config file '%v', using default settings: %v", configFile, err)
}
err = yaml.NewDecoder(strings.NewReader(string(yamlData)), yaml.DisallowUnknownField()).Decode(&Config)
if err != nil {
return fmt.Errorf("error unmarshaling YAML data: %v", err)
}
loadEnvConfig()
return nil
}
func ValidateConfig(config Settings) error {
validate := validator.New()
err := validate.Struct(Config)
if err != nil {
return fmt.Errorf("could not validate config: %v", err)
}
return nil
}
func loadEnvConfig() {
adminPassword, ok := os.LookupEnv("FILEBROWSER_ADMIN_PASSWORD")
if ok {
logger.Info("Using admin password from FILEBROWSER_ADMIN_PASSWORD environment variable")
Config.Auth.AdminPassword = adminPassword
}
officeSecret, ok := os.LookupEnv("FILEBROWSER_ONLYOFFICE_SECRET")
if ok {
logger.Info("Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable")
Config.Integrations.OnlyOffice.Secret = officeSecret
}
ffmpegPath, ok := os.LookupEnv("FILEBROWSER_FFMPEG_PATH")
if ok {
Config.Integrations.Media.FfmpegPath = ffmpegPath
}
oidcClientId := os.Getenv("FILEBROWSER_OIDC_CLIENT_ID")
if oidcClientId != "" {
Config.Auth.Methods.OidcAuth.ClientID = oidcClientId
logger.Info("Using OIDC Client ID from FILEBROWSER_OIDC_CLIENT_ID environment variable")
}
oidcClientSecret := os.Getenv("FILEBROWSER_OIDC_CLIENT_SECRET")
if oidcClientSecret != "" {
Config.Auth.Methods.OidcAuth.ClientSecret = oidcClientSecret
logger.Info("Using OIDC Client Secret from FILEBROWSER_OIDC_CLIENT_SECRET environment variable")
}
jwtTokenSecret := os.Getenv("FILEBROWSER_JWT_TOKEN_SECRET")
if jwtTokenSecret != "" {
Config.Auth.Key = jwtTokenSecret
logger.Info("Using JWT Token Secret from FILEBROWSER_JWT_TOKEN_SECRET environment variable")
}
totpSecret := os.Getenv("FILEBROWSER_TOTP_SECRET")
if totpSecret != "" {
Config.Auth.TotpSecret = totpSecret
logger.Info("Using TOTP Secret from FILEBROWSER_TOTP_SECRET environment variable")
}
recaptchaSecret := os.Getenv("FILEBROWSER_RECAPTCHA_SECRET")
if recaptchaSecret != "" {
Config.Auth.Methods.PasswordAuth.Recaptcha.Secret = recaptchaSecret
logger.Info("Using ReCaptcha Secret from FILEBROWSER_RECAPTCHA_SECRET environment variable")
}
}
func setDefaults(generate bool) Settings {
// get number of CPUs available
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
cpus := runtime.NumCPU()
if cpus > 0 && !generate {
numCpus = cpus
}
database := os.Getenv("FILEBROWSER_DATABASE")
if database == "" {
database = "database.db"
}
return Settings{
Server: Server{
Port: 80,
NumImageProcessors: numCpus,
BaseURL: "",
Database: database,
SourceMap: map[string]Source{},
NameToSource: map[string]Source{},
MaxArchiveSizeGB: 50,
CacheDir: "tmp",
},
Auth: Auth{
AdminUsername: "admin",
TokenExpirationHours: 2,
Methods: LoginMethods{
PasswordAuth: PasswordAuthConfig{
Enabled: true,
MinLength: 5,
Signup: false,
},
},
},
Frontend: Frontend{
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
StickySidebar: true,
LockPassword: false,
ShowHidden: false,
DarkMode: true,
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: users.Permissions{
Modify: false,
Share: false,
Admin: false,
Api: false,
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
ChunkSize: 10, // 10MB
},
},
}
}
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
if len(scopes) == 0 {
return Config.UserDefaults.DefaultScopes, nil
}
newScopes := []users.SourceScope{}
for _, scope := range scopes {
// first check if its already a path name and keep it
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
continue
}
// check if its the name of a source and convert it to a path
source, ok = Config.Server.NameToSource[scope.Name]
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,
})
}
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,
})
}
}
return newScopes
}
func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
for _, scope := range scopes {
if scope.Name == sourcePath {
return true
}
}
return false
}
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
source, ok := Config.Server.NameToSource[sourceName]
if !ok {
logger.Debug("Could not get scope from source name: ", sourceName)
return "", fmt.Errorf("source with name not found %v", sourceName)
}
for _, scope := range scopes {
if scope.Name == source.Path {
return scope.Scope, nil
}
}
logger.Debugf("scope not found for source %v", sourceName)
return "", fmt.Errorf("scope not found for source %v", sourceName)
}
func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (string, error) {
for _, scope := range scopes {
if scope.Name == sourcePath {
return scope.Scope, nil
}
}
return "", fmt.Errorf("scope not found for source %v", sourcePath)
}
// assumes backend style scopes
func GetSources(u *users.User) []string {
sources := []string{}
for _, scope := range u.Scopes {
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
sources = append(sources, source.Name)
}
}
return sources
}
func loadCustomFavicon() {
// Check if a custom favicon path is configured
if Config.Frontend.Favicon == "" {
logger.Debug("No custom favicon configured, using default")
return
}
// Get absolute path for the favicon
faviconPath, err := filepath.Abs(Config.Frontend.Favicon)
if err != nil {
logger.Warningf("Could not resolve favicon path '%v': %v", Config.Frontend.Favicon, err)
Config.Frontend.Favicon = "" // Unset invalid path
return
}
// Check if the favicon file exists and get info
stat, err := os.Stat(faviconPath)
if err != nil {
logger.Warningf("Could not access custom favicon file '%v': %v", faviconPath, err)
Config.Frontend.Favicon = "" // Unset invalid path
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", ".jpg", ".jpeg", ".gif", ".svg", ".webp":
// Valid favicon formats
default:
logger.Warningf("Unsupported favicon format '%v', supported formats: .ico, .png, .jpg, .gif, .svg, .webp", 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)
}
func modifyExcludeInclude(config *Source) {
normalize := func(s []string, checkExists bool) {
for i, v := range s {
s[i] = "/" + strings.Trim(v, "/")
// check if file/folder exists
if checkExists {
realPath, err := filepath.Abs(config.Path + s[i])
if err != nil {
logger.Warningf("could not get absolute path for %v: %v", s[i], err)
continue
}
if _, err := os.Stat(realPath); os.IsNotExist(err) {
logger.Warningf("configured exclude/include path %v does not exist", realPath)
}
}
}
}
normalize(config.Config.Exclude.FolderPaths, true)
normalize(config.Config.Exclude.FilePaths, true)
normalize(config.Config.Include.RootFolders, true)
normalize(config.Config.Include.RootFiles, true)
normalize(config.Config.NeverWatchPaths, true)
}
+101
View File
@@ -0,0 +1,101 @@
package settings
import (
"os"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestInitialize(t *testing.T) {
type args struct {
configFile string
}
tests := []struct {
name string
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Initialize(tt.args.configFile)
})
}
}
func Test_setDefaults(t *testing.T) {
tests := []struct {
name string
want Settings
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setDefaults(true); !reflect.DeepEqual(got, tt.want) {
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
}
})
}
}
func TestConfigLoadChanged(t *testing.T) {
defaultConfig := setDefaults(true)
err := loadConfigWithDefaults("./validConfig.yaml", true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
}
}
func TestConfigLoadEnvVars(t *testing.T) {
defaultConfig := setDefaults(true)
expectedKey := "MYKEY"
// mock environment variables
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
err := loadConfigWithDefaults("./validConfig.yaml", true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
if Config.Integrations.OnlyOffice.Secret != expectedKey {
t.Errorf("Expected OnlyOffice.Secret to be '%v', got '%s'", expectedKey, Config.Integrations.OnlyOffice.Secret)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
}
}
func TestConfigLoadSpecificValues(t *testing.T) {
defaultConfig := setDefaults(true)
err := loadConfigWithDefaults("./validConfig.yaml", true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
testCases := []struct {
fieldName string
globalVal interface{}
newVal interface{}
}{
{"Server.Database", Config.Server.Database, defaultConfig.Server.Database},
}
for _, tc := range testCases {
if tc.globalVal == tc.newVal {
t.Errorf("Differences should have been found:\nConfig.%s: %v \nSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
}
}
}
func TestInvalidConfig(t *testing.T) {
configFile := "./invalidConfig.yaml"
err := loadConfigWithDefaults(configFile, true)
if err == nil {
t.Fatalf("expected error loading config file %s, got nil", configFile)
}
}
+517
View File
@@ -0,0 +1,517 @@
package settings
import (
"bytes"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"reflect"
"strings"
"gopkg.in/yaml.v3"
)
// CommentsMap[typeName][fieldName] = combined doc+inline comment text
type CommentsMap map[string]map[string]string
// SecretFieldsMap[typeName][fieldName] = true if field should be redacted
type SecretFieldsMap map[string]map[string]bool
// DeprecatedFieldsMap[typeName][fieldName] = true if field is deprecated
type DeprecatedFieldsMap map[string]map[string]bool
// getStringStyle determines whether a string should be quoted in YAML
func getStringStyle(value string) yaml.Style {
// Always quote all strings for consistency
return yaml.DoubleQuotedStyle
}
// CollectComments parses all Go source in the directory of srcPath and returns CommentsMap.
func CollectComments(srcPath string) (CommentsMap, error) {
comments, _, _, err := CollectCommentsAndSecrets(srcPath)
return comments, err
}
// CollectCommentsAndSecrets parses all Go source and returns comments, secrets, and deprecated field mappings
func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
// parse entire package so we capture comments on all types
dir := srcPath
if filepath.IsAbs(srcPath) {
// If it's an absolute path to a file, get the directory
dir = filepath.Dir(srcPath)
}
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
if err != nil {
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 {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
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
}
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
}
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
}
}
}
}
}
}
return comments, secrets, deprecated, nil
}
// BuildNode constructs a yaml.Node for any Go value, injecting comments on struct fields.
func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
return buildNodeWithDefaults(v, comm, reflect.Value{}, SecretFieldsMap{}, DeprecatedFieldsMap{})
}
// buildNodeWithDefaults constructs a yaml.Node for any Go value, skipping fields that match defaults, redacting secrets, and filtering deprecated fields
func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.Value, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
// Dereference pointers
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
}
var defaultsElem reflect.Value
if defaults.IsValid() && !defaults.IsNil() {
defaultsElem = defaults.Elem()
}
return buildNodeWithDefaults(v.Elem(), comm, defaultsElem, secrets, deprecated)
}
switch v.Kind() {
case reflect.String:
value := v.String()
return &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: value,
Style: getStringStyle(value),
}, nil
case reflect.Struct:
rt := v.Type()
typeName := rt.Name()
mapNode := &yaml.Node{Kind: yaml.MappingNode}
for i := 0; i < rt.NumField(); i++ {
sf := rt.Field(i)
// skip unexported or omitted fields
if sf.PkgPath != "" {
continue
}
if yamlTag := sf.Tag.Get("yaml"); yamlTag == "-" {
continue
}
if jsonTag := sf.Tag.Get("json"); jsonTag == "-" {
continue
}
// Skip deprecated fields if filtering is enabled
if len(deprecated) > 0 && deprecated[typeName][sf.Name] {
continue
}
currentField := v.Field(i)
// If we have defaults, compare and skip if values match
if defaults.IsValid() && i < defaults.NumField() {
defaultField := defaults.Field(i)
currentValue := currentField.Interface()
defaultValue := defaultField.Interface()
isEqual := reflect.DeepEqual(currentValue, defaultValue)
if isEqual {
continue // Skip this field as it matches the default
}
}
// determine key: yaml tag > json tag > field name
yamlTag := sf.Tag.Get("yaml")
jsonTag := sf.Tag.Get("json")
var keyName string
if yamlTag != "" && yamlTag != "-" {
keyName = strings.Split(yamlTag, ",")[0]
} else if jsonTag != "" && jsonTag != "-" {
keyName = strings.Split(jsonTag, ",")[0]
} else {
keyName = sf.Name
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: keyName}
// attach validate and comments inline (only when comments are enabled)
var parts []string
if cm := comm[typeName][sf.Name]; cm != "" {
parts = append(parts, cm)
}
// Only add validation tags if comments map is not empty (meaning comments are enabled)
if len(comm) > 0 {
if vt := sf.Tag.Get("validate"); vt != "" {
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
}
}
if len(parts) > 0 {
keyNode.LineComment = strings.Join(parts, " ")
}
// Check if this field should be redacted as a secret
var valNode *yaml.Node
var err error
if secrets[typeName][sf.Name] {
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "**hidden**",
Style: yaml.DoubleQuotedStyle, // Keep secrets quoted for clarity
}
} else {
// Pass through the corresponding default field for recursive comparison
var defaultField reflect.Value
if defaults.IsValid() && i < defaults.NumField() {
defaultField = defaults.Field(i)
}
valNode, err = buildNodeWithDefaults(currentField, comm, defaultField, secrets, deprecated)
if err != nil {
return nil, err
}
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
return mapNode, nil
case reflect.Slice, reflect.Array:
seq := &yaml.Node{Kind: yaml.SequenceNode}
// for non-struct slices, render inline [] via flow style
if v.Type().Elem().Kind() != reflect.Struct {
seq.Style = yaml.FlowStyle
}
// placeholder for empty slice of structs
if v.Len() == 0 && v.Type().Elem().Kind() == reflect.Struct {
zero := reflect.Zero(v.Type().Elem())
var defaultElem reflect.Value
if defaults.IsValid() && defaults.Len() > 0 {
defaultElem = defaults.Index(0)
}
n, err := buildNodeWithDefaults(zero, comm, defaultElem, secrets, deprecated)
if err != nil {
return nil, err
}
seq.Content = append(seq.Content, n)
return seq, nil
}
for i := 0; i < v.Len(); i++ {
var defaultElem reflect.Value
if defaults.IsValid() && i < defaults.Len() {
defaultElem = defaults.Index(i)
}
n, err := buildNodeWithDefaults(v.Index(i), comm, defaultElem, secrets, deprecated)
if err != nil {
return nil, err
}
seq.Content = append(seq.Content, n)
}
return seq, nil
case reflect.Map:
mapNode := &yaml.Node{Kind: yaml.MappingNode}
for _, key := range v.MapKeys() {
// Handle key
keyStr := fmt.Sprintf("%v", key.Interface())
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: keyStr,
}
// Handle value recursively
mapVal := v.MapIndex(key)
var defaultVal reflect.Value
if defaults.IsValid() {
defaultVal = defaults.MapIndex(key)
}
valNode, err := buildNodeWithDefaults(mapVal, comm, defaultVal, secrets, deprecated)
if err != nil {
return nil, err
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
return mapNode, nil
default:
n := &yaml.Node{}
if err := n.Encode(v.Interface()); err != nil {
return nil, err
}
return n, nil
}
}
func GenerateYaml() {
_ = loadConfigWithDefaults("", true)
Config.Server.Sources = []Source{
{
Path: ".",
},
}
setupLogging()
setupAuth(true)
setupSources(true)
setupUrls()
setupFrontend(true)
output := "generated.yaml" // "output YAML file"
// Generate YAML with comments enabled, full config, and deprecated fields filtered
// Force the source path to be correct for static generation
yamlContent, err := GenerateConfigYamlWithSource(&Config, true, true, true, "common/settings")
if err != nil {
fmt.Fprintf(os.Stderr, "error generating YAML: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(output, []byte(yamlContent), 0644); err != nil {
fmt.Fprintf(os.Stderr, "error writing YAML: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generated YAML with comments (deprecated fields filtered): %s\n", output)
}
// GenerateConfigYaml generates YAML from a given config with options for comments and filtering
func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filterDeprecated bool) (string, error) {
// Try different source paths to handle both runtime and test scenarios
sourcePaths := []string{
"common/settings", // When running from backend directory
".", // When running tests from settings directory
"../settings", // Alternative test path
}
for _, sourcePath := range sourcePaths {
yamlOutput, err := GenerateConfigYamlWithSource(config, showComments, showFull, filterDeprecated, sourcePath)
if err == nil {
return yamlOutput, nil
}
// If it's not a file not found error, return immediately
if !strings.Contains(err.Error(), "no such file or directory") && !strings.Contains(err.Error(), "cannot find") {
return "", err
}
}
// If all paths failed, try with empty maps to at least generate basic YAML
return GenerateConfigYamlWithEmptyMaps(config, showFull)
}
// GenerateConfigYamlWithEmptyMaps generates YAML without comment parsing when source files are unavailable
func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, error) {
// Create empty maps
comm := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
var node *yaml.Node
var err error
if showFull {
// Show the full current config
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
} else {
// Show only non-default values by comparing with defaults during node building
defaultConfig := setDefaults(true)
defaultConfig.Server.Sources = []Source{{Path: "."}}
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(&defaultConfig), secrets, deprecated)
}
if err != nil {
return "", err
}
// Convert to YAML
doc := &yaml.Node{Kind: yaml.DocumentNode}
doc.Content = []*yaml.Node{node}
var rawBuf bytes.Buffer
enc := yaml.NewEncoder(&rawBuf)
enc.SetIndent(2)
if err := enc.Encode(doc); err != nil {
return "", err
}
return AlignComments(rawBuf.String()), nil
}
// GenerateConfigYamlWithSource generates YAML from a given config with options for comments and filtering, using a custom source path
func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull bool, filterDeprecated bool, sourcePath string) (string, error) {
var comm CommentsMap
var secrets SecretFieldsMap
var deprecated DeprecatedFieldsMap
var err error
if showComments {
// Collect comments, secrets, and deprecated fields from the settings source files
comm, secrets, deprecated, err = CollectCommentsAndSecrets(sourcePath)
if err != nil {
return "", err
}
} else {
// Still need to collect secrets and deprecated fields even if not showing comments
_, secrets, deprecated, err = CollectCommentsAndSecrets(sourcePath)
if err != nil {
return "", err
}
// Create empty comments map
comm = make(CommentsMap)
}
// If not filtering deprecated fields, clear the deprecated map
if !filterDeprecated {
deprecated = make(DeprecatedFieldsMap)
}
var node *yaml.Node
if showFull {
// Show the full current config
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
} else {
// Show only non-default values by comparing with defaults during node building
// Create a clean default config (no file loading, just pure defaults)
defaultConfig := setDefaults(true)
// Apply same setup as a fresh instance would have
defaultConfig.Server.Sources = []Source{{Path: "."}}
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(&defaultConfig), secrets, deprecated)
}
if err != nil {
return "", err
}
// Create document
doc := &yaml.Node{Kind: yaml.DocumentNode}
doc.Content = []*yaml.Node{node}
// Encode to YAML
var rawBuf bytes.Buffer
enc := yaml.NewEncoder(&rawBuf)
enc.SetIndent(2)
if err := enc.Encode(doc); err != nil {
return "", err
}
// Apply comment alignment if comments are enabled
yamlOutput := rawBuf.String()
if showComments {
yamlOutput = AlignComments(yamlOutput)
}
return yamlOutput, nil
}
// formatLine applies padding to a single line so its comment starts at a target column.
func formatLine(line string) string {
const targetCol = 42
const defaultPadding = 1
// Find the first # character that is NOT inside double or single quotes.
inSingle, inDouble := false, false
for i := 0; i < len(line); i++ {
c := line[i]
switch c {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case '#':
if !inSingle && !inDouble {
// found a comment
contentPart := line[:i]
commentPart := line[i:]
trimmedContent := strings.TrimRight(contentPart, " ")
if len(trimmedContent) >= targetCol {
return trimmedContent + strings.Repeat(" ", defaultPadding) + commentPart
}
paddingNeeded := targetCol - len(trimmedContent)
return trimmedContent + strings.Repeat(" ", paddingNeeded) + commentPart
}
}
}
// No comment found
return line
}
// AlignComments formats each line of the YAML string independently.
func AlignComments(input string) string {
lines := strings.Split(input, "\n")
outputLines := make([]string, len(lines))
for i, line := range lines {
outputLines[i] = formatLine(line)
}
return strings.Join(outputLines, "\n")
}
@@ -0,0 +1,230 @@
package settings
import (
"regexp"
"strings"
"testing"
)
func TestGenerateConfigYaml_Basic(t *testing.T) {
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
// Test using the actual source directory structure
config := &Settings{
UserDefaults: UserDefaults{
Locale: "es", // Non-default value
DarkMode: true, // Default value
DisableOfficePreviewExt: ".docx .xlsx", // This field is deprecated
},
Auth: Auth{
Key: "secret123", // This is a secret field
AdminUsername: "testadmin", // This is a secret field
AdminPassword: "testpassword", // This is a secret field
},
}
// Test different combinations
testCases := []struct {
name string
showComments bool
showFull bool
filterDeprecated bool
expectSecrets bool
expectDeprecated bool
}{
{
name: "API_mode_all_fields",
showComments: false,
showFull: true,
filterDeprecated: false,
expectSecrets: true, // Secrets should be hidden
expectDeprecated: true, // Deprecated should be shown
},
{
name: "Static_config_filter_deprecated",
showComments: true,
showFull: true,
filterDeprecated: true,
expectSecrets: true, // Secrets should be hidden
expectDeprecated: false, // Deprecated should be filtered
},
{
name: "Filtered_non_defaults",
showComments: false,
showFull: false,
filterDeprecated: false,
expectSecrets: true, // Secrets should be hidden
expectDeprecated: false, // Might not appear if matches default
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Use the current directory structure for real testing
yamlOutput, err := GenerateConfigYaml(config, tc.showComments, tc.showFull, tc.filterDeprecated)
if err != nil {
t.Fatalf("GenerateConfigYaml failed: %v", err)
}
// Basic validation - should produce some output
if len(yamlOutput) == 0 {
t.Fatal("Generated empty YAML")
}
// Test string quoting - string values should be quoted, numbers and booleans should not
lines := strings.Split(yamlOutput, "\n")
for _, line := range lines {
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
// This is a key-value line, skip comment portion
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
value := strings.TrimSpace(parts[1])
// Remove comments
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
value = strings.TrimSpace(value[:commentIdx])
}
// Skip empty values, arrays, and objects
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") {
continue
}
// Check if it's a number (should NOT be quoted)
if reNumber.MatchString(value) {
if strings.HasPrefix(value, "\"") {
t.Errorf("Numeric value should not be quoted in line: %s (value: '%s')", line, value)
}
continue
}
// Check if it's a boolean (should NOT be quoted)
if value == "true" || value == "false" {
if strings.HasPrefix(value, "\"") {
t.Errorf("Boolean value should not be quoted in line: %s (value: '%s')", line, value)
}
continue
}
// Everything else should be a quoted string
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
// Add debug info to see what's wrong
t.Logf("DEBUG: Checking value '%s' from line '%s'", value, strings.TrimSpace(line))
t.Errorf("String value should be quoted in line: %s (value: %s)", strings.TrimSpace(line), value)
}
}
}
}
// Test secret hiding
if tc.expectSecrets {
if !strings.Contains(yamlOutput, "**hidden**") {
t.Error("Expected secrets to be hidden with **hidden**")
}
if strings.Contains(yamlOutput, "secret123") || strings.Contains(yamlOutput, "testadmin") || strings.Contains(yamlOutput, "testpassword") {
t.Error("Secret values should not appear in output")
}
}
// Test deprecated field filtering
hasDeprecated := strings.Contains(yamlOutput, "disableOfficePreviewExt")
if tc.expectDeprecated && !hasDeprecated {
t.Error("Expected deprecated field to be present")
}
if !tc.expectDeprecated && hasDeprecated && tc.filterDeprecated {
t.Error("Expected deprecated field to be filtered out when filterDeprecated=true")
}
// Test comments
hasComments := strings.Contains(yamlOutput, "#")
if tc.showComments && !hasComments {
t.Error("Expected comments to be present when showComments=true")
}
t.Logf("Test case: %s\nGenerated YAML:\n%s\n", tc.name, yamlOutput)
})
}
}
func TestCollectCommentsAndSecrets_Basic(t *testing.T) {
// Test the comment and secret collection directly
comments, secrets, deprecated, err := CollectCommentsAndSecrets(".")
if err != nil {
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
}
// Should find some secrets in the actual source files
foundSecrets := false
for typeName, typeSecrets := range secrets {
for fieldName := range typeSecrets {
t.Logf("Found secret: %s.%s", typeName, fieldName)
foundSecrets = true
}
}
if !foundSecrets {
t.Error("Expected to find some secret fields in the actual source code")
}
// Should find some deprecated fields
foundDeprecated := false
for typeName, typeDeprecated := range deprecated {
for fieldName := range typeDeprecated {
t.Logf("Found deprecated: %s.%s", typeName, fieldName)
foundDeprecated = true
}
}
if !foundDeprecated {
t.Error("Expected to find some deprecated fields in the actual source code")
}
t.Logf("Comments: %d types", len(comments))
t.Logf("Secrets: %d types", len(secrets))
t.Logf("Deprecated: %d types", len(deprecated))
}
func TestGenerateYaml_StaticGeneration(t *testing.T) {
// Test the static generation function that's used by FILEBROWSER_GENERATE_CONFIG=true
// Create a temporary config for testing
config := &Settings{
UserDefaults: UserDefaults{
Locale: "en",
DisableOfficePreviewExt: ".docx .xlsx", // This should be filtered out
},
Auth: Auth{
Key: "test-secret", // This should be redacted
},
}
// Test static generation with comments and deprecated filtering
yamlOutput, err := GenerateConfigYamlWithSource(config, true, true, true, ".")
if err != nil {
t.Fatalf("Static generation failed: %v", err)
}
// Verify comments are included
if !strings.Contains(yamlOutput, "#") {
t.Error("Static generation should include comments")
}
// Verify deprecated field is filtered out
if strings.Contains(yamlOutput, "disableOfficePreviewExt") {
t.Error("Static generation should filter out deprecated field disableOfficePreviewExt")
}
// Verify secrets are redacted
if !strings.Contains(yamlOutput, "**hidden**") {
t.Error("Static generation should redact secrets")
}
if strings.Contains(yamlOutput, "test-secret") {
t.Error("Static generation should not contain actual secret values")
}
// Verify it has proper structure
if !strings.Contains(yamlOutput, "userDefaults:") {
t.Error("Static generation should contain userDefaults section")
}
if !strings.Contains(yamlOutput, "auth:") {
t.Error("Static generation should contain auth section")
}
t.Logf("Static generation successful with comments and proper filtering")
}
+470
View File
@@ -0,0 +1,470 @@
package settings
import (
"os"
"regexp"
"strings"
"testing"
)
// Test structs for comprehensive testing
type TestConfig struct {
PublicField string `json:"publicField"` // A regular public field
SecretField string `json:"secretField"` // secret: this should be hidden
DeprecatedField string `json:"deprecatedField"` // deprecated: this field is old
EmptyField string `json:"emptyField"` // An empty string field
DefaultField string `json:"defaultField"` // A field that matches default
NonDefaultField string `json:"nonDefaultField"` // A field that differs from default
NestedStruct TestNested `json:"nestedStruct"`
}
type TestNested struct {
NestedPublic string `json:"nestedPublic"` // A nested public field
NestedSecret string `json:"nestedSecret"` // secret: nested secret field
NestedDeprecated string `json:"nestedDeprecated"` // deprecated: nested deprecated field
}
// Helper to create test source files for comment parsing
func createTestSourceFile(t *testing.T, content string) (string, func()) {
tmpFile, err := os.CreateTemp("", "test_*.go")
if err != nil {
t.Fatal(err)
}
if _, err := tmpFile.WriteString(content); err != nil {
t.Fatal(err)
}
tmpFile.Close()
cleanup := func() {
os.Remove(tmpFile.Name())
}
return tmpFile.Name(), cleanup
}
func TestGenerateConfigYaml_StringQuoting(t *testing.T) {
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
// Create a simple test source file for this test
sourceContent := `package settings
type Settings struct {
UserDefaults UserDefaults ` + "`json:\"userDefaults\"`" + `
Auth Auth ` + "`json:\"auth\"`" + `
}
type UserDefaults struct {
Locale string ` + "`json:\"locale\"`" + `
}
type Auth struct {
AdminUsername string ` + "`json:\"adminUsername\"`" + `
}
`
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
defer cleanup()
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
// Create real Settings with string values to test quoting
settings := &Settings{
UserDefaults: UserDefaults{
Locale: "en-US",
},
Auth: Auth{
AdminUsername: "admin",
},
}
// Test with no filtering - should quote all strings
yamlOutput, err := GenerateConfigYamlWithSource(settings, false, true, false, sourcePath)
if err != nil {
t.Fatalf("GenerateConfigYaml failed: %v", err)
}
// All string values should be quoted
lines := strings.Split(yamlOutput, "\n")
for _, line := range lines {
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
// This is a key-value line
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
value := strings.TrimSpace(parts[1])
// Remove comments
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
value = strings.TrimSpace(value[:commentIdx])
}
// Skip empty values, arrays, objects, booleans
if value == "" || value == "false" || value == "true" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") {
continue
}
// Check if it's a number (should NOT be quoted)
if reNumber.MatchString(value) {
if strings.HasPrefix(value, "\"") {
t.Errorf("Numeric value should not be quoted: %s (value: '%s')", line, value)
}
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)
}
}
}
}
}
func TestGenerateConfigYaml_SecretHiding(t *testing.T) {
// Create temporary source file with our test structs
sourceContent := `package settings
type TestConfig struct {
PublicField string ` + "`json:\"publicField\"`" + ` // A regular public field
SecretField string ` + "`json:\"secretField\"`" + ` // secret: this should be hidden
}
type TestNested struct {
NestedPublic string ` + "`json:\"nestedPublic\"`" + ` // A nested public field
NestedSecret string ` + "`json:\"nestedSecret\"`" + ` // secret: nested secret field
}
`
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
defer cleanup()
// Test secret detection
_, secrets, _, err := CollectCommentsAndSecrets(tmpFile)
if err != nil {
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
}
// Verify secret fields are detected
if !secrets["TestConfig"]["SecretField"] {
t.Error("SecretField should be marked as secret")
}
if !secrets["TestNested"]["NestedSecret"] {
t.Error("NestedSecret should be marked as secret")
}
if secrets["TestConfig"]["PublicField"] {
t.Error("PublicField should not be marked as secret")
}
// Test that secrets are properly collected
if len(secrets["TestConfig"]) != 1 {
t.Errorf("Expected 1 secret field in TestConfig, got %d", len(secrets["TestConfig"]))
}
}
func TestGenerateConfigYaml_DeprecatedFiltering(t *testing.T) {
// Create temporary source file with deprecated fields
sourceContent := `package settings
type TestConfig struct {
PublicField string ` + "`json:\"publicField\"`" + ` // A regular public field
DeprecatedField string ` + "`json:\"deprecatedField\"`" + ` // deprecated: this field is old
}
`
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
defer cleanup()
// Test deprecated detection
_, _, deprecated, err := CollectCommentsAndSecrets(tmpFile)
if err != nil {
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
}
// Verify deprecated fields are detected
if !deprecated["TestConfig"]["DeprecatedField"] {
t.Error("DeprecatedField should be marked as deprecated")
}
if deprecated["TestConfig"]["PublicField"] {
t.Error("PublicField should not be marked as deprecated")
}
}
func TestGenerateConfigYaml_FullVsFiltered(t *testing.T) {
// Create minimal test source
sourceContent := `package settings
type Settings struct {
UserDefaults UserDefaults ` + "`json:\"userDefaults\"`" + `
}
type UserDefaults struct {
DarkMode bool ` + "`json:\"darkMode\"`" + `
Locale string ` + "`json:\"locale\"`" + `
}
`
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
defer cleanup()
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
// Create a config with both default and non-default values
config := &Settings{
UserDefaults: UserDefaults{
DarkMode: true, // This matches default
Locale: "es", // This differs from default ("en")
},
}
// Test full=true - should show all fields
fullYaml, err := GenerateConfigYamlWithSource(config, false, true, false, sourcePath)
if err != nil {
t.Fatalf("GenerateConfigYaml with full=true failed: %v", err)
}
// Test full=false - should only show non-default fields
filteredYaml, err := GenerateConfigYamlWithSource(config, false, false, false, sourcePath)
if err != nil {
t.Fatalf("GenerateConfigYaml with full=false failed: %v", err)
}
// Full output should be longer than filtered
if len(fullYaml) <= len(filteredYaml) {
t.Error("Full YAML should be longer than filtered YAML")
}
// Filtered output should contain the non-default field
if !strings.Contains(filteredYaml, "locale") {
t.Error("Filtered YAML should contain non-default locale field")
}
// Full output should contain default fields
if !strings.Contains(fullYaml, "darkMode") {
t.Error("Full YAML should contain darkMode field")
}
}
func TestGenerateConfigYaml_CommentsOnOff(t *testing.T) {
// Use the actual settings source directory since comments work there
config := &Settings{
UserDefaults: UserDefaults{
Locale: "en",
},
}
// Test with comments=true using the real source
withComments, err := GenerateConfigYaml(config, true, true, false)
if err != nil {
t.Fatalf("GenerateConfigYaml with comments=true failed: %v", err)
}
// Test with comments=false using the real source
withoutComments, err := GenerateConfigYaml(config, false, true, false)
if err != nil {
t.Fatalf("GenerateConfigYaml with comments=false failed: %v", err)
}
// Output with comments should be longer
if len(withComments) <= len(withoutComments) {
t.Errorf("YAML with comments (%d chars) should be longer than without comments (%d chars)", len(withComments), len(withoutComments))
}
// With comments should contain '#' characters
if !strings.Contains(withComments, "#") {
t.Error("YAML with comments should contain '#' characters")
}
// Without comments should not contain '#' characters
if strings.Contains(withoutComments, "#") {
t.Error("YAML without comments should not contain '#' characters")
}
}
func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
// Comprehensive test with all features
config := &Settings{
UserDefaults: UserDefaults{
Locale: "es", // Non-default
DarkMode: true, // Default
DisableOfficePreviewExt: ".docx .xlsx", // This is deprecated
},
Auth: Auth{
Key: "secret123", // This is secret
AdminUsername: "admin", // This is secret
AdminPassword: "password", // This is secret
},
}
tests := []struct {
name string
showComments bool
showFull bool
filterDeprecated bool
expectSecret bool
expectDeprecated bool
expectFull bool
expectComments bool
}{
{
name: "API mode: show all including deprecated",
showComments: false,
showFull: true,
filterDeprecated: false,
expectSecret: true, // secrets should be hidden
expectDeprecated: true, // deprecated should be shown
expectFull: true, // all fields shown
expectComments: false, // no comments
},
{
name: "Static config mode: filter deprecated",
showComments: true,
showFull: true,
filterDeprecated: true,
expectSecret: true, // secrets should be hidden
expectDeprecated: false, // deprecated should be filtered
expectFull: true, // all non-deprecated fields shown
expectComments: true, // comments included
},
{
name: "Filtered output: only changes",
showComments: false,
showFull: false,
filterDeprecated: false,
expectSecret: true, // secrets should be hidden
expectDeprecated: false, // might not appear if matches default
expectFull: false, // only non-default fields
expectComments: false, // no comments
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
yamlOutput, err := GenerateConfigYaml(config, tt.showComments, tt.showFull, tt.filterDeprecated)
if err != nil {
t.Fatalf("GenerateConfigYaml failed: %v", err)
}
// Check secret hiding
if tt.expectSecret {
if !strings.Contains(yamlOutput, "**hidden**") {
t.Error("Expected secrets to be hidden with **hidden**")
}
if strings.Contains(yamlOutput, "secret123") {
t.Error("Secret values should not appear in output")
}
}
// Check deprecated field filtering
hasDeprecated := strings.Contains(yamlOutput, "disableOfficePreviewExt")
if tt.expectDeprecated && !hasDeprecated {
t.Error("Expected deprecated field to be present")
}
if !tt.expectDeprecated && hasDeprecated && tt.filterDeprecated {
t.Error("Expected deprecated field to be filtered out")
}
// Check comments
hasComments := strings.Contains(yamlOutput, "#")
if tt.expectComments && !hasComments {
t.Error("Expected comments to be present")
}
if !tt.expectComments && hasComments {
t.Error("Expected no comments")
}
// Check string quoting - all string values should be quoted
lines := strings.Split(yamlOutput, "\n")
for _, line := range lines {
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
// This is a key-value line
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
value := strings.TrimSpace(parts[1])
// Skip comments and empty values
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
// If comment starts at the beginning, there's no value, just a comment
if commentIdx == 0 {
continue
}
value = strings.TrimSpace(value[:commentIdx])
}
// Check if it's a number (should NOT be quoted)
if reNumber.MatchString(value) {
if strings.HasPrefix(value, "\"") {
t.Errorf("Numeric value should not be quoted in line: %s (value: '%s')", line, value)
}
continue
}
// Check if it's a boolean (should NOT be quoted)
if value == "true" || value == "false" {
if strings.HasPrefix(value, "\"") {
t.Errorf("Boolean value should not be quoted in line: %s (value: '%s')", line, value)
}
continue
}
// Skip arrays, objects, and empty values
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
continue
}
// Everything else should be a quoted string
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
t.Errorf("String value should be quoted in line: %s (value: '%s')", line, value)
}
}
}
}
t.Logf("Generated YAML for %s:\n%s", tt.name, yamlOutput)
})
}
}
func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
tests := []struct {
name string
config *Settings
desc string
}{
{
name: "empty_config",
config: &Settings{},
desc: "Empty config should generate valid YAML",
},
{
name: "only_defaults",
config: &Settings{
UserDefaults: UserDefaults{
DarkMode: true, // default value
Locale: "en", // default value
},
},
desc: "Config with only default values",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test all combinations
for _, showComments := range []bool{false, true} {
for _, showFull := range []bool{false, true} {
for _, filterDeprecated := range []bool{false, true} {
yamlOutput, err := GenerateConfigYaml(tt.config, showComments, showFull, filterDeprecated)
if err != nil {
t.Fatalf("GenerateConfigYaml failed for %s (comments=%v, full=%v, filterDeprecated=%v): %v",
tt.desc, showComments, showFull, filterDeprecated, err)
}
// Basic validation - should produce valid YAML structure
if yamlOutput == "" {
t.Errorf("Generated empty YAML for %s", tt.desc)
}
// Should not contain obvious error messages (but allow "error" in log levels etc)
if strings.Contains(yamlOutput, "Error:") || strings.Contains(yamlOutput, "ERROR:") {
t.Errorf("YAML contains error for %s", tt.desc)
}
}
}
}
})
}
}
@@ -16,7 +16,6 @@ auth:
key: ""
secret: ""
header: ""
method: json
command: ""
signup: false
shell: ""
@@ -1,9 +1,10 @@
//go:generate go run ./tools/yaml.go -input=common/settings/settings.go -output=config.generated.yaml
package settings
import (
"crypto/rand"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
const DefaultUsersHomeBasePath = "/users"
@@ -29,30 +30,38 @@ func GetSettingsConfig(nameType string, Value string) string {
func AdminPerms() users.Permissions {
return users.Permissions{
Create: true,
Rename: true,
Modify: true,
Delete: true,
Share: true,
Download: true,
Admin: true,
Api: true,
Modify: true,
Share: true,
Admin: true,
Api: true,
}
}
// Apply applies the default options to a user.
func ApplyUserDefaults(u users.User) users.User {
func ApplyUserDefaults(u *users.User) {
u.StickySidebar = Config.UserDefaults.StickySidebar
u.DisableSettings = Config.UserDefaults.DisableSettings
u.DarkMode = Config.UserDefaults.DarkMode
u.Scope = Config.UserDefaults.Scope
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
u.Perm = Config.UserDefaults.Perm
u.Sorting = Config.UserDefaults.Sorting
u.Commands = Config.UserDefaults.Commands
u.Permissions = Config.UserDefaults.Permissions
u.Preview = Config.UserDefaults.Preview
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
return u
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
u.ThemeColor = Config.UserDefaults.ThemeColor
u.GallerySize = Config.UserDefaults.GallerySize
u.QuickDownload = Config.UserDefaults.QuickDownload
u.LockPassword = Config.UserDefaults.LockPassword
if len(u.Scopes) == 0 {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
u.Scopes = append(u.Scopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: source.Config.DefaultUserScope,
})
}
}
}
}
@@ -1,8 +1,7 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// StorageBackend is a settings storage backend.
@@ -29,20 +28,9 @@ func (s *Storage) Get() (*Settings, error) {
if err != nil {
return nil, err
}
if set.Server.UserHomeBasePath == "" {
set.Server.UserHomeBasePath = DefaultUsersHomeBasePath
}
return set, nil
}
var defaultEvents = []string{
"save",
"copy",
"rename",
"upload",
"delete",
}
// Save saves the settings for the current instance.
func (s *Storage) Save(set *Settings) error {
if len(set.Auth.Key) == 0 {
@@ -53,32 +41,10 @@ func (s *Storage) Save(set *Settings) error {
set.UserDefaults.Locale = "en"
}
if set.UserDefaults.Commands == nil {
set.UserDefaults.Commands = []string{}
}
if set.UserDefaults.ViewMode == "" {
set.UserDefaults.ViewMode = "normal"
}
if set.Rules == nil {
set.Rules = []users.Rule{}
}
if set.Commands == nil {
set.Commands = map[string][]string{}
}
for _, event := range defaultEvents {
if _, ok := set.Commands["before_"+event]; !ok {
set.Commands["before_"+event] = []string{}
}
if _, ok := set.Commands["after_"+event]; !ok {
set.Commands["after_"+event] = []string{}
}
}
err := s.back.Save(set)
if err != nil {
return err
+178
View File
@@ -0,0 +1,178 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type AllowedMethods string
const (
ProxyAuth AllowedMethods = "proxyAuth"
NoAuth AllowedMethods = "noAuth"
PasswordAuth AllowedMethods = "passwordAuth"
)
type Settings struct {
Server Server `json:"server"`
Auth Auth `json:"auth"`
Frontend Frontend `json:"frontend"`
UserDefaults UserDefaults `json:"userDefaults"`
Integrations Integrations `json:"integrations"`
}
type Server struct {
MinSearchLength int `json:"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"`
DebugMedia bool `json:"debugMedia"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
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)
// 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
}
type Integrations struct {
OnlyOffice OnlyOffice `json:"office" validate:"omitempty"`
Media Media `json:"media" validate:"omitempty"`
}
// onlyoffice secret is stored in the local.json file
// docker exec <containerID> /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string'
type OnlyOffice struct {
Url string `json:"url" validate:"required"` // The URL to the OnlyOffice Document Server, needs to be accessible to the user.
InternalUrl string `json:"internalUrl"` // An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
Secret string `json:"secret" validate:"required"` // secret: authentication key for OnlyOffice integration
}
type Media struct {
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
}
type LogConfig struct {
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Json bool `json:"json"` // output in json format, currently not supported
Utc bool `json:"utc"` // use UTC time in the output instead of local time
}
type Source struct {
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
Name string `json:"name"` // display name
Config SourceConfig `json:"config,omitempty"`
}
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
MaxWatchers int `json:"maxWatchers"` // number of concurrent watchers to use for this source, currently not supported
NeverWatchPaths []string `json:"neverWatchPaths"` // paths that get initially once. Useful for folders that rarely change contents (without source path prefix)
Exclude ExcludeIndexFilter `json:"exclude"` // exclude files and folders from indexing, if include is not set
Include IncludeIndexFilter `json:"include"` // include files and folders from indexing, if exclude is not set
DefaultUserScope string `json:"defaultUserScope"` // default "/" 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
}
type IncludeIndexFilter struct {
RootFolders []string `json:"rootFolders"` // list of root folders to include, relative to the source path (eg. "folder1")
RootFiles []string `json:"rootFiles"` // list of root files to include, relative to the source path (eg. "file1.txt")
}
type ExcludeIndexFilter struct {
Hidden bool `json:"hidden"` // exclude hidden files and folders.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
FilePaths []string `json:"filePaths"` // list of filepaths Eg. "folder1" or "file1.txt" or "folder1/file1.txt" (without source path prefix)
FolderPaths []string `json:"folderPaths"` // (filepath) list of folder names to include/exclude. Eg. "folder1" or "folder1/subfolder" (do not include source path, just the subpaths from the source path)
FileNames []string `json:"fileNames"` // (global) list of file names to include/exclude. Eg. "a.jpg"
FolderNames []string `json:"folderNames"` // (global) list of folder names to include/exclude. Eg. "@eadir" or ".thumbnails"
FileEndsWith []string `json:"fileEndsWith"` // (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
FolderEndsWith []string `json:"folderEndsWith"` // (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
}
type Frontend struct {
Name string `json:"name"` // display name
DisableDefaultLinks bool `json:"disableDefaultLinks"` // disable default links in the sidebar
DisableUsedPercentage bool `json:"disableUsedPercentage"` // disable used percentage for the sources in the sidebar
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
}
type StylingConfig struct {
CustomCSS string `json:"customCSS"` // if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
CustomCSSRaw string `json:"-"` // The css raw content to use for the custom css.
LightBackground string `json:"lightBackground"` // specify a valid CSS color property value to use as the background color in light mode
DarkBackground string `json:"darkBackground"` // Specify a valid CSS color property value to use as the background color in dark mode
CustomThemes map[string]CustomTheme `json:"customThemes"` // A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
// In-memory (not exposed to config)
CustomThemeOptions map[string]CustomTheme `json:"-"` // not exposed
}
type CustomTheme struct {
Description string `json:"description"` // The description of the theme to display in the UI.
CSS string `json:"css,omitempty"` // The css file path and filename to use for the theme.
CssRaw string `json:"-"` // The css raw content to use for the theme.
}
type ExternalLink struct {
Text string `json:"text" validate:"required"` // the text to display on the link
Title string `json:"title"` // the title to display on hover
Url string `json:"url" validate:"required"` // the url to link to
}
// 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
}
+113
View File
@@ -0,0 +1,113 @@
package settings
import (
"os"
"regexp"
"strings"
"github.com/gtsteffaniak/go-logger/logger"
)
// Strict hex color regex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
var hexColorRE = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
// All valid CSS named colors (W3C spec, lower-case)
var cssNamedColors = map[string]struct{}{
"aliceblue": {}, "antiquewhite": {}, "aqua": {}, "aquamarine": {}, "azure": {},
"beige": {}, "bisque": {}, "black": {}, "blanchedalmond": {}, "blue": {}, "blueviolet": {},
"brown": {}, "burlywood": {}, "cadetblue": {}, "chartreuse": {}, "chocolate": {},
"coral": {}, "cornflowerblue": {}, "cornsilk": {}, "crimson": {}, "cyan": {},
"darkblue": {}, "darkcyan": {}, "darkgoldenrod": {}, "darkgray": {}, "darkgreen": {},
"darkgrey": {}, "darkkhaki": {}, "darkmagenta": {}, "darkolivegreen": {}, "darkorange": {},
"darkorchid": {}, "darkred": {}, "darksalmon": {}, "darkseagreen": {}, "darkslateblue": {},
"darkslategray": {}, "darkslategrey": {}, "darkturquoise": {}, "darkviolet": {}, "deeppink": {},
"deepskyblue": {}, "dimgray": {}, "dimgrey": {}, "dodgerblue": {}, "firebrick": {},
"floralwhite": {}, "forestgreen": {}, "fuchsia": {}, "gainsboro": {}, "ghostwhite": {},
"gold": {}, "goldenrod": {}, "gray": {}, "green": {}, "greenyellow": {}, "grey": {},
"honeydew": {}, "hotpink": {}, "indianred": {}, "indigo": {}, "ivory": {},
"khaki": {}, "lavender": {}, "lavenderblush": {}, "lawngreen": {}, "lemonchiffon": {},
"lightblue": {}, "lightcoral": {}, "lightcyan": {}, "lightgoldenrodyellow": {}, "lightgray": {},
"lightgreen": {}, "lightgrey": {}, "lightpink": {}, "lightsalmon": {}, "lightseagreen": {},
"lightskyblue": {}, "lightslategray": {}, "lightslategrey": {}, "lightsteelblue": {}, "lightyellow": {},
"lime": {}, "limegreen": {}, "linen": {}, "magenta": {}, "maroon": {}, "mediumaquamarine": {},
"mediumblue": {}, "mediumorchid": {}, "mediumpurple": {}, "mediumseagreen": {}, "mediumslateblue": {},
"mediumspringgreen": {}, "mediumturquoise": {}, "mediumvioletred": {}, "midnightblue": {}, "mintcream": {},
"mistyrose": {}, "moccasin": {}, "navajowhite": {}, "navy": {}, "oldlace": {},
"olive": {}, "olivedrab": {}, "orange": {}, "orangered": {}, "orchid": {},
"palegoldenrod": {}, "palegreen": {}, "paleturquoise": {}, "palevioletred": {}, "papayawhip": {},
"peachpuff": {}, "peru": {}, "pink": {}, "plum": {}, "powderblue": {},
"purple": {}, "rebeccapurple": {}, "red": {}, "rosybrown": {}, "royalblue": {},
"saddlebrown": {}, "salmon": {}, "sandybrown": {}, "seagreen": {}, "seashell": {},
"sienna": {}, "silver": {}, "skyblue": {}, "slateblue": {}, "slategray": {},
"slategrey": {}, "snow": {}, "springgreen": {}, "steelblue": {}, "tan": {},
"teal": {}, "thistle": {}, "tomato": {}, "turquoise": {}, "violet": {},
"wheat": {}, "white": {}, "whitesmoke": {}, "yellow": {}, "yellowgreen": {},
// And the special values:
"transparent": {}, "currentcolor": {},
}
func FallbackColor(val, defaultColor string) string {
val = strings.TrimSpace(val)
if val == "" {
return defaultColor
}
if hexColorRE.MatchString(val) {
return val
}
lc := strings.ToLower(val)
// CSS color functions
if strings.HasPrefix(lc, "rgb(") || strings.HasPrefix(lc, "rgba(") ||
strings.HasPrefix(lc, "hsl(") || strings.HasPrefix(lc, "hsla(") {
return val
}
// CSS variable
if strings.HasPrefix(lc, "var(") {
return val
}
// Named colors and special values
if _, ok := cssNamedColors[lc]; ok {
return val
}
// Log a warning if the color is invalid
logger.Warningf("Invalid CSS color value provided: '%s'. Falling back to default: '%s'", val, defaultColor)
return defaultColor
}
func addCustomTheme(name, description, cssFilePath string) {
// Store only file path in config (for YAML export)
Config.Frontend.Styling.CustomThemes[name] = CustomTheme{
Description: description,
CSS: cssFilePath, // Store file path, not content
}
// Load CSS content for runtime use (frontend consumption)
cssContent := ""
if cssFilePath != "" {
cssContent = readCustomCSS(cssFilePath)
}
Config.Frontend.Styling.CustomThemeOptions[name] = CustomTheme{
Description: description,
CSS: cssFilePath,
CssRaw: cssContent, // Store loaded content
}
}
func readCustomCSS(path string) string {
if path == "" {
return ""
}
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 ""
}
}
if len(content) > 128*1024 {
logger.Warningf("Custom CSS file %s is too large (%d bytes), ignoring", path, len(content))
return ""
}
logger.Infof("Loaded custom CSS from: %s (%d bytes)", path, len(content))
return string(content)
}
+35
View File
@@ -0,0 +1,35 @@
server:
numImageProcessors: 4
socket: ""
tlsKey: ""
tlsCert: ""
disablePreviews: false
disablePreviewResize: false
port: 80
baseURL: "/"
database: "mydb.db"
sources:
- path: "."
integrations:
office:
url: "http://localhost:9052"
secret: 6H1H7GzUk2alRkNxAPav0W02g8JS5oUd
auth:
methods:
password:
enabled: true
frontend:
name: ""
disableUsedPercentage: true
userDefaults:
locale: ""
viewMode: ""
darkMode: true
disableSettings: false
singleClick: true
permissions:
admin: true
modify: true
share: true
api: true
dateFormat: false
+16
View File
@@ -0,0 +1,16 @@
package utils
import (
"time"
"github.com/gtsteffaniak/go-cache/cache"
)
var (
DiskUsageCache = cache.NewCache(30*time.Second, 24*time.Hour)
RealPathCache = cache.NewCache(48*time.Hour, 72*time.Hour)
SearchResultsCache = cache.NewCache(15*time.Second, 1*time.Hour)
OnlyOfficeCache = cache.NewCache(48*time.Hour, 1*time.Hour)
JwtCache = cache.NewCache(1*time.Hour, 72*time.Hour)
MediaCache = cache.NewCache(24 * time.Hour) // subtitles get cached for 24 hours
)
+102
View File
@@ -0,0 +1,102 @@
package utils
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/mod/semver"
)
var updateAvailableUrl = ""
type Tag struct {
Name string `json:"name"`
}
type updateInfo struct {
LatestVersion string
CurrentVersion string
ReleaseNotes string
ReleaseUrl string
}
func CheckForUpdates() (updateInfo, error) {
// --- Configuration ---
repoOwner := "gtsteffaniak"
repoName := "filebrowser"
currentVersion := version.Version
splitVersion := strings.Split(currentVersion, "-")
versionCategory := "stable"
if len(splitVersion) > 1 {
versionCategory = splitVersion[1]
}
githubApiUrl := fmt.Sprintf("https://api.github.com/repos/%s/%s/tags", repoOwner, repoName)
// Make the GET request
resp, err := http.Get(githubApiUrl)
if err != nil {
return updateInfo{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return updateInfo{}, fmt.Errorf("failed to fetch tags from GitHub API")
}
// Decode the JSON response
var tags []Tag
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
return updateInfo{}, err
}
// Find the latest beta version greater than the current one
var NewVersion string
for _, tag := range tags {
// Check if the version is valid, is a pre-release, and contains "beta"
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
if semver.Compare(tag.Name, currentVersion) > 0 {
if NewVersion == "" || semver.Compare(tag.Name, NewVersion) > 0 {
NewVersion = tag.Name
}
}
}
}
if NewVersion == "" {
// No newer version found, return empty updateInfo
return updateInfo{}, fmt.Errorf("no newer version found for %s", currentVersion)
}
updateAvailableUrl = fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", repoOwner, repoName, NewVersion)
return updateInfo{
LatestVersion: NewVersion,
CurrentVersion: currentVersion,
ReleaseNotes: updateAvailableUrl,
ReleaseUrl: updateAvailableUrl,
}, nil
}
func GetUpdateAvailableUrl() string {
return updateAvailableUrl
}
// starts a background process to check for updates periodically.
func StartCheckForUpdates() {
// Create a ticker that fires every 24 hours.
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
// Start a loop that waits for the ticker to fire.
for range ticker.C {
_, err := CheckForUpdates()
if err != nil {
// In a real application, you might want more sophisticated logging.
logger.Debug("update check failed:", err)
continue // Don't stop the loop, just wait for the next tick.
}
// Update the global variable with the result of the check.
// This will be an empty string if no newer version was found.
}
}
@@ -6,26 +6,28 @@ import (
"encoding/hex"
"fmt"
math "math/rand"
"path/filepath"
"reflect"
"runtime"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/go-logger/logger"
)
func CheckErr(source string, err error) {
if err != nil {
logger.Fatal(fmt.Sprintf("%s: %v", source, err))
logger.Fatalf("%s: %v", source, err)
}
}
func GenerateKey() []byte {
func GenerateKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return nil
return ""
}
return b
return string(b)
}
// CapitalizeFirst returns the input string with the first letter capitalized.
@@ -69,7 +71,7 @@ func PrintStructFields(v interface{}) {
fieldValue = fieldValue[:100] + "..."
}
logger.Debug(fmt.Sprintf("Field: %s, %s\n", fieldType.Name, fieldValue))
logger.Debugf("Field: %s, %s\n", fieldType.Name, fieldValue)
}
}
@@ -92,3 +94,37 @@ func HashSHA256(data string) string {
bytes := sha256.Sum256([]byte(data))
return hex.EncodeToString(bytes[:])
}
func GetLastComponent(path string) string {
if path == "" {
return ""
}
path = strings.TrimSuffix(path, "/") // Remove trailing slash if any
lastSlash := strings.LastIndex(path, "/")
if lastSlash == -1 {
return path // No parent directory for a relative path without slashes
}
return path[lastSlash+1:]
}
func JoinPathAsUnix(parts ...string) string {
joinedPath := filepath.Join(parts...)
if runtime.GOOS == "windows" {
joinedPath = strings.ReplaceAll(joinedPath, "\\", "/")
}
return joinedPath
}
func NonNilSlice[T any](in []T) []T {
if in == nil {
return []T{}
}
return in
}
func Ternary[T any](cond bool, vtrue, vfalse T) T {
if cond {
return vtrue
}
return vfalse
}
File renamed without changes.
+46
View File
@@ -0,0 +1,46 @@
package utils
import (
"time"
"math/rand"
)
func GenerateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
for i := 0; i < levels; i++ {
dirName += "/" + GetRandomTerm()
}
return dirName
}
func GetRandomTerm() string {
wordbank := []string{
"hi", "test", "other", "name",
"cool", "things", "more", "items",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func GetRandomExtension() string {
wordbank := []string{
".txt", ".mp3", ".mov", ".doc",
".mp4", ".bak", ".zip", ".jpg",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func GenerateRandomSearchTerms(numTerms int) []string {
// Generate random search terms
searchTerms := make([]string, numTerms)
for i := 0; i < numTerms; i++ {
searchTerms[i] = GetRandomTerm()
}
return searchTerms
}
File renamed without changes.
+11
View File
@@ -0,0 +1,11 @@
server:
logging:
- levels: "info|warning|error"
sources:
- path: "/srv"
userDefaults:
preview:
image: true
popup: true
video: true
highQuality: true
+11 -9
View File
@@ -1,20 +1,22 @@
server:
port: 80
baseURL: "/"
root: "/srv"
auth:
method: password
signup: false
logging:
- levels: "info|warning|error"
sources:
- path: "/srv"
userDefaults:
preview:
image: true
popup: true
video: false
office: false
highQuality: false
darkMode: true
disableSettings: false
scope: "."
singleClick: false
permissions:
admin: false
create: false
rename: false
modify: false
delete: false
share: false
download: false
api: false
File diff suppressed because it is too large. Load diff
+481
View File
@@ -0,0 +1,481 @@
package access_test
import (
"path/filepath"
"testing"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
boltusers "github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := storm.Open(dbPath)
if err != nil {
t.Fatalf("failed to open storm db: %v", err)
}
userStore := users.NewStorage(boltusers.NewUsersBackend(db))
return access.NewStorage(db, userStore), userStore
}
func createTestUser(t *testing.T, userStore *users.Storage, username string) {
u := &users.User{NonAdminEditable: users.NonAdminEditable{Password: "test"}, Username: username}
err := userStore.Save(u, false, false)
if err != nil {
t.Fatalf("failed to create user %s: %v", username, err)
}
}
func setupTestSources() {
// Setup default test sources with allow-by-default behavior
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
"mnt/open": {
Path: "mnt/open",
Name: "open",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
}
}
func TestPermitted_UserBlacklist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if err := s.DenyUser("mnt/storage", "/secret", "alice"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if s.Permitted("mnt/storage", "/secret", "alice") {
t.Error("alice should not be permitted (denied)")
}
if !s.Permitted("mnt/storage", "/secret", "bob") {
t.Error("bob should be permitted (not denied)")
}
}
func TestPermitted_UserWhitelist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if err := s.AllowUser("mnt/storage", "/vip", "bob"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should be permitted (allowed)")
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (default allow behavior when DenyByDefault=false)")
}
}
func TestPermitted_GroupBlacklist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
_ = s.AddUserToGroup("admins", "alice")
if err := s.DenyGroup("mnt/storage", "/admin", "admins"); err != nil {
t.Errorf("DenyGroup failed: %v", err)
}
if s.Permitted("mnt/storage", "/admin", "bob") == false {
t.Error("bob should be permitted (not in denied group)")
}
if s.Permitted("mnt/storage", "/admin", "alice") {
t.Error("alice should not be permitted (in denied group)")
}
}
func TestPermitted_GroupWhitelist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
_ = s.AddUserToGroup("vip", "bob")
if err := s.AllowGroup("mnt/storage", "/vip", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should be permitted (in allowed group)")
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (default allow behavior when DenyByDefault=false)")
}
}
func TestPermitted_NoRule(t *testing.T) {
setupTestSources()
s, _ := createTestStorage(t)
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if !s.Permitted("mnt/storage", "/public", "anyone") {
t.Error("anyone should be permitted if no rule exists")
}
}
func TestPermitted_CombinedRules(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
createTestUser(t, userStore, "carol")
createTestUser(t, userStore, "eve")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
err = s.AddUserToGroup("vip", "bob")
if err != nil {
t.Errorf("AddUserToGroup failed: %v", err)
}
err = s.AddUserToGroup("admins", "alice")
if err != nil {
t.Errorf("AddUserToGroup failed: %v", err)
}
if err := s.DenyUser("mnt/storage", "/combo", "eve"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if err := s.AllowUser("mnt/storage", "/combo", "carol"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if err := s.DenyGroup("mnt/storage", "/combo", "admins"); err != nil {
t.Errorf("DenyGroup failed: %v", err)
}
if err := s.AllowGroup("mnt/storage", "/combo", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if s.Permitted("mnt/storage", "/combo", "eve") {
t.Error("eve should not be permitted (user denied)")
}
if !s.Permitted("mnt/storage", "/combo", "carol") {
t.Error("carol should be permitted (user allowed)")
}
if s.Permitted("mnt/storage", "/combo", "alice") {
t.Error("alice should not be permitted (in group denied)")
}
if !s.Permitted("mnt/storage", "/combo", "bob") {
t.Error("bob should be permitted (in group allowed)")
}
}
func TestPermitted_DenyAll(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Test DenyAll
if err = s.DenyAll("mnt/storage", "/private"); err != nil {
t.Errorf("DenyAll failed: %v", err)
}
if s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should not be permitted (deny all)")
}
if s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should not be permitted (deny all)")
}
// Test that Allow rule overrides DenyAll
if err = s.AllowUser("mnt/storage", "/private", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should be permitted (allow overrides deny all)")
}
// Test removing DenyAll
removed, err := s.RemoveDenyAll("mnt/storage", "/private")
if err != nil {
t.Errorf("RemoveDenyAll failed: %v", err)
}
if !removed {
t.Error("RemoveDenyAll should have removed the rule")
}
// After removing DenyAll, alice should be permitted due to the Allow rule
if !s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should be permitted after removing deny all")
}
// Bob should be permitted because DenyByDefault=false makes allow lists additive (not exclusive)
if !s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should be permitted after removing deny all (DenyByDefault=false makes allow lists additive)")
}
}
func TestPermitted_DenyByDefault(t *testing.T) {
// Clear access cache to prevent test pollution
access.ClearCache()
// Create isolated storage for this test
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test configuration with DenyByDefault enabled for one source
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: true,
},
},
"mnt/open": {
Path: "mnt/open",
Name: "open",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
}
// Test DenyByDefault: When no rules exist, users should be denied
if s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should not be permitted (DenyByDefault is true and no rules exist)")
}
if s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true and no rules exist)")
}
// Test that explicit allow rules override DenyByDefault
if err := s.AllowUser("mnt/storage", "/allowed", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/allowed", "alice") {
t.Error("alice should be permitted (explicit allow rule overrides DenyByDefault)")
}
if s.Permitted("mnt/storage", "/allowed", "bob") {
t.Error("bob should not be permitted (not in allow list and DenyByDefault is true)")
}
// Test that explicit deny rules work with DenyByDefault
if err := s.DenyUser("mnt/storage", "/denied", "alice"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if s.Permitted("mnt/storage", "/denied", "alice") {
t.Error("alice should not be permitted (explicit deny rule)")
}
if s.Permitted("mnt/storage", "/denied", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true)")
}
// Test that DenyByDefault doesn't affect sources where it's disabled
if !s.Permitted("mnt/open", "/public", "alice") {
t.Error("alice should be permitted in open source (DenyByDefault is false)")
}
if !s.Permitted("mnt/open", "/public", "bob") {
t.Error("bob should be permitted in open source (DenyByDefault is false)")
}
// Test recursive path checking with DenyByDefault
// Allow alice at parent path, should work for child paths too
if err := s.AllowUser("mnt/storage", "/parent", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/parent/child", "alice") {
t.Error("alice should be permitted for child path (inherits from parent allow rule)")
}
if s.Permitted("mnt/storage", "/parent/child", "bob") {
t.Error("bob should not be permitted for child path (DenyByDefault and not in allow list)")
}
// Test that group rules work with DenyByDefault
_ = s.AddUserToGroup("vip", "alice")
if err := s.AllowGroup("mnt/storage", "/vip", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (in allowed group)")
}
if s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should not be permitted (not in allowed group and DenyByDefault is true)")
}
}
func TestPermitted_DenyByDefaultWithDenyAll(t *testing.T) {
// Clear access cache to prevent test pollution
access.ClearCache()
// Create isolated storage for this test
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test configuration with DenyByDefault enabled
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: true,
},
},
}
// Test that explicit DenyAll rule also works when DenyByDefault is enabled
if err = s.DenyAll("mnt/storage", "/restricted"); err != nil {
t.Errorf("DenyAll failed: %v", err)
}
if s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should not be permitted (DenyAll rule)")
}
if s.Permitted("mnt/storage", "/restricted", "bob") {
t.Error("bob should not be permitted (DenyAll rule)")
}
// Test that Allow rule overrides DenyAll even with DenyByDefault
if err = s.AllowUser("mnt/storage", "/restricted", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should be permitted (allow overrides DenyAll)")
}
// Remove DenyAll and test that DenyByDefault takes effect
removed, err := s.RemoveDenyAll("mnt/storage", "/restricted")
if err != nil {
t.Errorf("RemoveDenyAll failed: %v", err)
}
if !removed {
t.Error("RemoveDenyAll should have removed the rule")
}
// After removing DenyAll, alice should be permitted due to Allow rule
if !s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should be permitted after removing DenyAll (has allow rule)")
}
// Bob should not be permitted due to DenyByDefault
if s.Permitted("mnt/storage", "/restricted", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true and no allow rule)")
}
}
func TestPermitted_DenyByDefault_AdminRootAccess(t *testing.T) {
// 1. Setup
access.ClearCache()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "admin")
createTestUser(t, userStore, "graham")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// 2. Configure source with DenyByDefault: true
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/secure": {
Path: "mnt/secure",
Name: "secure",
Config: settings.SourceConfig{
DenyByDefault: true,
},
},
}
// 3. Set up access rules
if err := s.AllowUser("mnt/secure", "/", "admin"); err != nil {
t.Fatalf("AllowUser for admin failed: %v", err)
}
if err := s.AllowUser("mnt/secure", "/test", "graham"); err != nil {
t.Fatalf("AllowUser for graham failed: %v", err)
}
// 4. Assertions
// Admin checks
if !s.Permitted("mnt/secure", "/", "admin") {
t.Error("admin should be permitted for /")
}
if !s.Permitted("mnt/secure", "/test", "admin") {
t.Error("admin should be permitted for /test")
}
if !s.Permitted("mnt/secure", "/test/sub", "admin") {
t.Error("admin should be permitted for /test/sub (this is the bug)")
}
// Graham checks
if !s.Permitted("mnt/secure", "/test", "graham") {
t.Error("graham should be permitted for /test")
}
if !s.Permitted("mnt/secure", "/test/sub", "graham") {
t.Error("graham should be permitted for /test/sub")
}
if s.Permitted("mnt/secure", "/", "graham") {
t.Error("graham should NOT be permitted for /")
}
if s.Permitted("mnt/secure", "/anotherfolder", "graham") {
t.Error("graham should NOT be permitted for /anotherfolder")
}
// Bob checks (should have no access)
if s.Permitted("mnt/secure", "/", "bob") {
t.Error("bob should NOT be permitted for /")
}
if s.Permitted("mnt/secure", "/test", "bob") {
t.Error("bob should NOT be permitted for /test")
}
if s.Permitted("mnt/secure", "/test/sub", "bob") {
t.Error("bob should NOT be permitted for /test/sub")
}
}
+34
View File
@@ -0,0 +1,34 @@
package crud
// CrudBackend defines the basic CRUD operations for a type T.
type CrudBackend[T any] interface {
GetByID(id any) (*T, error)
GetAll() ([]*T, error)
Save(obj *T) error
DeleteByID(id any) error
}
// Storage provides generic CRUD operations for any type T.
type Storage[T any] struct {
backend CrudBackend[T]
}
func NewStorage[T any](backend CrudBackend[T]) *Storage[T] {
return &Storage[T]{backend: backend}
}
func (s *Storage[T]) Get(id any) (*T, error) {
return s.backend.GetByID(id)
}
func (s *Storage[T]) GetAll() ([]*T, error) {
return s.backend.GetAll()
}
func (s *Storage[T]) Save(obj *T) error {
return s.backend.Save(obj)
}
func (s *Storage[T]) Delete(id any) error {
return s.backend.DeleteByID(id)
}
+54
View File
@@ -0,0 +1,54 @@
package share
import "sync"
type CommonShare struct {
//AllowEdit bool `json:"allowEdit,omitempty"`
AllowUpload bool `json:"allowUpload,omitempty"`
DisableFileViewer bool `json:"disableFileViewer,omitempty"`
DownloadsLimit int `json:"downloadsLimit,omitempty"`
ShareTheme string `json:"shareTheme,omitempty"`
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
MaxBandwidth int `json:"maxBandwidth,omitempty"`
DisableThumbnails bool `json:"disableThumbnails,omitempty"`
KeepAfterExpiration bool `json:"keepAfterExpiration,omitempty"`
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
ThemeColor string `json:"themeColor,omitempty"`
Banner string `json:"banner,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Favicon string `json:"favicon,omitempty"`
QuickDownload bool `json:"quickDownload,omitempty"`
HideNavButtons bool `json:"hideNavButtons,omitempty"`
DisableSidebar bool `json:"disableSidebar"`
ViewMode string `json:"viewMode,omitempty"`
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
Path string `json:"path,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
DisableShareCard bool `json:"disableShareCard,omitempty"`
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
EnableOnlyOffice bool `json:"enableOnlyOffice,omitempty"`
EnableOnlyOfficeEditing bool `json:"enableOnlyOfficeEditing,omitempty"`
}
type CreateBody struct {
CommonShare
Hash string `json:"hash,omitempty"`
Password string `json:"password"`
Expires string `json:"expires"`
Unit string `json:"unit"`
}
// Link is the information needed to build a shareable link.
type Link struct {
CommonShare
Mu sync.Mutex `json:"-"`
Downloads int `json:"downloads"`
Hash string `json:"hash" storm:"id,index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
// Token is a random value that will only be set when PasswordHash is set. It is
// URL-Safe and is used to download links in password-protected shares via a
// query arg.
Token string `json:"token,omitempty"`
}
+320
View File
@@ -0,0 +1,320 @@
package share
import (
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// StorageBackend is the interface to implement for a share storage.
type StorageBackend interface {
All() ([]*Link, error)
FindByUserID(id uint) ([]*Link, error)
GetByHash(hash string) (*Link, error)
GetPermanent(path, source string, id uint) (*Link, error)
GetBySourcePath(path, source string) ([]*Link, error)
Gets(path, source string, id uint) ([]*Link, error)
Save(s *Link) error
Delete(hash string) error
}
// crudBackend implements crud.CrudBackend[Link] for share storage.
type crudBackend struct {
back StorageBackend
}
func (c *crudBackend) GetByID(id any) (*Link, error) {
hash, ok := id.(string)
if !ok {
return nil, errors.ErrInvalidDataType
}
return c.back.GetByHash(hash)
}
func (c *crudBackend) GetAll() ([]*Link, error) {
return c.back.All()
}
func (c *crudBackend) Save(obj *Link) error {
return c.back.Save(obj)
}
func (c *crudBackend) DeleteByID(id any) error {
hash, ok := id.(string)
if !ok {
return errors.ErrInvalidDataType
}
return c.back.Delete(hash)
}
// Storage is a share storage using generics.
type Storage struct {
Generic *crud.Storage[Link]
back StorageBackend
shareCache map[string]*Link
mu sync.RWMutex
users *users.Storage
}
// NewStorage creates a share links storage from a backend.
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
return &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareCache: make(map[string]*Link),
users: usersStore,
}
}
// All wraps StorageBackend.All and handles expiry.
func (s *Storage) All() ([]*Link, error) {
links, err := s.back.All()
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
s.mu.Unlock()
return filtered, nil
}
// FindByUserID wraps StorageBackend.FindByUserID and handles expiry.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
links, err := s.back.FindByUserID(id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
s.mu.Unlock()
return filtered, nil
}
// GetByHash wraps StorageBackend.GetByHash and handles expiry.
func (s *Storage) GetByHash(hash string) (*Link, error) {
// return stable in-memory pointer if available
s.mu.RLock()
if link, ok := s.shareCache[hash]; ok && link != nil {
s.mu.RUnlock()
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
return nil, errors.ErrNotExist
}
return link, nil
}
s.mu.RUnlock()
link, err := s.back.GetByHash(hash)
if err != nil {
return nil, err
}
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
return nil, errors.ErrNotExist
}
s.mu.Lock()
s.shareCache[hash] = link
s.mu.Unlock()
return link, nil
}
// GetPermanent wraps StorageBackend.GetPermanent
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
l, err := s.back.GetPermanent(path, source, id)
if err == nil && l != nil {
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
}
return l, err
}
// Gets wraps StorageBackend.Gets and handles expiry.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
s.mu.Unlock()
return filtered, nil
}
// GetBySourcePath wraps StorageBackend.GetBySourcePath and handles expiry.
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
links, err := s.back.GetBySourcePath(path, source)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
s.mu.Unlock()
return filtered, nil
}
// UpdateShares updates all shares that exactly match oldSource and oldPath
// to point to newSource and newPath. Returns the number of updated shares.
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
links, err := s.back.All()
if err != nil && err != errors.ErrNotExist {
logger.Error("failed to list shares", "error", err)
return 0, err
}
updated := 0
for _, l := range links {
if l == nil {
continue
}
if l.Source != oldSource {
continue
}
pos := strings.Index(l.Path, oldPath)
if pos < 0 {
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = oldFullPath[:pos] + newPath + oldFullPath[pos+len(oldPath):]
if err := s.back.Save(l); err != nil {
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
return updated, err
}
s.mu.Lock()
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
// update existing cached pointer fields
existing.Source = l.Source
existing.Path = l.Path
} else {
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
// Save wraps StorageBackend.Save
func (s *Storage) Save(l *Link) error {
if err := s.back.Save(l); err != nil {
return err
}
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
return nil
}
// Delete wraps StorageBackend.Delete
func (s *Storage) Delete(hash string) error {
if err := s.back.Delete(hash); err != nil {
return err
}
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
return nil
}
// Flush persists the current in-memory state of all shares to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
s.mu.RLock()
links := make([]*Link, 0, len(s.shareCache))
for _, l := range s.shareCache {
if l != nil {
links = append(links, l)
}
}
s.mu.RUnlock()
for _, l := range links {
if err := s.back.Save(l); err != nil {
return err
}
}
return nil
}
// filterExpired removes expired links and deletes them from storage.
func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
var filtered []*Link
for _, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
s.mu.Lock()
delete(s.shareCache, link.Hash)
s.mu.Unlock()
continue
}
filtered = append(filtered, link)
}
return filtered, nil
}
@@ -1,9 +1,9 @@
package bolt
import (
"github.com/asdine/storm/v3"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
type authBackend struct {
@@ -17,8 +17,6 @@ func (s authBackend) Get(t string) (auth.Auther, error) {
auther = &auth.JSONAuth{}
case "proxy":
auther = &auth.ProxyAuth{}
case "hook":
auther = &auth.HookAuth{}
case "noauth":
auther = &auth.NoAuth{}
default:
+37
View File
@@ -0,0 +1,37 @@
package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// Storage is a storage powered by a Backend which makes the necessary
// verifications when fetching and saving data to ensure consistency.
type BoltStore struct {
Users *users.Storage
Share *share.Storage
Auth *auth.Storage
Settings *settings.Storage
Access *access.Storage
}
// NewStorage creates a storage.Storage based on Bolt DB.
func NewStorage(db *storm.DB) (*BoltStore, error) {
userStore := users.NewStorage(usersBackend{db: db})
authStore, err := auth.NewStorage(authBackend{db: db}, userStore)
if err != nil {
return nil, err
}
return &BoltStore{
Users: userStore,
Share: share.NewStorage(shareBackend{db: db}, userStore),
Auth: authStore,
Settings: settings.NewStorage(settingsBackend{db: db}),
Access: access.NewStorage(db, userStore),
}, nil
}
Loaded 100 of 402 files, more files were not shown because too many files have changed in this diff. Show more