Compare commits

...
75 Commits
Author SHA1 Message Date
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
Graham Steffaniak 02482ab8d4 make playwright more lenient and do retires (#318) 2025-01-27 16:27:33 -06:00
Graham Steffaniak 087c72507f Beta/v0.4.2 release (#314) 2025-01-26 19:21:12 -05:00
Graham Steffaniak 7f9e6ac184 Small tweaks 2 (#310) 2025-01-25 19:59:18 -05:00
Graham Steffaniak c168599c91 beta/v0.4.1 release (#307) 2025-01-25 18:31:40 -06:00
Graham Steffaniak a30bfcf7d0 Beta/v0.4.0 release (#297) 2025-01-21 08:02:43 -06:00
Graham Steffaniak b273f44cac Update .goreleaser.yaml (#295) 2025-01-20 21:05:12 -05:00
Graham Steffaniak f9a31f08c3 updated go-releaser (#294) 2025-01-20 17:31:38 -06:00
Graham Steffaniak 09493392db Fix user creation issue (#291) 2025-01-13 19:30:51 -06:00
Graham Steffaniak 7e68db9858 Update .goreleaser.yaml 2025-01-12 20:12:27 -05:00
Graham Steffaniak 34f6bea461 Update main.yaml 2025-01-12 18:55:13 -06:00
Graham Steffaniak 1e35473998 beta v0.3.7 release (#286) 2025-01-12 18:50:22 -06:00
Graham bd4d108b58 updated workflow 2025-01-12 18:30:40 -06:00
Graham Steffaniak 288ea8f59b Update .goreleaser.yaml (#281) 2025-01-08 20:12:53 -06:00
Graham Steffaniak f18c388c88 updated tag match 2025-01-08 19:29:43 -06:00
Graham Steffaniak e922a92aa6 Merge branch 'main' of github.com:gtsteffaniak/filebrowser 2025-01-08 19:22:45 -06:00
Graham Steffaniak 237769fa9e udpated workflows 2025-01-08 19:22:02 -06:00
Graham Steffaniak 9a33e7b379 Update README.md
updated readme
2025-01-08 19:08:17 -06:00
Graham Steffaniak cd3e111e39 Beta/v0.3.6 release (#279) 2025-01-08 19:02:57 -06:00
Graham Steffaniak 9cc30f25e4 fix user creation condition (#275) 2025-01-06 14:57:31 -06:00
Graham Steffaniak 7ab400eb7f updated root 2025-01-06 09:59:34 -06:00
Graham Steffaniak 4a4eb7ce31 Windows compatibility (#271) 2025-01-06 08:43:37 -06:00
Graham Steffaniak f864790175 Updated feature matrix 2025-01-05 13:07:16 -06:00
Graham Steffaniak 6494ca1991 v0.3.5 (#269) 2025-01-05 13:05:33 -06:00
Graham Steffaniak 93925d8430 updated roadmap 2024-12-26 11:38:29 -06:00
Graham Steffaniak 0270711a22 v0.3.4 initial release 2024-12-26 11:31:04 -06:00
Graham Steffaniak d2a3e50d37 v0.3.3 release (#257) 2024-12-16 18:01:55 -06:00
Graham Steffaniak 266a76459d v0.3.2 release (#250) 2024-12-02 11:14:50 -06:00
Graham Steffaniak a79d44582f Update README.md 2024-11-27 18:19:43 -06:00
363 changed files with 26768 additions and 12051 deletions

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
backend/**.db
backend/tmp
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'!"
+13 -27
View File
@@ -6,41 +6,27 @@ on:
- "main"
jobs:
push_latest_to_registry:
name: Push latest
test_playwright:
name: Test Playwright
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Find latest tag
run: |
echo "LATEST_TAG=$(git describe --tags `git rev-list --tags --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
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
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
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
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 }}
file: ./_docker/Dockerfile.playwright-general
push: false
+66 -9
View File
@@ -1,14 +1,15 @@
name: pr-request
name: PR Request
on:
pull_request:
branches:
- "main"
- "v[0-9]+.[0-9]+.[0-9]+"
- "dev/v[0-9]+.[0-9]+.[0-9]+"
- "beta/v[0-9]+.[0-9]+.[0-9]+"
- "stable/v[0-9]+.[0-9]+.[0-9]+"
jobs:
test_frontend:
name: Push release
test_playwright:
name: Test Playwright - general
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -17,11 +18,67 @@ jobs:
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
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
file: ./_docker/Dockerfile.playwright-general
push: false
test_playwright_proxy:
name: Test Playwright - proxy
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
- 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
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-proxy
push: false
test_playwright_noauth:
name: Test Playwright - noauth
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
- 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
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-noauth
push: false
push_pr_to_registry:
name: Push PR
@@ -47,10 +104,10 @@ 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 }}
@@ -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 -4
View File
@@ -1,9 +1,9 @@
name: dev tests
name: regular tests
on:
push:
branches:
- "*"
- "**"
jobs:
test-backend:
@@ -21,10 +21,10 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23.3'
go-version: 'stable'
- uses: golangci/golangci-lint-action@v5
with:
version: v1.60
version: 'v1.64'
working-directory: backend
format-backend:
runs-on: ubuntu-latest
-76
View File
@@ -1,76 +0,0 @@
name: version release
on:
push:
branches:
- "v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
create_release:
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 1.22.2
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ steps.extract_branch.outputs.branch }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.branch }}
prerelease: false
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.branch }}
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: 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: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 }}
+66
View File
@@ -0,0 +1,66 @@
name: beta release
on:
push:
branches:
- "beta/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright - regular
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
- 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
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false # change this to false when stable gets released
make_latest: true # change this to false when stable gets released
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
+25 -13
View File
@@ -3,14 +3,14 @@ name: dev release
on:
push:
branches:
- "dev_v[0-9]+.[0-9]+.[0-9]+"
- "dev/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
push_release_to_registry:
name: Push dev release
name: Push release
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -24,27 +24,39 @@ 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:v/filebrowser:/')
echo "CLEANED_TAG=$JSON" >> $GITHUB_ENV
TAGS="${{ steps.meta.outputs.tags }}"
# Apply modifications to both docker.io and ghcr.io tags
CLEANED_TAGS=$(echo "$TAGS" | sed 's/filebrowser:dev-v/filebrowser:/g' | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev/g')
# Add dev tag to both registries
CLEANED_TAGS="$CLEANED_TAGS,gtstef/filebrowser:dev,ghcr.io/gtsteffaniak/filebrowser:dev"
echo "cleaned_tag<<EOF" >> $GITHUB_OUTPUT
echo "$CLEANED_TAGS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
context: .
platforms: linux/amd64
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
file: ./_docker/Dockerfile.slim
push: true
tags: ${{ env.CLEANED_TAG }}
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+65
View File
@@ -0,0 +1,65 @@
name: stable release
on:
push:
branches:
- "stable/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright
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
- 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
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
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 }}
+135 -3
View File
@@ -1,9 +1,9 @@
name: tag
name: tag update
on:
push:
tags:
- "v*"
- "v[0-9]+.[0-9]+.[0-9]+*"
permissions:
contents: write
@@ -21,7 +21,7 @@ jobs:
run: npm i && npm run build
working-directory: frontend
- name: Install UPX
run: sudo apt-get install -y upx
run: sudo apt-get install -y upx gcc-multilib g++-multilib gcc-mingw-w64 gcc-aarch64-linux-gnu
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -29,3 +29,135 @@ 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"; ":")) |
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 'beta'/'stable' 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)
| sub(":(.+)"; ":\(.captures[0])-slim")
) |
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta"
]
else . end |
if any(.tags[]; test("stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable"
]
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 }}
+12 -2
View File
@@ -1,19 +1,28 @@
*.db
*.bak
*.log
*.mjs
_old
rice-box.go
.idea/
/filebrowser
/filebrowser.exe
/backend/backend
/backend/filebrowser
/backend/filebrowser.exe
/backend/backend.exe
/backend/tmp
/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/*
.DS_Store
node_modules
@@ -37,3 +46,4 @@ yarn-error.log*
*.sw*
bin/
build/
*__debug*
+553 -1
View File
@@ -2,6 +2,558 @@
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.7.6-beta
NOTE: if using docker arm32 image, you will need to switch to the slim images. The regular docker images are much larger now and support generating office previews out of the box without any only office running. However, they don't support arm32. Also, be aware the docker images are much larger now (600MB I believe) because of the office document preview support -- if thats not something you care about you can switch to the slim images.
**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
- ".doc", // DOC
- ".docx", // DOCX
- ".ppt", // PPT
- ".pptx", // PPTX
- ".xls", // excel xls
- ".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**:
- 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.
- changed user property from `hideDotFiles` to `showHidden`. Defaults to false, so a user would need to must unhide hidden files if they want to view hidden files.
**Notes**:
- cleaned up old and deprecated config.
- removed unneeded "Global settings". All system configuration is done on config yaml, See configuration wiki for more help.
**Bugfixes**:
- Another fix for memory https://github.com/gtsteffaniak/filebrowser/issues/298
## v0.4.1-beta
**New Features**:
- right-click actions are available on search. https://github.com/gtsteffaniak/filebrowser/issues/273
**Notes**:
- delete prompt now lists all items that will be affected by delete
- Debug and logger output tweaks.
**Bugfixes**:
- calculating checksums errors.
- copy/move issues for some circumstances.
- The previous position wasn't returned when closing a preview window https://github.com/gtsteffaniak/filebrowser/issues/298
- fixed sources configuration mapping error (advanced `server.sources` config)
## v0.4.0-beta
**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**:
- 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
## v0.3.7-beta
**Notes**:
- Adding windows builds back to automated process... will replace manually if they throw malicious defender warnings.
- Adding playwright tests to all pr's against dev/beta/release branches.
- These playwright tests should help keep release more reliably stable.
**Bugfixes**:
- closing with the default bar issue.
- tar.gz archive creation issue
## v0.3.6-beta
**New Features**:
- Adds "externalUrl" server config https://github.com/gtsteffaniak/filebrowser/issues/272
**Notes**:
- All views modes to show header bar for sorting.
- other small style changes
**Bugfixes**:
- select and info bug after sorting https://github.com/gtsteffaniak/filebrowser/issues/277
- downloading from shares with public user
- Ctrl and Shift key modifiers work on listing views as expected.
- copy/move file/folder error and show errors https://github.com/gtsteffaniak/filebrowser/issues/278
- file move/copy context fix.
## v0.3.5
**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
- can enable/disable indexing hidden files
- prepped for multiple sources (not supported yet!)
- Theme and Branding support (see updates to [configuration wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration) on how to use)
- Automatically expire shares https://github.com/gtsteffaniak/filebrowser/issues/208
**Notes**:
- MacOS application files (ending in ".app") were previously treated as folders, now they are treated as a single file.
- No longer indexes "$RECYCLE.BIN" or "System Volume Information" directories.
- Icon styling tweaked so all icons have a background.
- Updated Login page styling.
- Settings profile menu has been simplified, password changes happen in user management.
- Improved windows compatibility and built on windows platform to fix false windows defender warning.
- If no "root" location is provided in the server config, the default is the **current directory** (rather than `/srv` like before)
**Bugfixes**:
- Fixed setting share expiration time would not work due to type conversion error.
- More safari fixes related to text-selection.
- Sort by name value sorting ignores the extension, only sorts by name https://github.com/gtsteffaniak/filebrowser/issues/230
- Fixed manual language selection issue.
- Fixed exact date time issue.
New login page:
<img width="300" alt="image" src="https://github.com/user-attachments/assets/d3ed359e-a969-4f6a-9f72-94d2b68aba49" />
Example branding in sidebar:
<img width="500" alt="image2" src="https://github.com/user-attachments/assets/d8ee14ca-4495-4106-9d26-631a5937e134" />
Example user settings page:
<img width="500" alt="image3" src="https://github.com/user-attachments/assets/79757a11-669e-4597-bd3d-e41efd667a1e" />
## v0.3.4
**Bugfixes**:
- Safari right-click actions.
- Some small image viewer behavior
- Progressive webapp "install to homescreen" fix.
## v0.3.3
**New Features**
- Navigating remembers your previous scroll position when opening items and then navigating backwards.
- New Icons with larger selection of file types
- file "type" is shown on item info page.
- added optional non-root "filebrowser" user for docker image. See https://github.com/gtsteffaniak/filebrowser/issues/251
- File preview supports more file types:
- images: jpg, bmp, gif, tiff, png, svg, heic, webp
**Notes**:
- The file "type" is now either "directory" or a specific mimetype such as "text/xml".
- update safari styling
**Bugfixes**:
- Delete/move file/folders sometimes wouldn't work.
- Possible fix for context menu not showing issue. See https://github.com/gtsteffaniak/filebrowser/issues/251
- Fixed drag/drop not refreshing immediately to reflect changes.
## v0.3.2
**New Features**
- Mobile search has the same features as desktop.
**Notes**:
- Added compression. Helpful for browsing folders with a large number of items. Considering https://github.com/gtsteffaniak/filebrowser/issues/201 resolved, although future pagination support will still come.
- Compressed download options limited to `.zip` and `.tar.gz`
- right-click context menu stays in view.
**Bugfixes**:
- search result links when non-default baseUrl configured
- frontend sort bug squashed https://github.com/gtsteffaniak/filebrowser/issues/230
- bug which caused "noauth" method not to work after v0.3.0 routes update
## v0.3.1
**New Features**
@@ -171,7 +723,7 @@ This change focuses on minimizing and simplifying build process.
- The shell feature has been deprecated.
- Custom commands can be executed within the Docker container if needed.
- The JSON config file is no longer used.
- All configurations are now performed via the advanced `filebrowser.yaml`.
- All configurations are now performed via the advanced `config.yaml`.
- The only allowed flag is specifying the config file.
- Removed old code for migrating database versions.
- Eliminated all unused `cmd` code.
-27
View File
@@ -1,27 +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/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/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
COPY --from=base /app/filebrowser* ./
# exposing default port for auto discovery.
EXPOSE 80
COPY --from=nbuild /app/dist/ ./http/dist/
ENTRYPOINT [ "./filebrowser" ]
-19
View File
@@ -1,19 +0,0 @@
FROM golang:1.23-alpine AS base
WORKDIR /app
COPY ./backend ./
RUN go build -ldflags="-w -s" -o filebrowser .
FROM node:slim
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
RUN npx playwright install --with-deps firefox
COPY [ "backend/filebrowser.yaml", "./" ]
COPY ./frontend/ ./frontend
WORKDIR /app/frontend
RUN npm run build-docker
WORKDIR /app
COPY --from=base /app/filebrowser* ./
RUN cp -R frontend/tests/ srv
ENV FILEBROWSER_NO_EMBEDED="true"
RUN ./filebrowser & 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.
+80 -174
View File
@@ -1,243 +1,149 @@
<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/e4a47229-66f8-4838-9575-dd2413596688" 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://www.paypal.com/donate/?business=W5XKNXHJM2WPE&no_recurring=0&currency_code=USD)
<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]
> Starting with `v0.3.0` API routes have been slightly altered for friendly usage outside of the UI. The resources api returns items in separate `files` and `folder` objects now.
> There is no stable version -- planned for 2025. ([Read more](https://github.com/gtsteffaniak/filebrowser/discussions/628))
> [!WARNING]
> If on windows, please use docker. The windows binary is unstable and may not work.
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
> [!WARNING]
> There is no stable version yet. Always check release notes for bugfixes on functionality that may have been changed. If you notice any unexpected behavior -- please open an issue to have it fixed soon.
FileBrowser Quantum is a fork of the file browser opensource project with the following changes:
1. [x] Indexes files efficiently. See [indexing readme](./docs/indexing.md)
- Real-time search results as you type
- Search supports file/folder sizes and many file type filters.
- Enhanced interactive results that shows file/folder sizes.
1. [x] 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
1. [x] Revamped and simplified configuration via `filebrowser.yml` config file.
1. [x] Better listing browsing
- Switching view modes is instant
- Folder sizes are shown as well
- Changing Sort order is instant
- The entire directory is loaded in 1/3 the time
1. Developer API support
- Can create long-live API Tokens.
- Helpful Swagger page available at `/swagger` endpoint.
1. ✅ Multiple sources support
2. ✅ Login support for OIDC, password + 2FA, and proxy.
3. ✅ Revamped UI
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. ✅ 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):
- jobs/runners are not supported yet (planned).
- shell commands are completely removed and will not be returning.
- themes and branding are not fully supported yet (planned).
- see feature matrix below for more.
- ❌ jobs are not supported yet (planned).
- ❌ rules are not supported yet (planned).
- ❌ shell commands are completely removed and will not be returned.
## 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 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.
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 in a tiny executable 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**.
## Look
This repo will always be free and open-source.
One way you can observe the improved user experience is how I changed
the UI. The Navbar is simplified to a three-component system :
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
1. (Left) The slide-out action panel button
## 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/75226dc4-9802-46f0-9e3c-e4403d3275da" title="Main Screenshot">
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
</p>
## Install
## Install and Configuration
Using docker:
Check out the [Getting Started Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Getting-Started)
1. docker run (no persistent db):
```
docker run -it -v /path/to/folder:/srv -p 80:80 gtstef/filebrowser
```
1. docker compose:
- with local storage
```
services:
filebrowser:
volumes:
- '/path/to/folder:/srv' # required (for now not configurable)
- './database:/database' # optional if you want db to persist - configure a path under "database" dir in config file.
- './filebrowser.yaml:/filebrowser.yaml' # required
ports:
- '80:80'
image: gtstef/filebrowser
restart: always
```
- with network share
```
services:
filebrowser:
volumes:
- 'storage:/srv' # required (for now not configurable)
- './database:/database' # optional if you want db to persist - configure a path under "database" dir in config file.
- './filebrowser.yaml:/filebrowser.yaml' # required
ports:
- '80:80'
image: gtstef/filebrowser
restart: always
volumes:
storage:
driver_opts:
type: cifs
o: "username=admin,password=password,rw" # enter valid info here
device: "//192.168.1.100/share/" # enter valid info here
```
Not using docker (not recommended), download your binary from releases and run with your custom config file:
```
./filebrowser -c <filebrowser.yml or other /path/to/config.yaml>
```
For help configuring your filebrowser see [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration-And-Examples)
## Command Line Usage
There are very few commands available. There are 3 actions done via command line:
1. Running the program, as shown on the install step. The only argument used is the config file, if you choose to override default "filebrowser.yaml"
2. Checking the version info via `./filebrowser version`
3. Updating the DB, which currently only supports adding users via `./filebrowser set -u username,password [-a] [-s "example/scope"]`
See the [CLI Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
## API Usage
FileBrowser Quantum comes with a swagger page that can be accessed from the API section of settings or by going to `/swagger` to see the full list:
See the [API Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/API)
![image](https://github.com/user-attachments/assets/12abd1f6-21d3-4437-98ed-9b0da6cf2c73)
You use the token as a bearer token. For example in postman:
Successful Request:
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4f18fa8a-8d87-4f40-9dc7-3d4407769b59"></p>
Failed Request
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4da0deae-f93d-4d94-83b1-68806afb343a"></p>
## Configuration
All configuration is now done via a single configuration file:
`filebrowser.yaml`, here is an example of minimal [configuration
file](./backend/filebrowser.yaml).
View the [Configuration Help Page](./docs/configuration.md) 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 and office-related features.
## Migration from the original filebrowser
If you currently use the original filebrowser but want to try using this.
I would recommend that you start fresh without reusing the database. However,
If you want to migrate your existing database to FileBrowser Quantum,
visit the [migration
readme](./docs/migration.md)
See the [Migration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Migration)
## 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 | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
ftp support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 22 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 |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 200 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 | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
2FA sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
sharable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
office file support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
activity log | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
collaboration on same file | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
trash support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Content preview icons | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
Plugins support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
## Roadmap
see [Roadmap Page](./docs/roadmap.md)
Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert documents | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert videos | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
Can convert photos | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
+46
View File
@@ -0,0 +1,46 @@
FROM gtstef/ffmpeg:7.1.1 AS ffmpeg
FROM golang:1.24-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 -tags pdf -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 .
FROM node:lts-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
# requirements to enable pdf support
RUN apk add --no-cache libffi mupdf && \
find / -name "libmupdf.so*" -type f -exec mv {} /usr/lib/libmupdf.so \;
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_NO_EMBEDED="true"
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.media.yaml ./config.yaml
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" ]
+4
View File
@@ -0,0 +1,4 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test
RUN npx playwright install --with-deps firefox
+8
View File
@@ -0,0 +1,8 @@
FROM gtstef/playwright-base
WORKDIR /app
COPY [ "./_docker/src/general/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+8
View File
@@ -0,0 +1,8 @@
FROM gtstef/playwright-base
WORKDIR /app
COPY [ "./_docker/src/noauth/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+11
View File
@@ -0,0 +1,11 @@
FROM gtstef/playwright-base
WORKDIR /app
COPY [ "./_docker/src/proxy/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+58
View File
@@ -0,0 +1,58 @@
FROM golang:1.24-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/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:lts-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
RUN apk add --no-cache curl xz && \
ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ;; \
aarch64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" ;; \
armv7l) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
mkdir -p /tmp/ffmpeg && \
curl -L "$URL" | tar -xJ -C /tmp/ffmpeg && \
mv /tmp/ffmpeg/ffmpeg-*/ffmpeg /usr/local/bin/ && \
mv /tmp/ffmpeg/ffmpeg-*/ffprobe /usr/local/bin/ && \
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \
rm -rf /tmp/ffmpeg && \
apk del curl xz && \
rm -rf /var/cache/apk/*
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_NO_EMBEDED="true"
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.media.yaml ./config.yaml
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" ]
+16
View File
@@ -0,0 +1,16 @@
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
filebrowser:
hostname: filebrowser
volumes:
- '../frontend:/home/frontend'
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile
+16
View File
@@ -0,0 +1,16 @@
server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
- path: "."
name: "docker"
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: 5000,
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, // 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"] },
},
],
});
+20
View File
@@ -0,0 +1,20 @@
server:
port: 80
baseURL: "/files/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
auth:
methods:
noauth: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
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: 5000,
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"] },
},
],
});
+36
View File
@@ -0,0 +1,36 @@
server:
port: 8080
baseURL: "/"
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: false
api: false
+19
View File
@@ -0,0 +1,19 @@
map $remote_addr $uuid {
default "demo-${remote_addr}";
}
server {
listen 80;
server_name localhost 127.0.0.1;
location / {
proxy_set_header X-Username $uuid;
add_header X-Username $uuid;
proxy_pass http://filebrowser:8080;
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;
}
}
@@ -10,19 +10,23 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
//globalSetup: "./global-setup",
timeout: 3000,
testDir: "./tests-playwright/proxy",
/* Run tests in files in parallel */
fullyParallel: true,
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
forbidOnly: false,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
retries: 2,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
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: 3000,
//storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
@@ -40,4 +44,4 @@ export default defineConfig({
use: { ...devices["Desktop Firefox"] },
},
],
});
});
+84 -11
View File
@@ -3,39 +3,112 @@ 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
tags:
- mupdf
ldflags: &ldflags
- -s -w -X github.com/gtsteffaniak/filebrowser/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/version.CommitSHA={{ .ShortCommit }}
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/common/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
goos:
- darwin
- linux
goarch:
- amd64
- arm
- arm64
goarm:
- 6
- 7
hooks:
post:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for linux amd64
- id: linux-arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
tags:
- mupdf
ldflags: &ldflags
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/common/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
goos:
- linux
goarch:
- arm64
hooks:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for linux
- id: linux-arm32
ldflags: &ldflags
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/common/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
goos:
- linux
goarch:
- arm
goarm:
- "6"
- "7"
hooks:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm
- id: windows
env:
- CGO_ENABLED=1
- CC=gcc-mingw-w64
tags:
- mupdf
ldflags: *ldflags
main: main.go
binary: filebrowser
goos:
- windows
goarch:
- amd64
hooks:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for freebsd without arm & upx
- id: freeBSD
ldflags: *ldflags
main: main.go
binary: filebrowser
goos:
- freebsd
goarch:
- amd64
hooks:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for macos without upx
- id: macos
ldflags: *ldflags
main: main.go
binary: filebrowser
goos:
- darwin
goarch:
- amd64
- arm64
hooks:
post:
- upx {{ .Path }} # Compress the binary with UPX
archives:
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
- name_template: >
{{- if eq .Os "windows" -}}
{{.ProjectName}}
{{- else -}}
{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}}
{{- end -}}
format: binary
checksum:
File renamed without changes.
@@ -31,9 +31,10 @@ type FileCache struct {
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
if err := os.MkdirAll(filepath.Join(dir, "thumbnails", "video"), 0700); err != nil {
return nil, fmt.Errorf("can't make directory %s: %v", dir, err)
}
return &FileCache{dir: dir}, nil
}
File renamed without changes.
+179
View File
@@ -0,0 +1,179 @@
package files
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"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) {
cwd, err := os.Getwd()
if err != nil {
return
}
trimPrefix := filepath.Dir(filepath.Dir(cwd))
tests := []struct {
name string
paths []string
want struct {
path string
isDir bool
}
}{
{
name: "current directory",
paths: []string{
"./",
},
want: struct {
path string
isDir bool
}{
path: "",
isDir: true,
},
},
{
name: "current directory",
paths: []string{
"./files/file.go",
},
want: struct {
path string
isDir bool
}{
path: "/files/file.go",
isDir: false,
},
},
{
name: "other test case",
paths: []string{
"/mnt/doesnt/exist",
},
want: struct {
path string
isDir bool
}{
path: "/mnt/doesnt/exist",
isDir: false,
},
},
}
idx := indexing.Index{
Source: settings.Source{
Path: trimPrefix,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
realPath, isDir, _ := idx.GetRealPath(tt.paths...)
adjustedRealPath := strings.TrimPrefix(realPath, trimPrefix)
if tt.want.path != adjustedRealPath || tt.want.isDir != isDir {
t.Errorf("expected %v:%v but got: %v:%v", tt.want.path, tt.want.isDir, adjustedRealPath, isDir)
}
})
}
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
input iteminfo.FileInfo
expected iteminfo.FileInfo
}{
{
name: "Numeric and Lexicographical Sorting",
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "10.txt"},
{Name: "2.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []iteminfo.ItemInfo{
{Name: "File2.txt"},
{Name: "File10.txt"},
{Name: "File1"},
{Name: "banana"},
},
},
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "2.txt"},
{Name: "10.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []iteminfo.ItemInfo{
{Name: "banana"},
{Name: "File1"},
{Name: "File10.txt"},
{Name: "File2.txt"},
},
},
},
{
name: "Only Lexicographical Sorting",
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "dog.txt"},
{Name: "Cat.txt"},
{Name: "apple"},
},
Files: []iteminfo.ItemInfo{
{Name: "Zebra"},
{Name: "apple"},
{Name: "cat"},
},
},
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "Cat.txt"},
{Name: "dog.txt"},
},
Files: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "cat"},
{Name: "Zebra"},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.input.SortItems()
getNames := func(items []iteminfo.ItemInfo) []string {
names := []string{}
for _, folder := range items {
names = append(names, folder.Name)
}
return names
}
actualFolderNames := getNames(test.input.Folders)
expectedFolderNames := getNames(test.expected.Folders)
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
}
actualFileNames := getNames(test.input.Files)
expectedFileNames := getNames(test.expected.Files)
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
}
})
}
}
+414
View File
@@ -0,0 +1,414 @@
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/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{}
if opts.Source == "" {
opts.Source = settings.Config.Server.DefaultSource.Name
}
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, err
}
opts.IsDir = isDir
// TODO: whats the best way to save trips to disk here?
// disabled using cache because its not clear if this is helping or hurting
// check if the file exists in the index
//info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
//if exists {
// err := RefreshFileInfo(opts)
// if err != nil {
// return info, err
// }
// if opts.Content {
// content := ""
// content, err = getContent(opts.Path)
// if err != nil {
// return info, err
// }
// info.Content = content
// }
// return info, nil
//}
err = index.RefreshFileInfo(opts)
if err != nil {
return response, err
}
info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
if opts.Content {
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(realPath)
if err != nil {
logger.Debugf("could not get content for file: "+info.Path, info.Name, err)
return response, err
}
response.Content = content
} else {
logger.Debug("skipping large text file contents (20MB limit): "+info.Path, info.Name)
}
}
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
if strings.HasPrefix(info.Type, "video") {
parentInfo, exists := index.GetReducedMetadata(filepath.Dir(info.Path), true)
if exists {
response.DetectSubtitles(parentInfo)
}
}
return response, nil
}
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)
}
refreshConfig := iteminfo.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return err
}
return nil
}
func MoveResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := indexing.GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := indexing.GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
// refresh info for source and dest
err = idxSrc.RefreshFileInfo(iteminfo.FileOptions{
Path: refreshSourceDir,
IsDir: true,
})
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
if refreshSourceDir == refreshDestDir {
return nil
}
refreshConfig := iteminfo.FileOptions{Path: refreshDestDir, IsDir: true}
err = idxDst.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for dest: %v", err)
}
return nil
}
func CopyResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := indexing.GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := indexing.GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
index := indexing.GetIndex(sourceIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshConfig := iteminfo.FileOptions{Path: refreshSourceDir, IsDir: true}
// refresh info for source and dest
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
refreshConfig.Path = refreshDestDir
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
}
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, 0775)
if err != nil {
return err
}
err = idx.RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
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)
parentDir := filepath.Dir(dst)
// Create the directory and all necessary parents
err := os.MkdirAll(parentDir, 0775)
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, 0775)
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
}
opts.Path = idx.MakeIndexPath(parentDir)
opts.IsDir = true
return idx.RefreshFileInfo(opts)
}
// 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("create user: 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("create user: 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("create user: scope path does not exist: %s", scope.Scope)
}
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: 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("create user: 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("create user: scope folder does not exist: %s", path)
}
}
u.Scopes[i] = scope
}
return nil
}
File renamed without changes.
File renamed without changes.
+194
View File
@@ -0,0 +1,194 @@
package fileutils
import (
"io"
"os"
"path"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
// 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 {
err := os.Rename(src, dst)
if err == nil {
return nil
}
// fallback
err = CopyFile(src, dst)
if err != nil {
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)
}
}()
return nil
}
// CopyFile copies a file or directory from source to dest and returns an error if any.
func CopyFile(source, dest string) error {
// Check if the source exists and whether it's a file or directory.
info, err := os.Stat(source)
if err != nil {
return err
}
if info.IsDir() {
// If the source is a directory, copy it recursively.
return copyDirectory(source, dest)
}
// If the source is a file, copy the file.
return copySingleFile(source, dest)
}
// copySingleFile handles copying a single file.
func copySingleFile(source, dest string) error {
// Open the source file.
src, err := os.Open(source)
if err != nil {
return err
}
defer src.Close()
// Create the destination directory if needed.
err = os.MkdirAll(filepath.Dir(dest), 0775) //nolint:gomnd
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
if err != nil {
return err
}
defer dst.Close()
// Copy the contents of the file.
_, err = io.Copy(dst, src)
if err != nil {
return err
}
// Copy the mode.
info, err := os.Stat(source)
if err != nil {
return err
}
err = os.Chmod(dest, info.Mode())
if err != nil {
return err
}
return nil
}
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
// Create the destination directory.
err := os.MkdirAll(dest, 0775) //nolint:gomnd
if err != nil {
return err
}
// Read the contents of the source directory.
entries, err := os.ReadDir(source)
if err != nil {
return err
}
// Iterate over each entry in the directory.
for _, entry := range entries {
srcPath := filepath.Join(source, entry.Name())
destPath := filepath.Join(dest, entry.Name())
if entry.IsDir() {
// Recursively copy subdirectories.
err = copyDirectory(srcPath, destPath)
if err != nil {
return err
}
} else {
// Copy files.
err = copySingleFile(srcPath, destPath)
if err != nil {
return err
}
}
}
return nil
}
// CommonPrefix returns the common directory path of provided files.
func CommonPrefix(sep byte, paths ...string) string {
// Handle special cases.
switch len(paths) {
case 0:
return ""
case 1:
return path.Clean(paths[0])
}
// Treat string as []byte, not []rune as is often done in Go.
c := []byte(path.Clean(paths[0]))
// Add a trailing sep to handle the case where the common prefix directory
// is included in the path list.
c = append(c, sep)
// Ignore the first path since it's already in c.
for _, v := range paths[1:] {
// Clean up each path before testing it.
v = path.Clean(v) + string(sep)
// Find the first non-common byte and truncate c.
if len(v) < len(c) {
c = c[:len(v)]
}
for i := 0; i < len(c); i++ {
if v[i] != c[i] {
c = c[:i]
break
}
}
}
// Remove trailing non-separator characters and the final separator.
for i := len(c) - 1; i >= 0; i-- {
if c[i] == sep {
c = c[:i]
break
}
}
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.
+18 -1
View File
@@ -2,8 +2,14 @@ package auth
import (
"net/http"
"sync"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var (
revokedApiKeyList map[string]bool
revokeMu sync.Mutex
)
// Auther is the authentication interface.
@@ -13,3 +19,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()
delete(revokedApiKeyList, key)
revokeMu.Unlock()
}
+54 -58
View File
@@ -3,15 +3,15 @@ package auth
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"strings"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
type hookCred struct {
@@ -32,13 +32,13 @@ type HookAuth struct {
// 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 {
logger.Error("decode body error")
return nil, os.ErrPermission
}
@@ -51,6 +51,8 @@ func (a *HookAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error
if err != nil {
return nil, err
}
logger.Debugf("hook auth %v", action)
switch action {
case "auth":
u, err := a.SaveUser()
@@ -59,11 +61,19 @@ func (a *HookAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error
}
return u, nil
case "block":
logger.Error("block error")
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
logger.Error("pass error")
u, err := a.Users.Get(a.Cred.Username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
err = users.CheckPwd(cred.Password, u.Password)
if err != nil {
return nil, err
}
return u, nil
default:
@@ -138,7 +148,7 @@ func (a *HookAuth) GetValues(s string) {
// 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)
u, err := a.Users.Get(a.Cred.Username)
if err != nil && err != errors.ErrNotExist {
return nil, err
}
@@ -146,34 +156,33 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
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,
HideDotfiles: a.Settings.UserDefaults.HideDotfiles,
NonAdminEditable: users.NonAdminEditable{
Password: a.Cred.Password,
Locale: a.Settings.UserDefaults.Locale,
ViewMode: a.Settings.UserDefaults.ViewMode,
SingleClick: a.Settings.UserDefaults.SingleClick,
ShowHidden: a.Settings.UserDefaults.ShowHidden,
},
Username: a.Cred.Username,
Permissions: a.Settings.UserDefaults.Permissions,
}
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
log.Printf("user: %s, home dir: [%s].", u.Username, userHome)
err = a.Users.Save(u)
err = a.Users.Save(u, false, false)
if err != nil {
return nil, err
}
} else if p := !users.CheckPwd(a.Cred.Password, u.Password); len(a.Fields.Values) > 1 || p {
return u, nil
}
err = users.CheckPwd(a.Cred.Password, u.Password)
if err != nil {
return nil, err
}
if len(a.Fields.Values) > 1 {
u = a.GetUser(u)
// update user with provided fields
err := a.Users.Update(u)
err := a.Users.Update(u, u.Permissions.Admin)
if err != nil {
return nil, err
}
@@ -185,32 +194,24 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
// 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
isAdmin := d.Permissions.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,
Admin: isAdmin,
Modify: isAdmin || d.Permissions.Modify,
Share: isAdmin || d.Permissions.Share,
}
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,
NonAdminEditable: users.NonAdminEditable{
Password: d.Password,
Locale: a.Fields.GetString("user.locale", d.Locale),
ViewMode: a.Fields.GetString("user.viewMode", d.ViewMode),
SingleClick: a.Fields.GetBoolean("user.singleClick", d.SingleClick),
ShowHidden: a.Fields.GetBoolean("user.showHidden", d.ShowHidden),
},
Commands: d.Commands,
HideDotfiles: d.HideDotfiles,
Perm: perms,
ID: d.ID,
Username: d.Username,
Scopes: d.Scopes,
Permissions: perms,
LockPassword: true,
}
@@ -231,16 +232,11 @@ var validHookFields = []string{
"user.singleClick",
"user.sorting.by",
"user.sorting.asc",
"user.commands",
"user.hideDotfiles",
"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",
"user.perm.api",
}
// IsValid checks if the provided field is on the valid fields list
+45 -25
View File
@@ -2,42 +2,32 @@ package auth
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
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) {
config := &settings.Config
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) {
password := r.URL.Query().Get("password")
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
totpCode := r.URL.Query().Get("code")
// 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
@@ -47,12 +37,42 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
return nil, os.ErrPermission
}
}
u, err := userStore.Get(config.Server.Root, 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 {
logger.Error("OTP verification failed")
return nil, err
}
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Warningf("user %s is not allowed to login with password authentication, bypassing and updating login method", user.Username)
user.LoginMethod = users.LoginMethodPassword
// Perform the user update
err = userStore.Update(user, true, "LoginMethod")
if err != nil {
logger.Debug(err.Error())
}
//http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
//return
}
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/settings"
"github.com/gtsteffaniak/filebrowser/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(settings.Config.Server.Root, uint(1))
return usr.Get(uint(1))
}
// LoginPage tells that no auth doesn't require a login page.
+3 -5
View File
@@ -4,10 +4,8 @@ import (
"net/http"
"os"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodProxyAuth is used to identify no auth.
@@ -21,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(settings.Config.Server.Root, username)
user, err := usr.Get(username)
if err == errors.ErrNotExist {
return nil, os.ErrPermission
}
+28 -3
View File
@@ -1,7 +1,11 @@
package auth
import (
"github.com/gtsteffaniak/filebrowser/users"
"encoding/base64"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// StorageBackend is a storage backend for auth storage.
@@ -17,8 +21,29 @@ 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(&HookAuth{})
if err != nil {
return nil, err
}
err = store.Save(&NoAuth{})
if err != nil {
return nil, err
}
encryptionKey, err = base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
if err != nil {
logger.Warning("failed to decode TOTP secret, using default key. This is insecure and should be fixed.")
}
return store, nil
}
// Get wraps a StorageBackend.Get.
+200
View File
@@ -0,0 +1,200 @@
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 := "otpauth://totp/FileBrowser%20Quantum?secret=" + 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, user.Permissions.Admin, "TOTPSecret", "TOTPNonce"); err != nil {
logger.Debug("error updating user with OTP token:", err)
return fmt.Errorf("error updating user with OTP token: %w", err)
}
return nil
}
+73 -33
View File
@@ -1,43 +1,83 @@
== Running benchmark ==
/usr/local/go/bin/go
? github.com/gtsteffaniak/filebrowser [no test files]
? github.com/gtsteffaniak/filebrowser/auth [no test files]
? github.com/gtsteffaniak/filebrowser/cmd [no test files]
? github.com/gtsteffaniak/filebrowser/backend [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/diskcache 0.004s
? github.com/gtsteffaniak/filebrowser/errors [no test files]
2024/10/07 12:46:34 could not update unknown type: unknown
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.004s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 0.006s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.003s
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
? github.com/gtsteffaniak/go-cache/cache [no test files]
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
? github.com/gtsteffaniak/filebrowser/backend/common/logger [no test files]
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
PASS
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.006s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.003s
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.002s
? github.com/gtsteffaniak/filebrowser/backend/events [no test files]
2025/05/11 20:42:56 [DEBUG] Saving user [admin] changepass: true
2025/05/11 20:42:56 [DEBUG] Saving user [non-admin] changepass: true
PASS
ok github.com/gtsteffaniak/filebrowser/backend/http 0.197s
2025/05/11 20:42:57 initializing index: [test]
2025/05/11 20:42:57 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:57 [DEBUG] Starting full scan for [test]
2025/05/11 20:42:57 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:42:57 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:42:57 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:57 [DEBUG] Next scan in 1m0s
2025/05/11 20:42:58 initializing index: [test]
2025/05/11 20:42:58 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:58 [DEBUG] Starting full scan for [test]
2025/05/11 20:42:58 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:42:58 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:42:58 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:58 [DEBUG] Next scan in 1m0s
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/files
pkg: github.com/gtsteffaniak/filebrowser/backend/indexing
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 10 3847878 ns/op 758424 B/op 5567 allocs/op
BenchmarkSearchAllIndexes-8 10 780431 ns/op 173444 B/op 2014 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/files 0.073s
ok github.com/gtsteffaniak/filebrowser/backend/indexing 5.097s
PASS
ok github.com/gtsteffaniak/filebrowser/fileutils 0.003s
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.005s
PASS
ok github.com/gtsteffaniak/filebrowser/http 0.080s
PASS
ok github.com/gtsteffaniak/filebrowser/img 0.137s
PASS
ok github.com/gtsteffaniak/filebrowser/rules 0.002s
PASS
ok github.com/gtsteffaniak/filebrowser/runner 0.003s
PASS
ok github.com/gtsteffaniak/filebrowser/settings 0.004s
? github.com/gtsteffaniak/filebrowser/share [no test files]
? github.com/gtsteffaniak/filebrowser/storage [no test files]
? github.com/gtsteffaniak/filebrowser/storage/bolt [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/users 0.002s
? github.com/gtsteffaniak/filebrowser/utils [no test files]
? github.com/gtsteffaniak/filebrowser/version [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.128s
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
? github.com/gtsteffaniak/filebrowser/backend/tools [no test files]
+366
View File
@@ -0,0 +1,366 @@
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/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"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")
}
if configPath == "" {
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, 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 "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]
ok := getStore(dbConfig)
if !ok {
logger.Fatal("could not load db info")
}
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)
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
logger.Errorf("could not create user: %v", err)
}
return false
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Warningf("user %s is not allowed to login with password authentication, bypassing and updating login method", user.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, _ = utils.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
}
}
}
+86 -120
View File
@@ -1,161 +1,127 @@
package cmd
import (
"flag"
"context"
"fmt"
"log"
"os"
"strings"
"os/signal"
"syscall"
"github.com/gtsteffaniak/filebrowser/diskcache"
"github.com/gtsteffaniak/filebrowser/files"
fbhttp "github.com/gtsteffaniak/filebrowser/http"
"github.com/gtsteffaniak/filebrowser/img"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/swagger/docs"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"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/users"
"github.com/gtsteffaniak/filebrowser/version"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
)
func getStore(config string) (*storage.Storage, bool) {
var store *storage.Storage
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 {
log.Fatal("could not load db info: ", err)
logger.Fatalf("could not load db info: %v", err)
}
return store, hasDB
store = s
return hasDB
}
func generalUsage() {
fmt.Printf(`usage: ./html-web-crawler <command> [options] --urls <urls>
commands:
collect Collect data from URLs
crawl Crawl URLs and collect data
install Install chrome browser for javascript enabled scraping.
Note: Consider instead to install via native package manager,
then set "CHROME_EXECUTABLE" in the environment
` + "\n")
fmt.Printf(`usage: ./filebrowser <command> [options]
commands:
-h Print help
-c Print the default config file
version Print version information
set -u Username and password for the new user
set -a Create user as admin
set -s Specify a user scope
set -h Print this help message
`)
}
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", "filebrowser.yaml", "Path to the config file.")
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 context and channels for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, scope, dbConfig string
var asAdmin bool
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
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", "filebrowser.yaml", "Path to the config file.")
// 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 {
log.Printf("Creating user as admin: %s\n", username)
} else {
log.Printf("Creating user: %s\n", username)
}
newUser := users.User{
Username: username,
Password: password,
}
if scope != "" {
newUser.Scope = scope
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
log.Fatal("Could not create user: ", err)
}
return
case "version":
fmt.Println("FileBrowser Quantum - A modern web-based file manager")
fmt.Printf("Version : %v\n", version.Version)
fmt.Printf("Commit : %v\n", version.CommitSHA)
fmt.Printf("Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v\n", version.Version)
return
}
}
store, dbExists := getStore(configPath)
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)
}
log.Printf("Initializing FileBrowser Quantum (%v)\n", version.Version)
log.Printf("Using Config file : %v", configPath)
log.Println("Embeded frontend :", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true")
log.Println(database)
log.Println("Sources :", settings.Config.Server.Root)
sourceList := []string{}
for path, source := range settings.Config.Server.SourceMap {
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
}
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.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)
go files.InitializeIndex(serverConfig.Indexing)
if err := rootCMD(store, &serverConfig); err != nil {
log.Fatal("Error starting filebrowser:", err)
if len(settings.Config.Server.SourceMap) == 0 {
logger.Fatal("No sources configured, exiting...")
}
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false)
}
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 *storage.Storage, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
if serverConfig.NumImageProcessors < 1 {
log.Fatal("Image resize workers count could not be < 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 {
log.Fatalf("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
}
-68
View File
@@ -1,68 +0,0 @@
package cmd
import (
"strconv"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
rulesCmd.AddCommand(rulesRmCommand)
rulesRmCommand.Flags().Uint("index", 0, "index of rule to remove")
_ = rulesRmCommand.MarkFlagRequired("index")
}
var rulesRmCommand = &cobra.Command{
Use: "rm <index> [index_end]",
Short: "Remove a global rule or user rule",
Long: `Remove a global rule or user rule. The provided index
is the same that's printed when you run 'rules ls'. Note
that after each removal/addition, the index of the
commands change. So be careful when removing them after each
other.
You can also specify an optional parameter (index_end) so
you can remove all commands from 'index' to 'index_end',
including 'index_end'.`,
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.RangeArgs(1, 2)(cmd, args); err != nil { //nolint:gomnd
return err
}
for _, arg := range args {
if _, err := strconv.Atoi(arg); err != nil {
return err
}
}
return nil
},
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
i, err := strconv.Atoi(args[0])
utils.CheckErr("strconv.Atoi", err)
f := i
if len(args) == 2 { //nolint:gomnd
f, err = strconv.Atoi(args[1])
utils.CheckErr("strconv.Atoi", err)
}
user := func(u *users.User) {
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
err := store.Users.Save(u)
utils.CheckErr("store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
err := store.Settings.Save(s)
utils.CheckErr("store.Settings.Save", err)
}
runRules(store, cmd, user, global)
}),
}
-86
View File
@@ -1,86 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
rulesCmd.PersistentFlags().StringP("username", "u", "", "username of user to which the rules apply")
rulesCmd.PersistentFlags().UintP("id", "i", 0, "id of user to which the rules apply")
}
var rulesCmd = &cobra.Command{
Use: "rules",
Short: "Rules management utility",
Long: `On each subcommand you'll have available at least two flags:
"username" and "id". You must either set only one of them
or none. If you set one of them, the command will apply to
an user, otherwise it will be applied to the global set or
rules.`,
Args: cobra.NoArgs,
}
func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User), globalFn func(*settings.Settings)) {
id := getUserIdentifier(cmd.Flags())
if id != nil {
user, err := st.Users.Get("", id)
utils.CheckErr("st.Users.Get", err)
if usersFn != nil {
usersFn(user)
}
printRules(user.Rules, id)
return
}
s, err := st.Settings.Get()
utils.CheckErr("st.Settings.Get", err)
if globalFn != nil {
globalFn(s)
}
printRules(s.Rules, id)
}
func getUserIdentifier(flags *pflag.FlagSet) interface{} {
id := mustGetUint(flags, "id")
username := mustGetString(flags, "username")
if id != 0 {
return id
} else if username != "" {
return username
}
return nil
}
func printRules(rulez []users.Rule, id interface{}) {
for id, rule := range rulez {
fmt.Printf("(%d) ", id)
if rule.Regex {
if rule.Allow {
fmt.Printf("Allow Regex: \t%s\n", rule.Regexp.Raw)
} else {
fmt.Printf("Disallow Regex: \t%s\n", rule.Regexp.Raw)
}
} else {
if rule.Allow {
fmt.Printf("Allow Path: \t%s\n", rule.Path)
} else {
fmt.Printf("Disallow Path: \t%s\n", rule.Path)
}
}
}
}
-59
View File
@@ -1,59 +0,0 @@
package cmd
import (
"regexp"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
rulesCmd.AddCommand(rulesAddCmd)
rulesAddCmd.Flags().BoolP("allow", "a", false, "indicates this is an allow rule")
rulesAddCmd.Flags().BoolP("regex", "r", false, "indicates this is a regex rule")
}
var rulesAddCmd = &cobra.Command{
Use: "add <path|expression>",
Short: "Add a global rule or user rule",
Long: `Add a global rule or user rule.`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
allow := mustGetBool(cmd.Flags(), "allow")
regex := mustGetBool(cmd.Flags(), "regex")
exp := args[0]
if regex {
regexp.MustCompile(exp)
}
rule := users.Rule{
Allow: allow,
Regex: regex,
}
if regex {
rule.Regexp = &users.Regexp{Raw: exp}
} else {
rule.Path = exp
}
user := func(u *users.User) {
u.Rules = append(u.Rules, rule)
err := store.Users.Save(u)
utils.CheckErr("store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules, rule)
err := store.Settings.Save(s)
utils.CheckErr("store.Settings.Save", err)
}
runRules(store, cmd, user, global)
}),
}
-20
View File
@@ -1,20 +0,0 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/spf13/cobra"
)
func init() {
rulesCmd.AddCommand(rulesLsCommand)
}
var rulesLsCommand = &cobra.Command{
Use: "ls",
Short: "List global rules or user specific rules",
Long: `List global rules or user specific rules.`,
Args: cobra.NoArgs,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
runRules(store, cmd, nil, nil)
}),
}
+161
View File
@@ -0,0 +1,161 @@
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 {
updateUser := false
if user.Username == "publicUser" {
settings.ApplyUserDefaults(user)
updateUser = true
}
if updateUserScopes(user) {
updateUser = true
}
if updatePermissions(user) {
updateUser = true
}
if updatePreviewSettings(user) {
updateUser = true
}
if updateLoginType(user) {
updateUser = 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, false, true)
if err != nil {
logger.Errorf("could not update user: %v", err)
}
}
}
if settings.Config.Auth.ResetAdminOnStart {
logger.Info("Resetting admin user to default username and password.")
adminUser, err := store.Users.Get(1)
if err != nil {
logger.Fatalf("could not load admin user: %v", err)
}
adminUser.Username = settings.Config.Auth.AdminUsername
adminUser.Password = settings.Config.Auth.AdminPassword
adminUser.Permissions.Admin = true
err = store.Users.Save(adminUser, true, true)
if err != nil {
logger.Errorf("could not Save admin user: %v", err)
}
}
}
func updateUserScopes(user *users.User) bool {
newScopes := []users.SourceScope{}
seen := make(map[string]bool)
// 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] = true
}
// Preserve user-defined scopes not matching current sources, append to end
for _, s := range user.Scopes {
if !seen[s.Name] {
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
}
+184
View File
@@ -0,0 +1,184 @@
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.DefaultSource = 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.DefaultSource = 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)
})
}
}
-54
View File
@@ -1,54 +0,0 @@
package cmd
import (
"fmt"
"os"
"strconv"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
)
var usersCmd = &cobra.Command{
Use: "users",
Short: "Users management utility",
Long: `Users management utility.`,
Args: cobra.NoArgs,
}
func printUsers(usrs []*users.User) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) //nolint:gomnd
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
for _, u := range usrs {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
u.ID,
u.Username,
u.Scope,
u.Locale,
u.ViewMode,
u.SingleClick,
u.Perm.Admin,
u.Perm.Execute,
u.Perm.Create,
u.Perm.Rename,
u.Perm.Modify,
u.Perm.Delete,
u.Perm.Share,
u.Perm.Download,
u.LockPassword,
)
}
w.Flush()
}
func parseUsernameOrID(arg string) (username string, id uint) {
id64, err := strconv.ParseUint(arg, 10, 64)
if err != nil {
return arg, 0
}
return "", uint(id64)
}
-42
View File
@@ -1,42 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
usersCmd.AddCommand(usersAddCmd)
}
var usersAddCmd = &cobra.Command{
Use: "add <username> <password>",
Short: "Create a new user",
Long: `Create a new user and add it to the database.`,
Args: cobra.ExactArgs(2), //nolint:gomnd
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
user := &users.User{
Username: args[0],
Password: args[1],
LockPassword: mustGetBool(cmd.Flags(), "lockPassword"),
}
servSettings, err := store.Settings.GetServer()
utils.CheckErr("store.Settings.GetServer()", err)
// since getUserDefaults() polluted s.Defaults.Scope
// which makes the Scope not the one saved in the db
// we need the right s.Defaults.Scope here
s2, err := store.Settings.Get()
utils.CheckErr("store.Settings.Get()", err)
userHome, err := s2.MakeUserDir(user.Username, user.Scope, servSettings.Root)
utils.CheckErr("s2.MakeUserDir", err)
user.Scope = userHome
err = store.Users.Save(user)
utils.CheckErr("store.Users.Save", err)
printUsers([]*users.User{user})
}),
}
-26
View File
@@ -1,26 +0,0 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/utils"
"github.com/spf13/cobra"
)
func init() {
usersCmd.AddCommand(usersExportCmd)
}
var usersExportCmd = &cobra.Command{
Use: "export <path>",
Short: "Export all users to a file.",
Long: `Export all users to a json or yaml file. Please indicate the
path to the file where you want to write the users.`,
Args: jsonYamlArg,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
list, err := store.Users.Gets("")
utils.CheckErr("store.Users.Gets", err)
err = marshal(args[0], list)
utils.CheckErr("marshal", err)
}),
}
-53
View File
@@ -1,53 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
usersCmd.AddCommand(usersFindCmd)
usersCmd.AddCommand(usersLsCmd)
}
var usersFindCmd = &cobra.Command{
Use: "find <id|username>",
Short: "Find a user by username or id",
Long: `Find a user by username or id. If no flag is set, all users will be printed.`,
Args: cobra.ExactArgs(1),
Run: findUsers,
}
var usersLsCmd = &cobra.Command{
Use: "ls",
Short: "List all users.",
Args: cobra.NoArgs,
Run: findUsers,
}
var findUsers = cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
var (
list []*users.User
user *users.User
err error
)
if len(args) == 1 {
username, id := parseUsernameOrID(args[0])
if username != "" {
user, err = store.Users.Get("", username)
} else {
user, err = store.Users.Get("", id)
}
list = []*users.User{user}
} else {
list, err = store.Users.Gets("")
}
utils.CheckErr("findUsers", err)
printUsers(list)
})
-88
View File
@@ -1,88 +0,0 @@
package cmd
import (
"errors"
"fmt"
"os"
"strconv"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
usersCmd.AddCommand(usersImportCmd)
usersImportCmd.Flags().Bool("overwrite", false, "overwrite users with the same id/username combo")
usersImportCmd.Flags().Bool("replace", false, "replace the entire user base")
}
var usersImportCmd = &cobra.Command{
Use: "import <path>",
Short: "Import users from a file",
Long: `Import users from a file. The path must be for a json or yaml
file. You can use this command to import new users to your
installation. For that, just don't place their ID on the files
list or set it to 0.`,
Args: jsonYamlArg,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
fd, err := os.Open(args[0])
utils.CheckErr("os.Open", err)
defer fd.Close()
list := []*users.User{}
err = unmarshal(args[0], &list)
utils.CheckErr("unmarshal", err)
if mustGetBool(cmd.Flags(), "replace") {
oldUsers, err := store.Users.Gets("")
utils.CheckErr("store.Users.Gets", err)
err = marshal("users.backup.json", list)
utils.CheckErr("marshal users.backup.json", err)
for _, user := range oldUsers {
err = store.Users.Delete(user.ID)
utils.CheckErr("store.Users.Delete", err)
}
}
overwrite := mustGetBool(cmd.Flags(), "overwrite")
for _, user := range list {
onDB, err := store.Users.Get("", user.ID)
// User exists in DB.
if err == nil {
if !overwrite {
newErr := errors.New("user " + strconv.Itoa(int(user.ID)) + " is already registered")
utils.CheckErr("", newErr)
}
// If the usernames mismatch, check if there is another one in the DB
// with the new username. If there is, print an error and cancel the
// operation
if user.Username != onDB.Username {
if conflictuous, err := store.Users.Get("", user.Username); err == nil { //nolint:govet
newErr := usernameConflictError(user.Username, conflictuous.ID, user.ID)
utils.CheckErr("usernameConflictError", newErr)
}
}
} else {
// If it doesn't exist, set the ID to 0 to automatically get a new
// one that make sense in this DB.
user.ID = 0
}
err = store.Users.Save(user)
utils.CheckErr("store.Users.Save", err)
}
}),
}
func usernameConflictError(username string, originalID, newID uint) error {
return fmt.Errorf(`can't import user with ID %d and username "%s" because the username is already registred with the user %d`,
newID, username, originalID)
}
-33
View File
@@ -1,33 +0,0 @@
package cmd
import (
"log"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/utils"
"github.com/spf13/cobra"
)
func init() {
usersCmd.AddCommand(usersRmCmd)
}
var usersRmCmd = &cobra.Command{
Use: "rm <id|username>",
Short: "Delete a user by username or id",
Long: `Delete a user by username or id`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
username, id := parseUsernameOrID(args[0])
var err error
if username != "" {
err = store.Users.Delete(username)
} else {
err = store.Users.Delete(id)
}
utils.CheckErr("usersRmCmd", err)
log.Println("user deleted successfully")
}),
}
-40
View File
@@ -1,40 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
usersCmd.AddCommand(usersUpdateCmd)
}
var usersUpdateCmd = &cobra.Command{
Use: "update <id|username>",
Short: "Updates an existing user",
Long: `Updates an existing user. Set the flags for the
options you want to change.`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
username, id := parseUsernameOrID(args[0])
var (
err error
user *users.User
)
if id != 0 {
user, err = store.Users.Get("", id)
} else {
user, err = store.Users.Get("", username)
}
utils.CheckErr("store.Users.Get", err)
err = store.Users.Update(user)
utils.CheckErr("store.Users.Update", err)
printUsers([]*users.User{user})
}),
}
-88
View File
@@ -1,88 +0,0 @@
package cmd
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"github.com/goccy/go-yaml"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/utils"
)
func mustGetString(flags *pflag.FlagSet, flag string) string {
s, err := flags.GetString(flag)
utils.CheckErr("mustGetString", err)
return s
}
func mustGetBool(flags *pflag.FlagSet, flag string) bool {
b, err := flags.GetBool(flag)
utils.CheckErr("mustGetBool", err)
return b
}
func mustGetUint(flags *pflag.FlagSet, flag string) uint {
b, err := flags.GetUint(flag)
utils.CheckErr("mustGetUint", err)
return b
}
type cobraFunc func(cmd *cobra.Command, args []string)
type pythonFunc func(cmd *cobra.Command, args []string, store *storage.Storage)
func marshal(filename string, data interface{}) error {
fd, err := os.Create(filename)
utils.CheckErr("os.Create", err)
defer fd.Close()
switch ext := filepath.Ext(filename); ext {
case ".json":
encoder := json.NewEncoder(fd)
encoder.SetIndent("", " ")
return encoder.Encode(data)
case ".yml", ".yaml": //nolint:goconst
_, err := yaml.Marshal(fd)
return err
default:
return errors.New("invalid format: " + ext)
}
}
func unmarshal(filename string, data interface{}) error {
fd, err := os.Open(filename)
utils.CheckErr("os.Open", err)
defer fd.Close()
switch ext := filepath.Ext(filename); ext {
case ".json":
return json.NewDecoder(fd).Decode(data)
case ".yml", ".yaml":
return yaml.NewDecoder(fd).Decode(data)
default:
return errors.New("invalid format: " + ext)
}
}
func jsonYamlArg(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
return err
}
switch ext := filepath.Ext(args[0]); ext {
case ".json", ".yml", ".yaml":
return nil
default:
return errors.New("invalid format: " + ext)
}
}
func cobraCmd(fn pythonFunc) cobraFunc {
return func(cmd *cobra.Command, args []string) {
}
}
@@ -18,4 +18,7 @@ var (
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")
)
+106
View File
@@ -0,0 +1,106 @@
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"` // the number of hours until the token expires. Default is 2 hours.
Methods LoginMethods `json:"methods"`
Key string `json:"key"` // the key used to sign the JWT tokens. If not set, a random key will be generated.
AdminUsername string `json:"adminUsername"` // the username of the admin user. If not set, the default is "admin".
AdminPassword string `json:"adminPassword"` // the password of the admin user. If not set, the default is "admin".
ResetAdminOnStart bool `json:"resetAdminOnStart"` // if set to true, the admin user will be reset to the default username and password on startup.
TotpSecret string `json:"totpSecret"` // 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,min=5"` // minimum pasword length required.
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"` // client id of the OIDC application
ClientSecret string `json:"clientSecret"` // 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 user identifier to use for authentication. Default is "username", can 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.
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.Enabled {
return errors.New("OIDC is not enabled")
}
if oidcCfg.UserIdentifier == "" {
oidcCfg.UserIdentifier = "username"
}
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})
if oidcCfg.Scopes == "" {
oidcCfg.Scopes = "openid email profile"
}
return nil
}
+441
View File
@@ -0,0 +1,441 @@
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)
if err != nil {
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 previeously, "
errmsg += "please review your config for typos and invalid keys which are no longer supported. "
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()
setupSources(false)
setupUrls()
setupFrontend()
}
func setupFrontend() {
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: "https://github.com/gtsteffaniak/filebrowser/wiki",
})
}
}
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 {
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
}
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = 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{}
first := true
potentialDefaultSource := Config.Server.DefaultSource
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) {
if first {
potentialDefaultSource = source
}
first = false
sourceList = append(sourceList, source)
if source.Config.DefaultEnabled {
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = source
}
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)
}
}
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = potentialDefaultSource
}
sourceList2 := []Source{}
for _, s := range sourceList {
if s.Path == Config.Server.DefaultSource.Path {
s.Config.DefaultEnabled = true
Config.Server.SourceMap[s.Path] = s
Config.Server.NameToSource[s.Name] = s
}
sourceList2 = append(sourceList2, s)
}
sourceList = sourceList2
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 + "/"
}
Config.Server.InternalUrl = strings.Trim(Config.Server.InternalUrl, "/")
Config.Server.ExternalUrl = strings.Trim(Config.Server.ExternalUrl, "/")
Config.Integrations.OnlyOffice.Url = strings.Trim(Config.Integrations.OnlyOffice.Url, "/")
Config.Integrations.OnlyOffice.InternalUrl = strings.Trim(Config.Integrations.OnlyOffice.InternalUrl, "/")
}
func setupAuth() {
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 {
err := validateOidcAuth()
if err != nil {
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 {
logConfig := logger.JsonConfig{
Levels: logConfig.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) error {
Config = setDefaults()
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
logger.Warningf("could not open config file '%v', using default settings", configFile)
}
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
}
}
func setDefaults() Settings {
// get number of CPUs available
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
if cpus := runtime.NumCPU(); cpus > 0 {
numCpus = cpus
}
return Settings{
Server: Server{
Port: 80,
NumImageProcessors: numCpus,
BaseURL: "",
Database: "database.db",
SourceMap: map[string]Source{},
NameToSource: map[string]Source{},
MaxArchiveSizeGB: 50,
CacheDir: "tmp",
},
Auth: Auth{
AdminUsername: "admin",
AdminPassword: "admin",
TokenExpirationHours: 2,
Methods: LoginMethods{
PasswordAuth: PasswordAuthConfig{
Enabled: true,
MinLength: 5,
Signup: false,
},
},
},
Frontend: Frontend{
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
DisableOnlyOfficeExt: ".txt .csv .html .pdf",
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,
},
},
}
}
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
}
+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(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
}
})
}
}
func TestConfigLoadChanged(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./validConfig.yaml")
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()
expectedKey := "MYKEY"
// mock environment variables
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
err := loadConfigWithDefaults("./validConfig.yaml")
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()
err := loadConfigWithDefaults("./validConfig.yaml")
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)
if err == nil {
t.Fatalf("expected error loading config file %s, got nil", configFile)
}
}
+255
View File
@@ -0,0 +1,255 @@
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
// collectComments parses all Go source in the directory of srcPath and returns commentsMap.
func collectComments(srcPath string) (commentsMap, error) {
// parse entire package so we capture comments on all types
dir := filepath.Dir(srcPath)
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
if err != nil {
return nil, err
}
out := make(commentsMap)
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
}
m := make(map[string]string)
out[ts.Name.Name] = m
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
name := field.Names[0].Name
var parts []string
if field.Doc != nil {
parts = append(parts, strings.TrimSpace(field.Doc.Text()))
}
if field.Comment != nil {
parts = append(parts, strings.TrimSpace(field.Comment.Text()))
}
if len(parts) > 0 {
m[name] = strings.Join(parts, " : ")
}
}
}
}
}
}
return out, 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) {
// Dereference pointers
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
}
return buildNode(v.Elem(), comm)
}
switch v.Kind() {
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
}
// 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
var parts []string
if cm := comm[typeName][sf.Name]; cm != "" {
parts = append(parts, cm)
}
if vt := sf.Tag.Get("validate"); vt != "" {
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
}
if len(parts) > 0 {
keyNode.LineComment = strings.Join(parts, " ")
}
valNode, err := buildNode(v.Field(i), comm)
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())
n, err := buildNode(zero, comm)
if err != nil {
return nil, err
}
seq.Content = append(seq.Content, n)
return seq, nil
}
for i := 0; i < v.Len(); i++ {
n, err := buildNode(v.Index(i), comm)
if err != nil {
return nil, err
}
seq.Content = append(seq.Content, n)
}
return seq, nil
default:
n := &yaml.Node{}
if err := n.Encode(v.Interface()); err != nil {
return nil, err
}
return n, nil
}
}
func GenerateYaml() {
_ = loadConfigWithDefaults("")
Config.Server.Sources = []Source{
{
Path: ".",
},
}
setupLogging()
setupAuth()
setupSources(true)
setupUrls()
setupFrontend()
input := "common/settings/settings.go" // "path to Go source file or directory containing structs"
output := "generated.yaml" // "output YAML file"
comm, err := collectComments(input)
if err != nil {
fmt.Fprintf(os.Stderr, "error parsing comments: %v\n", err)
os.Exit(1)
}
node, err := buildNode(reflect.ValueOf(Config), comm)
if err != nil {
fmt.Fprintf(os.Stderr, "error building YAML node: %v\n", err)
os.Exit(1)
}
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 {
fmt.Fprintf(os.Stderr, "error encoding YAML: %v\n", err)
os.Exit(1)
}
aligned := alignComments(rawBuf.String())
if err := os.WriteFile(output, []byte(aligned), 0644); err != nil {
fmt.Fprintf(os.Stderr, "error writing YAML: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generated YAML with comments: %s\n", output)
}
// 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
idx := strings.Index(line, "#")
// If there's no comment, do nothing.
if idx == -1 {
return line
}
// Separate the content (before #) from the comment (from # onwards).
contentPart := line[:idx]
commentPart := line[idx:]
// The YAML encoder adds a space; trim it so we measure only the content length.
trimmedContent := strings.TrimRight(contentPart, " ")
// If the content already extends past our target column, just add a single space.
if len(trimmedContent) >= targetCol {
return trimmedContent + strings.Repeat(" ", defaultPadding) + commentPart
}
// Otherwise, calculate the needed padding to reach the target column and add it.
paddingNeeded := targetCol - len(trimmedContent)
return trimmedContent + strings.Repeat(" ", paddingNeeded) + commentPart
}
// 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")
}
@@ -6,20 +6,16 @@ server:
tlsCert: ""
enableThumbnails: false
resizePreview: true
typeDetectionByHeader: true
port: 80
baseURL: "/"
address: ""
log: "stdout"
database: "mydb.db"
root: "/srv"
root: "."
auth:
recaptcha:
host: ""
key: ""
secret: ""
header: ""
method: json
command: ""
signup: false
shell: ""
@@ -48,5 +44,4 @@ userDefaults:
delete: true
share: true
download: true
hideDotfiles: false
dateFormat: false
@@ -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/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.HideDotfiles = Config.UserDefaults.HideDotfiles
u.Permissions = Config.UserDefaults.Permissions
u.Preview = Config.UserDefaults.Preview
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
return u
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
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/errors"
"github.com/gtsteffaniak/filebrowser/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
+133
View File
@@ -0,0 +1,133 @@
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 {
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
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the url that an integration service will use to communicate with filebrowser
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
DefaultSource Source `json:"-" validate:"omitempty"`
PdfAvailable bool `json:"-"` // used internally if compiled with pdf 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"`
}
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"`
}
type SourceConfig struct {
IndexingInterval uint32 `json:"indexingInterval"` // optional manual overide interval in seconds to re-index the source
Disabled bool `json:"disabled"` // disable the indexing of this source
MaxWatchers int `json:"maxWatchers"` // number of concurrent watchers to use for this source, currently not supported
NeverWatch []string `json:"neverWatchPaths"` // paths to never watch, relative to the source path (eg. "/folder/file.txt")
IgnoreHidden bool `json:"ignoreHidden"` // ignore hidden files and folders.
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
Exclude IndexFilter `json:"exclude"` // exclude files and folders from indexing, if include is not set
Include IndexFilter `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 IndexFilter struct {
Files []string `json:"files"` // array of file names to include/exclude
Folders []string `json:"folders"` // array of folder names to include/exclude
FileEndsWith []string `json:"fileEndsWith"` // array of file names to include/exclude (eg "a.jpg")
}
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"`
}
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 {
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
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // comma separated list of file extensions to disable onlyoffice preview 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
}
+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
+15
View File
@@ -0,0 +1,15 @@
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)
)
+146
View File
@@ -0,0 +1,146 @@
package utils
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
math "math/rand"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
func CheckErr(source string, err error) {
if err != nil {
logger.Fatalf("%s: %v", source, err)
}
}
func GenerateKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return ""
}
return string(b)
}
// CapitalizeFirst returns the input string with the first letter capitalized.
func CapitalizeFirst(s string) string {
if len(s) == 0 {
return s // Return the empty string as is
}
return strings.ToUpper(string(s[0])) + s[1:]
}
func InsecureRandomIdentifier(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
math.New(math.NewSource(time.Now().UnixNano()))
result := make([]byte, length)
for i := range result {
result[i] = charset[math.Intn(len(charset))]
}
return string(result)
}
func PrintStructFields(v interface{}) {
val := reflect.ValueOf(v)
typ := reflect.TypeOf(v)
// Ensure the input is a struct
if val.Kind() != reflect.Struct {
logger.Debug("Provided value is not a struct")
return
}
// Iterate over the fields of the struct
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
// Convert field value to string, if possible
fieldValue := fmt.Sprintf("%v", field.Interface())
// Limit to 50 characters
if len(fieldValue) > 100 {
fieldValue = fieldValue[:100] + "..."
}
logger.Debugf("Field: %s, %s\n", fieldType.Name, fieldValue)
}
}
func GetParentDirectoryPath(path string) string {
if path == "/" || path == "" {
return ""
}
path = strings.TrimSuffix(path, "/") // Remove trailing slash if any
lastSlash := strings.LastIndex(path, "/")
if lastSlash == -1 {
return "" // No parent directory for a relative path without slashes
}
if lastSlash == 0 {
return "/" // If the last slash is the first character, return root
}
return path[:lastSlash]
}
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
}
// ResolveSymlinks resolves symlinks in the given path and returns
// the final resolved path, whether it's a directory (considering bundle logic), and any error.
func ResolveSymlinks(path string) (string, bool, error) {
for {
// Get the file info using os.Lstat to handle symlinks
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
// Check if the path is a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the symlink's target relative to its directory
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, check with bundle-aware directory logic
isDir := iteminfo.IsDirectory(info)
return path, isDir, nil
}
}
}
File renamed without changes.
+73
View File
@@ -0,0 +1,73 @@
package utils
import (
"time"
"math/rand"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func CreateMockData(numDirs, numFilesPerDir int) iteminfo.FileInfo {
dir := iteminfo.FileInfo{}
dir.Path = "/here/is/your/mock/dir"
for i := 0; i < numDirs; i++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + GetRandomTerm() + GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Folders = append(dir.Folders, newFile)
}
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + GetRandomTerm() + GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Files = append(dir.Files, newFile)
}
return dir
}
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
+22
View File
@@ -0,0 +1,22 @@
server:
port: 80
baseURL: "/"
logging:
- levels: "info|warning|error"
sources:
- path: "/srv"
userDefaults:
preview:
image: true
popup: true
video: false
office: false
highQuality: false
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: false
share: false
api: false
@@ -10,6 +10,7 @@ type CreateBody struct {
type Link struct {
Hash string `json:"hash" storm:"id,index"`
Path string `json:"path" storm:"index"`
Source string `json:"source" storm:"index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
@@ -3,7 +3,7 @@ package share
import (
"time"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// StorageBackend is the interface to implement for a share storage.
@@ -11,8 +11,8 @@ type StorageBackend interface {
All() ([]*Link, error)
FindByUserID(id uint) ([]*Link, error)
GetByHash(hash string) (*Link, error)
GetPermanent(path string, id uint) (*Link, error)
Gets(path string, id uint) ([]*Link, error)
GetPermanent(path, source string, id uint) (*Link, error)
Gets(path, source string, id uint) ([]*Link, error)
Save(s *Link) error
Delete(hash string) error
}
@@ -85,13 +85,13 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
}
// GetPermanent wraps a StorageBackend.GetPermanent
func (s *Storage) GetPermanent(path string, id uint) (*Link, error) {
return s.back.GetPermanent(path, id)
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
return s.back.GetPermanent(path, source, id)
}
// Gets wraps a StorageBackend.Gets
func (s *Storage) Gets(path string, id uint) ([]*Link, error) {
links, err := s.back.Gets(path, id)
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
@@ -1,9 +1,9 @@
package bolt
import (
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/auth"
"github.com/gtsteffaniak/filebrowser/errors"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
type authBackend struct {
@@ -1,12 +1,12 @@
package bolt
import (
"github.com/asdine/storm/v3"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/auth"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/share"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// NewStorage creates a storage.Storage based on Bolt DB.
@@ -14,6 +14,9 @@ func NewStorage(db *storm.DB) (*auth.Storage, *users.Storage, *share.Storage, *s
userStore := users.NewStorage(usersBackend{db: db})
shareStore := share.NewStorage(shareBackend{db: db})
settingsStore := settings.NewStorage(settingsBackend{db: db})
authStore := auth.NewStorage(authBackend{db: db}, userStore)
authStore, err := auth.NewStorage(authBackend{db: db}, userStore)
if err != nil {
return nil, nil, nil, nil, err
}
return authStore, userStore, shareStore, settingsStore, nil
}
@@ -1,8 +1,8 @@
package bolt
import (
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/settings"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
type settingsBackend struct {
+84
View File
@@ -0,0 +1,84 @@
package bolt
import (
"time"
storm "github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/go-logger/logger"
)
type shareBackend struct {
db *storm.DB
}
func (s shareBackend) All() ([]*share.Link, error) {
var v []*share.Link
err := s.db.All(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
}
return v, err
}
func (s shareBackend) FindByUserID(id uint) ([]*share.Link, error) {
var v []*share.Link
err := s.db.Select(q.Eq("UserID", id)).Find(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
}
return v, err
}
func (s shareBackend) GetByHash(hash string) (*share.Link, error) {
var v share.Link
err := s.db.One("Hash", hash, &v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return &v, err
}
func (s shareBackend) GetPermanent(path, source string, id uint) (*share.Link, error) {
var v share.Link
// TODO remove legacy and return notfound errors
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", source), q.Eq("Expire", 0), q.Eq("UserID", id)).First(&v)
return &v, nil
}
func (s shareBackend) Gets(path, sourcePath string, id uint) ([]*share.Link, error) {
var v []*share.Link
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", sourcePath), q.Eq("UserID", id)).Find(&v)
filteredList := []*share.Link{}
var err error
// through and filter out expired share
for i := range v {
if v[i].Expire < time.Now().Unix() && v[i].Expire != 0 {
err = s.Delete(v[i].PasswordHash)
if err != nil {
logger.Errorf("expired share could not be deleted: %v", err.Error())
}
} else {
filteredList = append(filteredList, v[i])
}
}
return filteredList, err
}
func (s shareBackend) Save(l *share.Link) error {
return s.db.Save(l)
}
func (s shareBackend) Delete(hash string) error {
err := s.db.DeleteStruct(&share.Link{Hash: hash})
if err == storm.ErrNotFound {
return nil
}
return err
}
+269
View File
@@ -0,0 +1,269 @@
package bolt
import (
"fmt"
"reflect"
"slices"
"strings"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
type usersBackend struct {
db *storm.DB
}
func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
user = &users.User{}
var arg string
var val interface{}
switch i := i.(type) {
case uint:
val = i
arg = "ID"
case int:
val = uint(i)
arg = "ID"
case string:
arg = "Username"
val = i
default:
return nil, errors.ErrInvalidDataType
}
err = st.db.One(arg, val, user)
if err != nil {
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return nil, err
}
return
}
func (st usersBackend) Gets() ([]*users.User, error) {
var allUsers []*users.User
err := st.db.All(&allUsers)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
if err != nil {
return allUsers, err
}
return allUsers, err
}
func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...string) error {
existingUser, err := st.GetBy(user.ID)
if err != nil {
return err
}
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !user.OtpEnabled {
return errors.ErrNoTotpConfigured
}
fields, err = parseFields(user, fields, actorIsAdmin)
if err != nil {
return err
}
if !slices.Contains(fields, "Password") {
user.Password = existingUser.Password
} else {
if existingUser.LockPassword {
return fmt.Errorf("password cannot be changed when lock password is enabled")
}
}
if !actorIsAdmin {
err := checkRestrictedFields(existingUser, fields)
if err != nil {
return err
}
}
if len(fields) == 0 {
return fmt.Errorf("no fields to update")
}
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "Scopes") {
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
if err != nil {
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, true)
if err != nil {
return err
}
}
// Use reflection to access struct fields
userFields := reflect.ValueOf(user).Elem() // Get struct value
for _, field := range fields {
// Get the corresponding field using reflection
fieldValue := userFields.FieldByName(field)
if !fieldValue.IsValid() {
return fmt.Errorf("invalid field: %s", field)
}
// Ensure the field is settable
if !fieldValue.CanSet() {
return fmt.Errorf("cannot set value of field: %s", field)
}
// Get the value to be stored
val := fieldValue.Interface()
// Update the database
if err := st.db.UpdateField(existingUser, field, val); err != nil {
return fmt.Errorf("failed to update user field: %s, error: %v", field, err)
}
}
// last revoke api keys if needed.
if existingUser.Permissions.Api && !user.Permissions.Api && slices.Contains(fields, "Permissions") {
for _, key := range existingUser.ApiKeys {
auth.RevokeAPIKey(key.Key) // add to blacklist
}
}
return nil
}
func (st usersBackend) Save(user *users.User, changePass, disableScopeChange bool) error {
if user.LoginMethod == "" {
user.LoginMethod = users.LoginMethodPassword
}
logger.Debugf("Saving user [%s] changepass: %v", user.Username, changePass)
if user.LoginMethod == users.LoginMethodPassword && changePass {
err := checkPassword(user.Password)
if err != nil {
return err
}
pass, err := users.HashPwd(user.Password)
if err != nil {
return err
}
user.Password = pass
}
// converting scopes to map of paths intead of names (names can change)
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
if err != nil {
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, disableScopeChange)
if err != nil {
logger.Error(err.Error())
}
err = st.db.Save(user)
if err == storm.ErrAlreadyExists {
return fmt.Errorf("user with provided username already exists")
}
return err
}
func (st usersBackend) DeleteByID(id uint) error {
return st.db.DeleteStruct(&users.User{ID: id})
}
func (st usersBackend) DeleteByUsername(username string) error {
user, err := st.GetBy(username)
if err != nil {
return err
}
return st.db.DeleteStruct(user)
}
// Define a function to check for restricted fields
func checkRestrictedFields(existingUser *users.User, fields []string) error {
// Get a list of allowed fields from NonAdminEditable
allowed := getNonAdminEditableFieldNames()
for _, field := range fields {
if !slices.Contains(allowed, field) {
return fmt.Errorf("non-admins cannot modify field: %s", field)
}
}
return nil
}
// Helper to return list of field names from NonAdminEditable struct
func getNonAdminEditableFieldNames() []string {
var names []string
t := reflect.TypeOf(users.NonAdminEditable{})
for i := 0; i < t.NumField(); i++ {
names = append(names, t.Field(i).Name)
}
return names
}
func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string, error) {
// If `Which` is not specified, default to updating all fields
if len(fields) == 0 || fields[0] == "all" {
fields = []string{}
v := reflect.ValueOf(user)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
// Dynamically populate fields to update
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// which=all can't update password
switch strings.ToLower(field.Name) {
case "id", "username", "loginmethod", "password":
// Skip these fields
continue
}
fields = append(fields, field.Name)
}
}
newfields := []string{}
for _, field := range fields {
capitalField := utils.CapitalizeFirst(field)
if capitalField == "Scopes" {
if !actorIsAdmin {
continue
}
}
if capitalField == "Password" {
if user.LoginMethod != users.LoginMethodPassword {
return nil, fmt.Errorf("password cannot be changed when login method is not password")
}
err := checkPassword(user.Password)
if err != nil {
return nil, fmt.Errorf("password does not meet complexity requirements")
}
value, err := users.HashPwd(user.Password)
if err != nil {
logger.Error(err.Error())
}
user.Password = value
}
newfields = append(newfields, capitalField)
}
return newfields, nil
}
func checkPassword(password string) error {
if len(password) < settings.Config.Auth.Methods.PasswordAuth.MinLength {
return fmt.Errorf("password must be at least %d characters long", settings.Config.Auth.Methods.PasswordAuth.MinLength)
}
return nil
}
@@ -1,9 +1,9 @@
package bolt
import (
"github.com/asdine/storm/v3"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
func get(db *storm.DB, name string, to interface{}) error {
@@ -2,19 +2,18 @@ package storage
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/auth"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/share"
"github.com/gtsteffaniak/filebrowser/storage/bolt"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// Storage is a storage powered by a Backend which makes the necessary
@@ -34,14 +33,17 @@ func InitializeDb(path string) (*Storage, bool, error) {
panic(err)
}
db, err := storm.Open(path)
utils.CheckErr(fmt.Sprintf("storm.Open path %v", path), err)
if err != nil {
if strings.Contains(err.Error(), "timeout") {
logger.Fatal("the database is locked, please close all other instances of filebrowser before starting.")
}
logger.Fatalf("could not open database: %v", err)
}
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
if err != nil {
return nil, exists, err
}
err = bolt.Save(db, "version", 2) //nolint:gomnd
err = bolt.Save(db, "version", 2)
if err != nil {
return nil, exists, err
}
@@ -80,55 +82,54 @@ func dbExists(path string) (bool, error) {
func quickSetup(store *Storage) {
settings.Config.Auth.Key = utils.GenerateKey()
if settings.Config.Auth.Method == "noauth" {
err := store.Auth.Save(&auth.NoAuth{})
utils.CheckErr("store.Auth.Save", err)
} else {
settings.Config.Auth.Method = "password"
err := store.Auth.Save(&auth.JSONAuth{})
utils.CheckErr("store.Auth.Save", err)
}
err := store.Settings.Save(&settings.Config)
utils.CheckErr("store.Settings.Save", err)
err = store.Settings.SaveServer(&settings.Config.Server)
utils.CheckErr("store.Settings.SaveServer", err)
user := settings.ApplyUserDefaults(users.User{})
user := &users.User{}
settings.ApplyUserDefaults(user)
user.Username = settings.Config.Auth.AdminUsername
user.Password = settings.Config.Auth.AdminPassword
user.Perm.Admin = true
user.Scope = "./"
user.DarkMode = true
user.ViewMode = "normal"
user.Permissions.Admin = true
user.Scopes = []users.SourceScope{}
for _, val := range settings.Config.Server.Sources {
user.Scopes = append(user.Scopes, users.SourceScope{
Name: val.Path, // backend name is path
Scope: "",
})
}
user.LockPassword = false
user.Perm = settings.AdminPerms()
err = store.Users.Save(&user)
user.Permissions = settings.AdminPerms()
logger.Debugf("Creating user as admin: %v %v", user.Username, user.Password)
err = store.Users.Save(user, true, true)
utils.CheckErr("store.Users.Save", err)
}
// create new user
func CreateUser(userInfo users.User, asAdmin bool) error {
newUser := &userInfo
if userInfo.LoginMethod == "password" {
if userInfo.Password == "" {
return fmt.Errorf("password is required to create a password login user")
}
} else {
hashpass, err := users.HashPwd(userInfo.Username)
if err != nil {
return err
}
userInfo.Password = hashpass
}
// must have username or password to create
if userInfo.Username == "" || userInfo.Password == "" {
return errors.ErrInvalidRequestParams
if userInfo.Username == "" {
return fmt.Errorf("username is required to create a user")
}
newUser := settings.ApplyUserDefaults(userInfo)
logger.Debugf("Creating user: %v %v", userInfo.Username, userInfo.Scopes)
settings.ApplyUserDefaults(newUser)
if asAdmin {
newUser.Perm = settings.AdminPerms()
userInfo.Permissions = settings.AdminPerms()
}
// create new home directory
userHome, err := settings.Config.MakeUserDir(newUser.Username, newUser.Scope, settings.Config.Server.Root)
if err != nil {
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
return err
}
newUser.Scope = userHome
log.Printf("user: %s, home dir: [%s].", newUser.Username, userHome)
_, _, err = files.GetRealPath(settings.Config.Server.Root, newUser.Scope)
if err != nil {
log.Println("user path is not valid", newUser.Scope)
return nil
}
err = store.Users.Save(&newUser)
// create new home directories
err := store.Users.Save(newUser, true, false)
if err != nil {
return err
}
@@ -11,7 +11,6 @@ func HashPwd(password string) (string, error) {
}
// CheckPwd checks if a password is correct.
func CheckPwd(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
func CheckPwd(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
@@ -4,31 +4,29 @@ import (
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// StorageBackend is the interface to implement for a users storage.
type StorageBackend interface {
GetBy(interface{}) (*User, error)
Gets() ([]*User, error)
Save(u *User) error
Update(u *User, fields ...string) error
Save(u *User, changePass bool, disableScopeChange bool) error
Update(u *User, adminActor bool, fields ...string) error
DeleteByID(uint) error
DeleteByUsername(string) error
}
// Store is an interface for user storage.
type Store interface {
Get(baseScope string, id interface{}) (user *User, err error)
Gets(baseScope string) ([]*User, error)
Update(user *User, fields ...string) error
Save(user *User) error
Get(id interface{}) (user *User, err error)
Gets() ([]*User, error)
Update(user *User, adminActor bool, fields ...string) error
Save(user *User, changePass bool, disableScopeChange bool) error
Delete(id interface{}) error
LastUpdate(id uint) int64
AddApiKey(username uint, name string, key AuthToken) error
DeleteApiKey(username uint, name string) error
AddRule(username string, rule Rule) error
DeleteRule(username string, ruleID string) error
}
// Storage is a users storage.
@@ -49,7 +47,7 @@ func NewStorage(back StorageBackend) *Storage {
// Get allows you to get a user by its name or username. The provided
// id must be a string for username lookup or a uint for id lookup. If id
// is neither, a ErrInvalidDataType will be returned.
func (s *Storage) Get(baseScope string, id interface{}) (user *User, err error) {
func (s *Storage) Get(id interface{}) (user *User, err error) {
user, err = s.back.GetBy(id)
if err != nil {
return
@@ -58,7 +56,7 @@ func (s *Storage) Get(baseScope string, id interface{}) (user *User, err error)
}
// Gets gets a list of all users.
func (s *Storage) Gets(baseScope string) ([]*User, error) {
func (s *Storage) Gets() ([]*User, error) {
users, err := s.back.Gets()
if err != nil {
return nil, err
@@ -67,8 +65,8 @@ func (s *Storage) Gets(baseScope string) ([]*User, error) {
}
// Update updates a user in the database.
func (s *Storage) Update(user *User, fields ...string) error {
err := s.back.Update(user, fields...)
func (s *Storage) Update(user *User, adminIActor bool, fields ...string) error {
err := s.back.Update(user, adminIActor, fields...)
if err != nil {
return err
}
@@ -79,25 +77,8 @@ func (s *Storage) Update(user *User, fields ...string) error {
return nil
}
// AddRule adds a rule to the user's rules list and updates the user in the database.
func (s *Storage) AddRule(userID string, rule Rule) error {
user, err := s.Get("", userID)
if err != nil {
return err
}
user.Rules = append(user.Rules, rule)
err = s.Update(user, "Rules")
if err != nil {
return err
}
return nil
}
func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
user, err := s.Get("", userID)
user, err := s.Get(userID)
if err != nil {
return err
}
@@ -106,7 +87,7 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
user.ApiKeys = make(map[string]AuthToken)
}
user.ApiKeys[name] = key
err = s.Update(user, "ApiKeys")
err = s.Update(user, true, "ApiKeys")
if err != nil {
return err
}
@@ -115,7 +96,7 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
}
func (s *Storage) DeleteApiKey(userID uint, name string) error {
user, err := s.Get("", userID)
user, err := s.Get(userID)
if err != nil {
return err
}
@@ -124,32 +105,7 @@ func (s *Storage) DeleteApiKey(userID uint, name string) error {
user.ApiKeys = make(map[string]AuthToken)
}
delete(user.ApiKeys, name)
err = s.Update(user, "ApiKeys")
if err != nil {
return err
}
return nil
}
// DeleteRule deletes a rule specified by ID from the user's rules list and updates the user in the database.
func (s *Storage) DeleteRule(userID string, ruleID string) error {
user, err := s.Get("", userID)
if err != nil {
return err
}
// Find and remove the rule with the specified ID
var updatedRules []Rule
for _, r := range user.Rules {
if r.Id != ruleID {
updatedRules = append(updatedRules, r)
}
}
user.Rules = updatedRules
err = s.Update(user, "Rules")
err = s.Update(user, true, "ApiKeys")
if err != nil {
return err
}
@@ -158,8 +114,8 @@ func (s *Storage) DeleteRule(userID string, ruleID string) error {
}
// Save saves the user in a storage.
func (s *Storage) Save(user *User) error {
return s.back.Save(user)
func (s *Storage) Save(user *User, changePass, disableScopeChange bool) error {
return s.back.Save(user, changePass, disableScopeChange)
}
// Delete allows you to delete a user by its name or username. The provided
File renamed without changes.
Loaded 100 of 363 files, more files were not shown because too many files have changed in this diff. Show more