Compare commits

...
73 Commits
Author SHA1 Message Date
Graham Steffaniak 0ffc1c1ae6 Disable bool validation (#528) 2025-04-12 12:03:53 -05:00
Graham Steffaniak d3e49b2803 Update validations v0.6.8 (#525) 2025-04-12 10:18:50 -05:00
Graham Steffaniak 5be5646c7c updated fields 2025-04-11 19:06:50 -05:00
Graham Steffaniak 19e6a836a5 updated workflow 2025-04-11 19:01:14 -05:00
Graham Steffaniak a435fa20bf updated workflow 2025-04-11 18:58:09 -05:00
Graham Steffaniak 3ddedea6ef updated workflow 2025-04-11 18:53:14 -05:00
Graham Steffaniak 3d612c61a4 fix typo for deselect (#524) 2025-04-11 18:32:48 -05:00
Graham Steffaniak 938527301b fixes scope reset issue on restart (#522) 2025-04-10 15:51:49 -05:00
Graham Steffaniak 79a0b3def9 add validation to 0.6.8 release (#521) 2025-04-10 10:53:17 -05:00
Graham Steffaniak 9757cd58b6 updated workflow 2025-04-09 14:05:43 -05:00
Graham Steffaniak 31304ef9a6 updated with ghcr pushing (#513) 2025-04-09 10:54:16 -05:00
Aaron Ky-Riesenbach cf4abf63c6 Allow secrets to be read from environment variables (#511) 2025-04-08 18:16:52 -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
Graham Steffaniak f5450b06e8 Hotfix: Fix url link issues (#246) 2024-11-27 07:30:03 -06:00
Graham Steffaniak 15e8b4602f v0.3.1 release (#245) 2024-11-26 11:21:41 -06:00
Graham Steffaniak 21c9e321b3 Hotfix: user creation and other api url issues (#240) 2024-11-21 09:58:28 -06:00
Graham Steffaniak 9a011d3807 Update README.md 2024-11-20 18:34:18 -06:00
Graham Steffaniak 1ef28e1a1b updated test order (#238) 2024-11-20 18:28:01 -06:00
Graham Steffaniak a5548bb776 v0.3.0 release 2024-11-20 18:16:42 -06:00
Graham Steffaniak 822dc2f5fd Update README.md 2024-10-07 17:50:21 -05:00
Graham Steffaniak 1608877813 V0.2.10 (#220) 2024-10-07 17:44:53 -05:00
Graham Steffaniak b4b92bf852 Adjusted path tweak (#213) 2024-09-17 12:15:29 -05:00
Graham Steffaniak 2ec0c457cf hotfix: adjusted paths (#211) 2024-09-16 20:30:55 -05:00
308 changed files with 21440 additions and 9252 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
+65 -8
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,8 +104,8 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
file: ./_docker/Dockerfile
push: false # Do not push the image for now
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
@@ -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'."
+12 -5
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.22.5'
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
@@ -41,4 +41,11 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i eslint && npm run lint
run: npm i && npm run lint
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run test
-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
file: ./_docker/Dockerfile
push: true
tags: ${{ env.CLEANED_TAG }}
labels: ${{ steps.meta.outputs.labels }}
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 }}
+69 -2
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
@@ -29,3 +29,70 @@ 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,linux/arm/v7
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+10 -2
View File
@@ -1,18 +1,26 @@
*.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/loginAuth.json
/frontend/package-lock.json
/backend/vendor
/backend/*.cov
/backend/test_config.yaml
/backend/srv
/backend/http/dist
/backend/http/embed/*
.DS_Store
node_modules
+430 -2
View File
@@ -2,6 +2,434 @@
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.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`
- also pushes to ghcr `ghcr.io/gtsteffaniak/filebrowser` https://github.com/gtsteffaniak/filebrowser/issues/470
**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**
- Adds Smart Indexing by default.
**Notes**:
- Optimized api request response times via improved caching and simplified actions.
- User information persists more reliably.
- Added [indexing doc](./docs/indexing.md) to explain the expectations around indexing and how it works.
- The index should also use less RAM than it did in v0.3.0.
**Bugfixes**:
- Tweaked sorting by name, fixes case sensitive and numeric sorting. https://github.com/gtsteffaniak/filebrowser/issues/230
- Fixed unnecessary authentication status checks each route change
- Fix create file action issue.
- some small javascript related issues.
- Fixes pretty big bug viewing raw content in v0.3.0 (utf format message)
## v0.3.0
This Release focuses on the API and making it more accessible for developers to access functions without the UI.
**New Features**:
- You can now long-live api tokens to interact with API from the user settings page.
- These tokens have the same permissions as your user.
- Helpful swagger page for API usage.
- Some API's were refactored for friendlier API usage, moving some attributes to parameters and first looking for a api token, then using the stored cookie if none is found. This allows for all api requests from swagger page to work without a token.
- Add file size to search preview! Should have been in last release... sorry!
**Notes**:
- Replaced backend http framework with go standard library.
- Right-click Context menu can target the item that was right-clicked. To fully address https://github.com/gtsteffaniak/filebrowser/issues/214
- adjusted settings menu for mobile, always shows all available cards rather than grayed out cards that need to be clicked.
- longer and more cryptographically secure share links based on UUID rather than base64.
**Bugfixes**:
- Fixed ui bug with shares with password.
- Fixes baseurl related bugs https://github.com/gtsteffaniak/filebrowser/pull/228 Thanks @SimLV
- Fixed empty directory load issue.
- Fixed image preview cutoff on mobile.
- Fixed issue introduced in v0.2.10 where new files and folders were not showing up on ui
- Fixed preview issue where preview would not load after viewing video files.
- Fixed sorting issue where files were not sorted by name by default.
- Fixed copy file prompt issue
## v0.2.10
**New Features**:
- Allows user creation command line arguments https://github.com/gtsteffaniak/filebrowser/issues/196
- Folder sizes are always shown, leveraging the index. https://github.com/gtsteffaniak/filebrowser/issues/138
- Searching files based on filesize is no longer slower.
**Bugfixes**:
- fixes file selection usage when in single-click mode https://github.com/gtsteffaniak/filebrowser/issues/214
- Fixed displayed search context on root directory
- Fixed issue searching "smaller than" actually returned files "larger than"
**Notes**:
- Memory usage from index is reduced by ~40%
- Indexing time has increased 2x due to the extra processing time required to calculate directory sizes.
- File size calculations use 1024 base vs previous 1000 base (matching windows explorer)
## v0.2.9
This release focused on UI navigation experience. Improving keyboard navigation and adds right click context menu.
@@ -23,7 +451,7 @@ All notable changes to this project will be documented in this file. For commit
## v0.2.8
- **Feature**: New gallary view scaling options (closes [#141](https://github.com/gtsteffaniak/filebrowser/issues/141))
- **Feature**: New gallery view scaling options (closes [#141](https://github.com/gtsteffaniak/filebrowser/issues/141))
- **Change**: Refactored backend files functions
- **Change**: Improved UI response to filesystem changes
- **Change**: Added frontend tests for deployment integrity
@@ -109,7 +537,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.
-23
View File
@@ -1,23 +0,0 @@
FROM golang:1.22-alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
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* ./
COPY --from=nbuild /app/dist/ ./frontend/dist/
ENTRYPOINT [ "./filebrowser" ]
-19
View File
@@ -1,19 +0,0 @@
FROM golang:1.22-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
+74 -139
View File
@@ -1,50 +1,56 @@
<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/8ba93582-aba2-4996-8ac3-25f763a2e596" 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)
[![Poll](https://img.shields.io/badge/poll-vote_most_important_features-purple)](https://github.com/gtsteffaniak/filebrowser/discussions/368)
[![Donate with PayPal](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>
A modern web-based file manager
<br/><br/>
<img width="800" src="https://github.com/user-attachments/assets/c35a8455-c32d-47f5-853d-f285e160b022" title="Main Screenshot">
</div>
> [!WARNING]
> Starting with v0.2.0, *ALL* configuration is done via `filebrowser.yaml`
> Configuration file.
> Starting with v0.2.4 *ALL* share links need to be re-created (due to
> security fix).
> There is no stable version -- planned 2025.
FileBrowser Quantum is a fork of the filebrowser opensource project with the
following changes:
> [!NOTE]
> [Read about upcoming 0.7.0 release](https://github.com/gtsteffaniak/filebrowser/discussions/459)
1. [x] Enhanced lightning fast indexed search
- Real-time results as you type
- Works with more type filters
- Enhanced interactive results page.
2. [x] Revamped and simplified GUI navbar and sidebar menu.
- Additional compact view mode as well as refreshed view mode
styles.
3. [x] Revamped and simplified configuration via `filebrowser.yml` config file.
4. [x] Faster listing browsing
- Switching view modes is instant
- Changing Sort order is instant
- The entire directory is loaded in 1/3 the time
FileBrowser Quantum is a fork of the file browser open-source project with the following changes:
1. ✅ Indexes files (See [indexing Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.)
- Real-time search results as you type
- Search supports file/folder sizes and many file type filters.
- Enhanced interactive results that show file/folder sizes.
2. ✅ Multiple sources support
3. ✅ Revamped UI
- Additional compact view mode as well as refreshed view mode styles.
- Many graphical and user experience improvements.
- right-click context menu
4. ✅ Revamped and simplified configuration via `config.yaml` config file.
5. ✅ Better listing browsing
- Instantly Switches view modes and sort order without reloading data.
- Folder sizes are displayed
- Navigating remembers the scroll position, navigating back keeps the last scroll position.
6. ✅ Developer API support
- Ability to create long-live API Tokens.
- Helpful Swagger page available at `/swagger` endpoint.
Notable features that this fork *does not* have (removed):
- ❌ jobs 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.
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.
FileBrowser Quantum differs significantly to the original.
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:
@@ -53,10 +59,11 @@ focus of this fork is on a few key principles:
- Minimize external dependencies and standard library usage.
- Of course -- adding much-needed features.
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
## Look
One way you can observe the improved user experience is how I changed
the UI. The Navbar is simplified to a three-component system :
The UI has a simple three-component navigation system :
1. (Left) The slide-out action panel button
2. (Middle) The powerful search bar.
@@ -68,106 +75,36 @@ action panel. If the action is available based on context, it will show up as
a popup menu.
<p align="center">
<img width="800" src="https://github.com/gtsteffaniak/filebrowser/assets/42989099/899152cf-3e69-4179-aa82-752af2df3fc6" title="Main Screenshot">
<img width="800" src="https://github.com/user-attachments/assets/18c02d03-5c60-4e15-9c32-3cfe058a0c49" title="Main Screenshot">
<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:
See the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration)
1. docker run (no persistent db):
## Command Line Usage
```
docker run -it -v /path/to/folder:/srv -p 80:80 gtstef/filebrowser
```
See the [CLI Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
1. docker compose:
## API Usage
- with local storage
```
version: '3.7'
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
```
version: '3.7'
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 hinfo 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>
```
See the [API Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/API)
## Configuration
All configuration is now done via a single configuration file:
`filebrowser.yaml`, here is an example of minimal [configuration
file](./backend/filebrowser.yaml).
Configuration is done via the `config.yaml`, see the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration) for available configuration options and other help.
View the [Configuration Help Page](./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 filebrowser/filebrowser
If you currently use the original opensource filebrowser
but want to try using this. I recommend you start fresh without
reusing the database, but there are a few things you'll need to do if you
must migrate:
1. Create a configuration file as mentioned above.
2. Copy your database file from the original filebrowser to the path of
the new one.
3. Update the configuration file to use the database (under server in
filebrowser.yml)
4. If you are using docker, update the docker-compose file or docker run
command to use the config file as described in the install section
above.
5. If you are not using docker, just make sure you run filebrowser -c
filebrowser.yml and have a valid filebrowser config.
The filebrowser Quantum application should run with the same user and rules that
you have from the original. But keep in mind the differences that are
mentioned at the top of this readme.
## Migration from the original filebrowser
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 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
@@ -177,15 +114,15 @@ Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
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 |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 31 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 128 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
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 | ✅ | ✅ |
@@ -194,7 +131,8 @@ Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
2FA sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Long-live API key support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
@@ -203,18 +141,15 @@ 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](./roadmap.md)
Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
+32
View File
@@ -0,0 +1,32 @@
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/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
ENV FILEBROWSER_NO_EMBEDED="true"
RUN apk --no-cache add ca-certificates mailcap
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
COPY --from=base --chown=filebrowser:1000 /app/config.yaml ./
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
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
+10
View File
@@ -0,0 +1,10 @@
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 nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+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"] },
},
],
});
+17
View File
@@ -0,0 +1,17 @@
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"
@@ -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"] },
},
],
});
+35
View File
@@ -0,0 +1,35 @@
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
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://localhost: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"] },
},
],
});
});
+23 -6
View File
@@ -6,21 +6,20 @@ builds:
# Build configuration for darwin and linux
- id: default
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/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
goos:
- darwin
- linux
goarch:
- amd64
- arm
- arm64
goarm:
- 6
- 7
- "6"
- "7"
hooks:
post:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm
@@ -30,12 +29,30 @@ builds:
binary: filebrowser
goos:
- windows
goarch:
- amd64
hooks:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm & upx
- id: macos
ldflags: *ldflags
main: main.go
binary: filebrowser
goos:
- darwin
goarch:
- amd64
- arm64
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:
+2 -2
View File
@@ -3,13 +3,13 @@ package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// Auther is the authentication interface.
type Auther interface {
// Auth is called to authenticate a request.
Auth(r *http.Request, usr users.Store) (*users.User, error)
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
// LoginPage indicates if this auther needs a login page.
LoginPage() bool
}
+52 -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/errors"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type hookCred struct {
@@ -30,7 +30,7 @@ type HookAuth struct {
}
// Auth authenticates the user via a json in content body.
func (a *HookAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
func (a *HookAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
var cred hookCred
if r.Body == nil {
@@ -39,6 +39,7 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
err := json.NewDecoder(r.Body).Decode(&cred)
if err != nil {
logger.Error("decode body error")
return nil, os.ErrPermission
}
@@ -51,7 +52,6 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
if err != nil {
return nil, err
}
switch action {
case "auth":
u, err := a.SaveUser()
@@ -60,11 +60,19 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store) (*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:
@@ -139,7 +147,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
}
@@ -147,34 +155,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,
Perm: 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)
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, true)
if err != nil {
return nil, err
}
@@ -187,30 +194,22 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
func (a *HookAuth) GetUser(d *users.User) *users.User {
// adds all permissions when user is admin
isAdmin := d.Perm.Admin
perms := settings.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,
perms := users.Permissions{
Admin: isAdmin,
Modify: isAdmin || d.Perm.Modify,
Share: isAdmin || d.Perm.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,
ID: d.ID,
Username: d.Username,
Scopes: d.Scopes,
Perm: perms,
LockPassword: true,
}
@@ -232,16 +231,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
+13 -8
View File
@@ -2,13 +2,14 @@ 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/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type jsonCred struct {
@@ -23,15 +24,16 @@ type JSONAuth struct {
}
// Auth authenticates the user via a json in content body.
func (a JSONAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
config := &settings.Config
func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
var cred jsonCred
if r.Body == nil {
logger.Error("nil body error")
return nil, os.ErrPermission
}
err := json.NewDecoder(r.Body).Decode(&cred)
if err != nil {
logger.Error("decode body error")
return nil, os.ErrPermission
}
@@ -47,10 +49,13 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
return nil, os.ErrPermission
}
}
u, err := usr.Get(config.Server.Root, cred.Username)
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
return nil, os.ErrPermission
u, err := userStore.Get(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
+3 -4
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/users"
)
// MethodNoAuth is used to identify no auth.
@@ -14,8 +13,8 @@ const MethodNoAuth = "noauth"
type NoAuth struct{}
// Auth uses authenticates user 1.
func (a NoAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
return usr.Get(settings.Config.Server.Root, uint(1))
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return usr.Get(uint(1))
}
// LoginPage tells that no auth doesn't require a login page.
+4 -6
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/errors"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// MethodProxyAuth is used to identify no auth.
@@ -19,9 +17,9 @@ type ProxyAuth struct {
}
// Auth authenticates the user via an HTTP header.
func (a ProxyAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
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
}
+20 -3
View File
@@ -1,7 +1,7 @@
package auth
import (
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// StorageBackend is a storage backend for auth storage.
@@ -17,8 +17,25 @@ 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
}
return store, nil
}
// Get wraps a StorageBackend.Get.
+51 -26
View File
@@ -1,41 +1,66 @@
== 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]
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/diskcache 0.004s
? github.com/gtsteffaniak/filebrowser/errors [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/diskcache 0.005s
? github.com/gtsteffaniak/filebrowser/backend/errors [no test files]
/home/graham/git/filebrowser /home/graham/git/filebrowser
/home/graham/git/filebrowser/files/file.go /home/graham/git/filebrowser
/home/graham/git/filebrowser/mnt/doesnt/exist /home/graham/git/filebrowser
2025/01/04 14:04:55 Initializing index and assessing file system complexity
2025/01/04 14:04:55 Starting full scan
2025/01/04 14:04:55 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:55 Time Spent Indexing : 0 seconds
2025/01/04 14:04:55 Next scan in 1m0s
2025/01/04 14:04:56 Initializing index and assessing file system complexity
2025/01/04 14:04:56 Starting full scan
2025/01/04 14:04:56 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:56 Time Spent Indexing : 0 seconds
2025/01/04 14:04:56 Next scan in 1m0s
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/files
pkg: github.com/gtsteffaniak/filebrowser/backend/files
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 10 3559830 ns/op 274639 B/op 2026 allocs/op
BenchmarkSearchAllIndexes-8 10 31912612 ns/op 20545741 B/op 312477 allocs/op
BenchmarkFillIndex-8 2025/01/04 14:04:57 Initializing index and assessing file system complexity
2025/01/04 14:04:57 Starting full scan
2025/01/04 14:04:57 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:57 Time Spent Indexing : 0 seconds
2025/01/04 14:04:57 Next scan in 1m0s
10 3515090 ns/op 34273 B/op 451 allocs/op
BenchmarkCheckIndexExclude-8 10 156.2 ns/op 0 B/op 0 allocs/op
BenchmarkCheckIndexConditionsInclude-8 10 98.00 ns/op 0 B/op 0 allocs/op
2025/01/04 14:04:58 Initializing index and assessing file system complexity
2025/01/04 14:04:58 Starting full scan
2025/01/04 14:04:58 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:58 Time Spent Indexing : 0 seconds
2025/01/04 14:04:58 Next scan in 1m0s
BenchmarkSearchAllIndexes-8 2025/01/04 14:04:59 Initializing index and assessing file system complexity
2025/01/04 14:04:59 Starting full scan
2025/01/04 14:04:59 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:59 Time Spent Indexing : 0 seconds
2025/01/04 14:04:59 Next scan in 1m0s
10 766822 ns/op 34230 B/op 900 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/files 0.417s
ok github.com/gtsteffaniak/filebrowser/backend/files 5.094s
PASS
ok github.com/gtsteffaniak/filebrowser/fileutils 0.002s
2024/08/27 16:16:13 h: 401 <nil>
2024/08/27 16:16:13 h: 401 <nil>
2024/08/27 16:16:13 h: 401 <nil>
2024/08/27 16:16:13 h: 401 <nil>
2024/08/27 16:16:13 h: 401 <nil>
2024/08/27 16:16:13 h: 401 <nil>
ok github.com/gtsteffaniak/filebrowser/backend/fileutils 0.002s
PASS
ok github.com/gtsteffaniak/filebrowser/http 0.100s
ok github.com/gtsteffaniak/filebrowser/backend/http 0.184s
PASS
ok github.com/gtsteffaniak/filebrowser/img 0.124s
ok github.com/gtsteffaniak/filebrowser/backend/img 0.123s
PASS
ok github.com/gtsteffaniak/filebrowser/rules 0.002s
ok github.com/gtsteffaniak/filebrowser/backend/runner 0.004s
PASS
ok github.com/gtsteffaniak/filebrowser/runner 0.003s
ok github.com/gtsteffaniak/filebrowser/backend/settings 0.005s
? github.com/gtsteffaniak/filebrowser/backend/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/storage [no test files]
? github.com/gtsteffaniak/filebrowser/backend/storage/bolt [no test files]
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
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]
ok github.com/gtsteffaniak/filebrowser/backend/users 0.003s
PASS
ok github.com/gtsteffaniak/filebrowser/users 0.002s
? github.com/gtsteffaniak/filebrowser/version [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/utils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/version [no test files]
+87
View File
@@ -0,0 +1,87 @@
package cache
import (
"sync"
"time"
)
var (
DiskUsage = NewCache(30*time.Second, 24*time.Hour)
RealPath = NewCache(48*time.Hour, 72*time.Hour)
SearchResults = NewCache(15*time.Second, time.Hour)
OnlyOffice = NewCache(48*time.Hour, 1*time.Hour)
)
func NewCache(expires time.Duration, cleanup time.Duration) *KeyCache {
newCache := KeyCache{
data: make(map[string]cachedValue),
expiresAfter: expires, // default
}
go newCache.cleanupExpiredJob(cleanup)
return &newCache
}
type KeyCache struct {
data map[string]cachedValue
mu sync.RWMutex
expiresAfter time.Duration
}
type cachedValue struct {
value interface{}
expiresAt time.Time
}
func (c *KeyCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cachedValue{
value: value,
expiresAt: time.Now().Add(c.expiresAfter),
}
}
func (c *KeyCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
func (c *KeyCache) SetWithExp(key string, value interface{}, exp time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cachedValue{
value: value,
expiresAt: time.Now().Add(exp),
}
}
func (c *KeyCache) Get(key string) interface{} {
c.mu.RLock()
defer c.mu.RUnlock()
cached, ok := c.data[key]
if !ok || time.Now().After(cached.expiresAt) {
return nil
}
return cached.value
}
func (c *KeyCache) cleanupExpired() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, cached := range c.data {
if now.After(cached.expiresAt) {
delete(c.data, key)
}
}
}
// should automatically run for all cache types as part of init.
func (c *KeyCache) cleanupExpiredJob(frequency time.Duration) {
ticker := time.NewTicker(frequency)
defer ticker.Stop()
for range ticker.C {
c.cleanupExpired()
}
}
+127
View File
@@ -0,0 +1,127 @@
package cmd
import (
"flag"
"fmt"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
var (
configPath string
)
// return bool to indicate if the program should continue running
func runCLI() bool {
// 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 "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,
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.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
} else {
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
logger.Error(fmt.Sprintf("could not create user: %v", err))
}
return false
}
user.Password = password
if asAdmin {
user.Perm.Admin = true
}
err = store.Users.Save(user, true)
if err != nil {
logger.Error(fmt.Sprintf("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
}
-4
View File
@@ -1,4 +0,0 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore
+114 -161
View File
@@ -1,185 +1,138 @@
package cmd
import (
"crypto/tls"
"flag"
"context"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"embed"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
"github.com/swaggo/swag"
"github.com/spf13/pflag"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/auth"
"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/users"
"github.com/gtsteffaniak/filebrowser/version"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
//go:embed dist/*
var assets embed.FS
var store *storage.Storage
var nonEmbededFS = os.Getenv("FILEBROWSER_NO_EMBEDED") == "true"
type dirFS struct {
http.Dir
func getStore(configFile string) bool {
// Use the config file (global flag)
settings.Initialize(configFile)
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
logger.Fatal(fmt.Sprintf("could not load db info: %v", err))
}
store = s
return hasDB
}
func (d dirFS) Open(name string) (fs.File, error) {
return d.Dir.Open(name)
}
func init() {
// Define a flag for the config option (-c or --config)
configFlag := pflag.StringP("config", "c", "filebrowser.yaml", "Path to the config file")
// Bind the flags to the pflag command line parser
pflag.CommandLine.AddGoFlagSet(flag.CommandLine)
pflag.Parse()
log.Printf("Initializing FileBrowser Quantum (%v) with config file: %v \n", version.Version, *configFlag)
log.Println("Embeded Frontend:", !nonEmbededFS)
settings.Initialize(*configFlag)
}
var rootCmd = &cobra.Command{
Use: "filebrowser",
Run: python(func(cmd *cobra.Command, args []string, d pythonData) {
serverConfig := settings.Config.Server
if !d.hadDB {
quickSetup(d)
}
if serverConfig.NumImageProcessors < 1 {
log.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()
}
// initialize indexing and schedule indexing ever n minutes (default 5)
go files.InitializeIndex(serverConfig.IndexingInterval, serverConfig.Indexing)
_, err := os.Stat(serverConfig.Root)
checkErr(fmt.Sprint("cmd os.Stat ", serverConfig.Root), err)
var listener net.Listener
address := serverConfig.Address + ":" + strconv.Itoa(serverConfig.Port)
switch {
case serverConfig.Socket != "":
listener, err = net.Listen("unix", serverConfig.Socket)
checkErr("net.Listen", err)
socketPerm, err := cmd.Flags().GetUint32("socket-perm") //nolint:govet
checkErr("cmd.Flags().GetUint32", err)
err = os.Chmod(serverConfig.Socket, os.FileMode(socketPerm))
checkErr("os.Chmod", err)
case serverConfig.TLSKey != "" && serverConfig.TLSCert != "":
cer, err := tls.LoadX509KeyPair(serverConfig.TLSCert, serverConfig.TLSKey) //nolint:govet
checkErr("tls.LoadX509KeyPair", err)
listener, err = tls.Listen("tcp", address, &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cer}},
)
checkErr("tls.Listen", err)
default:
listener, err = net.Listen("tcp", address)
checkErr("net.Listen", err)
}
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
go cleanupHandler(listener, sigc)
if !nonEmbededFS {
assetsFs, err := fs.Sub(assets, "dist")
if err != nil {
log.Fatal("Could not embed frontend. Does backend/cmd/dist exist? Must be built and exist first")
}
handler, err := fbhttp.NewHandler(imgSvc, fileCache, d.store, &serverConfig, assetsFs)
checkErr("fbhttp.NewHandler", err)
defer listener.Close()
log.Println("Listening on", listener.Addr().String())
//nolint: gosec
if err := http.Serve(listener, handler); err != nil {
log.Fatalf("Could not start server on port %d: %v", serverConfig.Port, err)
}
} else {
assetsFs := dirFS{Dir: http.Dir("frontend/dist")}
handler, err := fbhttp.NewHandler(imgSvc, fileCache, d.store, &serverConfig, assetsFs)
checkErr("fbhttp.NewHandler", err)
defer listener.Close()
log.Println("Listening on", listener.Addr().String())
//nolint: gosec
if err := http.Serve(listener, handler); err != nil {
log.Fatalf("Could not start server on port %d: %v", serverConfig.Port, err)
}
}
}, pythonConfig{allowNoDB: true}),
func generalUsage() {
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() {
if err := rootCmd.Execute(); err != nil {
log.Fatal("Error starting filebrowser:", err)
keepGoing := runCLI()
if !keepGoing {
return
}
// Create context and channels for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
dbExists := getStore(configPath)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
sourceList := []string{}
for path, source := range settings.Config.Server.SourceMap {
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
}
logger.Info(fmt.Sprintf("Initializing FileBrowser Quantum (%v)", version.Version))
logger.Info(fmt.Sprintf("Using Config file : %v", configPath))
logger.Info(fmt.Sprintf("Auth Methods : %v", settings.Config.Auth.AuthMethods))
logger.Info(database)
logger.Info(fmt.Sprintf("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)
if len(settings.Config.Server.SourceMap) == 0 {
logger.Fatal("No sources configured, exiting...")
}
for _, source := range settings.Config.Server.SourceMap {
go files.Initialize(source)
}
validateUserInfo()
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
logger.Fatal(fmt.Sprintf("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 cleanupHandler(listener net.Listener, c chan os.Signal) { //nolint:interfacer
sig := <-c
log.Printf("Caught signal %s: shutting down.", sig)
listener.Close()
os.Exit(0)
}
func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
if serverConfig.NumImageProcessors < 1 {
logger.Fatal("Image resize workers count could not be < 1")
}
imgSvc := img.New(serverConfig.NumImageProcessors)
func quickSetup(d pythonData) {
settings.Config.Auth.Key = generateKey()
if settings.Config.Auth.Method == "noauth" {
err := d.store.Auth.Save(&auth.NoAuth{})
checkErr("d.store.Auth.Save", err)
cacheDir := settings.Config.Server.CacheDir
var fileCache diskcache.Interface
// Use file cache if cacheDir is specified
if cacheDir != "" {
var err error
fileCache, err = diskcache.NewFileCache(cacheDir)
if err != nil {
if cacheDir == "tmp" {
logger.Error("The cache dir could not be created. Make sure the user that you executed the program with has access to create directories in the local path. filebrowser is trying to use the default `server.cacheDir: tmp` , but you can change this location if you need to. Please see configuration wiki for more information about this error. https://github.com/gtsteffaniak/filebrowser/wiki/Configuration")
}
logger.Fatal(fmt.Sprintf("failed to create file cache path, which is now require to run the server: %v", err))
}
} else {
settings.Config.Auth.Method = "password"
err := d.store.Auth.Save(&auth.JSONAuth{})
checkErr("d.store.Auth.Save", err)
// No-op cache if no cacheDir is specified
fileCache = diskcache.NewNoOp()
}
err := d.store.Settings.Save(&settings.Config)
checkErr("d.store.Settings.Save", err)
err = d.store.Settings.SaveServer(&settings.Config.Server)
checkErr("d.store.Settings.SaveServer", err)
user := users.ApplyDefaults(users.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.LockPassword = false
user.Perm = settings.Permissions{
Create: true,
Rename: true,
Modify: true,
Delete: true,
Share: true,
Download: true,
Admin: true,
}
err = d.store.Users.Save(&user)
checkErr("d.store.Users.Save", err)
fbhttp.StartHttp(ctx, imgSvc, store, fileCache, shutdownComplete)
return nil
}
-66
View File
@@ -1,66 +0,0 @@
package cmd
import (
"strconv"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
)
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: python(func(cmd *cobra.Command, args []string, d pythonData) {
i, err := strconv.Atoi(args[0])
checkErr("strconv.Atoi", err)
f := i
if len(args) == 2 { //nolint:gomnd
f, err = strconv.Atoi(args[1])
checkErr("strconv.Atoi", err)
}
user := func(u *users.User) {
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
err := d.store.Users.Save(u)
checkErr("d.store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
err := d.store.Settings.Save(s)
checkErr("d.store.Settings.Save", err)
}
runRules(d.store, cmd, user, global)
}, pythonConfig{}),
}
-87
View File
@@ -1,87 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/rules"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
)
func init() {
rootCmd.AddCommand(rulesCmd)
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)
checkErr("st.Users.Get", err)
if usersFn != nil {
usersFn(user)
}
printRules(user.Rules, id)
return
}
s, err := st.Settings.Get()
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 []rules.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)
}
}
}
}
-58
View File
@@ -1,58 +0,0 @@
package cmd
import (
"regexp"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/rules"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
)
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: python(func(cmd *cobra.Command, args []string, d pythonData) {
allow := mustGetBool(cmd.Flags(), "allow")
regex := mustGetBool(cmd.Flags(), "regex")
exp := args[0]
if regex {
regexp.MustCompile(exp)
}
rule := rules.Rule{
Allow: allow,
Regex: regex,
}
if regex {
rule.Regexp = &rules.Regexp{Raw: exp}
} else {
rule.Path = exp
}
user := func(u *users.User) {
u.Rules = append(u.Rules, rule)
err := d.store.Users.Save(u)
checkErr("d.store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules, rule)
err := d.store.Settings.Save(s)
checkErr("d.store.Settings.Save", err)
}
runRules(d.store, cmd, user, global)
}, pythonConfig{}),
}
-19
View File
@@ -1,19 +0,0 @@
package cmd
import (
"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: python(func(cmd *cobra.Command, args []string, d pythonData) {
runRules(d.store, cmd, nil, nil)
}, pythonConfig{}),
}
+83
View File
@@ -0,0 +1,83 @@
package cmd
import (
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
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.Fatal(fmt.Sprintf("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 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.Fatal(fmt.Sprintf("Unable to create automatic backup of database due to error: %v", err))
}
}
err := store.Users.Save(user, false)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
}
}
}
}
func updateUserScopes(user *users.User) bool {
updateUser := false
newScopes := []users.SourceScope{}
for _, source := range settings.Config.Server.SourceMap {
scopePath, err := settings.GetScopeFromSourcePath(user.Scopes, source.Path)
// apply default scope if it doesn't exist
if !user.Perm.Admin && scopePath == "" {
scopePath = source.Config.DefaultUserScope
}
if scopePath == "" {
scopePath = "/"
}
if source.Config.CreateUserDir && !user.Perm.Admin {
scopePath = fmt.Sprintf("%s%s", scopePath, users.CleanUsername(user.Username))
}
// if user doesn't yet have scope for source, add it for admins and default sources
if err != nil {
if user.Perm.Admin || source.Config.DefaultEnabled {
newScopes = append(newScopes, users.SourceScope{Scope: scopePath, Name: source.Path}) // backend name is path
updateUser = true
}
} else {
newScopes = append(newScopes, users.SourceScope{Scope: scopePath, Name: source.Path}) // backend name is path
}
}
// maintain backwards compatibility, update user scope from scopes
if len(newScopes) == 0 {
newScopes = []users.SourceScope{
{
Scope: settings.Config.Server.DefaultSource.Config.DefaultUserScope,
Name: settings.Config.Server.DefaultSource.Path, // backend name is path
},
}
updateUser = true
}
user.Scopes = newScopes
return updateUser
}
-58
View File
@@ -1,58 +0,0 @@
package cmd
import (
"fmt"
"os"
"strconv"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
)
func init() {
rootCmd.AddCommand(usersCmd)
}
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)
}
-40
View File
@@ -1,40 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
)
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: python(func(cmd *cobra.Command, args []string, d pythonData) {
user := &users.User{
Username: args[0],
Password: args[1],
LockPassword: mustGetBool(cmd.Flags(), "lockPassword"),
}
servSettings, err := d.store.Settings.GetServer()
checkErr("d.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 := d.store.Settings.Get()
checkErr("d.store.Settings.Get()", err)
userHome, err := s2.MakeUserDir(user.Username, user.Scope, servSettings.Root)
checkErr("s2.MakeUserDir", err)
user.Scope = userHome
err = d.store.Users.Save(user)
checkErr("d.store.Users.Save", err)
printUsers([]*users.User{user})
}, pythonConfig{}),
}
-24
View File
@@ -1,24 +0,0 @@
package cmd
import (
"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: python(func(cmd *cobra.Command, args []string, d pythonData) {
list, err := d.store.Users.Gets("")
checkErr("d.store.Users.Gets", err)
err = marshal(args[0], list)
checkErr("marshal", err)
}, pythonConfig{}),
}
-51
View File
@@ -1,51 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
)
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 = python(func(cmd *cobra.Command, args []string, d pythonData) {
var (
list []*users.User
user *users.User
err error
)
if len(args) == 1 {
username, id := parseUsernameOrID(args[0])
if username != "" {
user, err = d.store.Users.Get("", username)
} else {
user, err = d.store.Users.Get("", id)
}
list = []*users.User{user}
} else {
list, err = d.store.Users.Gets("")
}
checkErr("findUsers", err)
printUsers(list)
}, pythonConfig{})
-86
View File
@@ -1,86 +0,0 @@
package cmd
import (
"errors"
"fmt"
"os"
"strconv"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
)
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: python(func(cmd *cobra.Command, args []string, d pythonData) {
fd, err := os.Open(args[0])
checkErr("os.Open", err)
defer fd.Close()
list := []*users.User{}
err = unmarshal(args[0], &list)
checkErr("unmarshal", err)
if mustGetBool(cmd.Flags(), "replace") {
oldUsers, err := d.store.Users.Gets("")
checkErr("d.store.Users.Gets", err)
err = marshal("users.backup.json", list)
checkErr("marshal users.backup.json", err)
for _, user := range oldUsers {
err = d.store.Users.Delete(user.ID)
checkErr("d.store.Users.Delete", err)
}
}
overwrite := mustGetBool(cmd.Flags(), "overwrite")
for _, user := range list {
onDB, err := d.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")
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 := d.store.Users.Get("", user.Username); err == nil { //nolint:govet
newErr := usernameConflictError(user.Username, conflictuous.ID, user.ID)
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 = d.store.Users.Save(user)
checkErr("d.store.Users.Save", err)
}
}, pythonConfig{}),
}
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)
}
-31
View File
@@ -1,31 +0,0 @@
package cmd
import (
"log"
"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: python(func(cmd *cobra.Command, args []string, d pythonData) {
username, id := parseUsernameOrID(args[0])
var err error
if username != "" {
err = d.store.Users.Delete(username)
} else {
err = d.store.Users.Delete(id)
}
checkErr("usersRmCmd", err)
log.Println("user deleted successfully")
}, pythonConfig{}),
}
-38
View File
@@ -1,38 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
)
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: python(func(cmd *cobra.Command, args []string, d pythonData) {
username, id := parseUsernameOrID(args[0])
var (
err error
user *users.User
)
if id != 0 {
user, err = d.store.Users.Get("", id)
} else {
user, err = d.store.Users.Get("", username)
}
checkErr("d.store.Users.Get", err)
err = d.store.Users.Update(user)
checkErr("d.store.Users.Update", err)
printUsers([]*users.User{user})
}, pythonConfig{}),
}
-154
View File
@@ -1,154 +0,0 @@
package cmd
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"github.com/asdine/storm/v3"
"github.com/goccy/go-yaml"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/storage/bolt"
)
func checkErr(source string, err error) {
if err != nil {
log.Fatalf("%s: %v", source, err)
}
}
func mustGetString(flags *pflag.FlagSet, flag string) string {
s, err := flags.GetString(flag)
checkErr("mustGetString", err)
return s
}
func mustGetBool(flags *pflag.FlagSet, flag string) bool {
b, err := flags.GetBool(flag)
checkErr("mustGetBool", err)
return b
}
func mustGetUint(flags *pflag.FlagSet, flag string) uint {
b, err := flags.GetUint(flag)
checkErr("mustGetUint", err)
return b
}
func generateKey() []byte {
k, err := settings.GenerateKey()
checkErr("generateKey", err)
return k
}
type cobraFunc func(cmd *cobra.Command, args []string)
type pythonFunc func(cmd *cobra.Command, args []string, data pythonData)
type pythonConfig struct {
noDB bool
allowNoDB bool
}
type pythonData struct {
hadDB bool
store *storage.Storage
}
func dbExists(path string) (bool, error) {
stat, err := os.Stat(path)
if err == nil {
return stat.Size() != 0, nil
}
if os.IsNotExist(err) {
d := filepath.Dir(path)
_, err = os.Stat(d)
if os.IsNotExist(err) {
if err := os.MkdirAll(d, 0700); err != nil { //nolint:govet,gomnd
return false, err
}
return false, nil
}
}
return false, err
}
func python(fn pythonFunc, cfg pythonConfig) cobraFunc {
return func(cmd *cobra.Command, args []string) {
data := pythonData{hadDB: true}
path := settings.Config.Server.Database
exists, err := dbExists(path)
if err != nil {
panic(err)
} else if exists && cfg.noDB {
log.Fatal(path + " already exists")
} else if !exists && !cfg.noDB && !cfg.allowNoDB {
log.Fatal(path + " does not exist. Please run 'filebrowser config init' first.")
}
data.hadDB = exists
db, err := storm.Open(path)
checkErr(fmt.Sprintf("storm.Open path %v", path), err)
defer db.Close()
data.store, err = bolt.NewStorage(db)
checkErr("bolt.NewStorage", err)
fn(cmd, args, data)
}
}
func marshal(filename string, data interface{}) error {
fd, err := os.Create(filename)
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)
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)
}
}
-21
View File
@@ -1,21 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/version"
)
func init() {
rootCmd.AddCommand(versionCmd)
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version number",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("File Browser " + version.Version + "/" + version.CommitSHA)
},
}
@@ -1,21 +1,16 @@
server:
port: 80
baseURL: "/"
root: "./srv"
auth:
method: password
signup: false
logging:
- levels: "info|debug|warning|error"
sources:
- path: "/srv"
userDefaults:
darkMode: true
disableSettings: false
scope: "."
hideDotfiles: true
singleClick: false
permissions:
admin: false
create: false
rename: false
modify: false
delete: false
share: false
download: false
api: false
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
// +build !windows
package files
func checkWindowsHidden(realpath string) bool {
// Non-Windows platforms don't support hidden attributes in the same way
return false
}
+33
View File
@@ -0,0 +1,33 @@
//go:build windows
// +build windows
package files
import (
"golang.org/x/sys/windows"
)
func checkWindowsHidden(realpath string) bool {
// Convert the realpath to a UTF-16 pointer
pointer, err := windows.UTF16PtrFromString(realpath)
if err != nil {
return false
}
// Get the file attributes
attributes, err := windows.GetFileAttributes(pointer)
if err != nil {
return false
}
// Check if the hidden attribute is set
if attributes&windows.FILE_ATTRIBUTE_HIDDEN != 0 {
return true
}
// Optional: Check for system attribute
if attributes&windows.FILE_ATTRIBUTE_SYSTEM != 0 {
return true
}
return false
}
+115 -18
View File
@@ -2,6 +2,7 @@ package files
import (
"mime"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -14,24 +15,96 @@ var AllFiletypeOptions = []string{
"archive",
"video",
"doc",
"dir",
"text",
}
// Document file extensions
var documentTypes = []string{
".word",
".pdf",
".doc",
".docx",
}
var textTypes = []string{
".text",
".sh",
".yaml",
".yml",
".json",
".env",
// Common Document Formats
".doc", ".docx", // Microsoft Word
".pdf", // Portable Document Format
".odt", // OpenDocument Text
".rtf", // Rich Text Format
".conf",
".bash_history",
".gitignore",
".htpasswd",
".profile",
".dockerignore",
".editorconfig",
// Presentation Formats
".ppt", ".pptx", // Microsoft PowerPoint
".odp", // OpenDocument Presentation
// google docs
".gdoc",
// google sheet
".gsheet",
// Spreadsheet Formats
".xls", ".xlsx", // Microsoft Excel
".ods", // OpenDocument Spreadsheet
// Other Document Formats
".epub", // Electronic Publication
".mobi", // Amazon Kindle
".fb2", // FictionBook
}
var onlyOfficeSupported = []string{
".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".epub",
".fb2", ".fodt", ".htm", ".html", ".mht", ".mhtml", ".odt",
".ott", ".rtf", ".stw", ".sxw", ".txt", ".wps", ".wpt", ".xml",
".csv", ".et", ".ett", ".fods", ".ods", ".ots", ".sxc", ".xls",
".xlsb", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx", ".dps",
".dpt", ".fodp", ".odp", ".otp", ".pot", ".potm", ".potx",
".pps", ".ppsm", ".ppsx", ".ppt", ".pptm", ".pptx", ".sxi",
".djvu", ".docxf", ".oform", ".oxps", ".pdf", ".xps",
}
// Text-based file extensions
var textTypes = []string{
// Common Text Formats
".txt",
".md", // Markdown
// Scripting and Programming Languages
".sh", // Bash script
".py", // Python
".js", // JavaScript
".ts", // TypeScript
".php", // PHP
".rb", // Ruby
".go", // Go
".java", // Java
".c", ".cpp", // C/C++
".cs", // C#
".swift", // Swift
// Configuration Files
".yaml", ".yml", // YAML
".json", // JSON
".xml", // XML
".ini", // INI
".toml", // TOML
".cfg", // Configuration file
// Other Text-Based Formats
".css", // Cascading Style Sheets
".html", ".htm", // HyperText Markup Language
".sql", // SQL
".csv", // Comma-Separated Values
".tsv", // Tab-Separated Values
".log", // Log file
".bat", // Batch file
".ps1", // PowerShell script
".tex", // LaTeX
".bib", // BibTeX
}
// Compressed file extensions
var compressedFile = []string{
".7z",
".rar",
@@ -39,6 +112,12 @@ var compressedFile = []string{
".tar",
".gz",
".xz",
".bz2",
".tgz", // tar.gz
".tbz2", // tar.bz2
".lzma",
".lz4",
".zstd",
}
type SearchOptions struct {
@@ -48,8 +127,18 @@ type SearchOptions struct {
Terms []string
}
func ParseSearch(value string) *SearchOptions {
opts := &SearchOptions{
func extendedMimeTypeCheck(extension string) string {
if isDoc(extension) {
return "application/document"
}
if isText(extension) {
return "text/plain"
}
return "blob"
}
func ParseSearch(value string) SearchOptions {
opts := SearchOptions{
Conditions: map[string]bool{
"exact": strings.Contains(value, "case:exact"),
},
@@ -91,7 +180,7 @@ func ParseSearch(value string) *SearchOptions {
opts.LargerThan = updateSize(size)
}
if strings.HasPrefix(filter, "smallerThan=") {
opts.Conditions["larger"] = true
opts.Conditions["smaller"] = true
size := strings.TrimPrefix(filter, "smallerThan=")
opts.SmallerThan = updateSize(size)
}
@@ -145,8 +234,6 @@ func IsMatchingType(extension string, matchType string) bool {
switch matchType {
case "doc":
return isDoc(extension)
case "pdf":
return extension == ".pdf"
case "text":
return isText(extension)
case "archive":
@@ -181,3 +268,13 @@ func isArchive(extension string) bool {
}
return false
}
func isOnlyOffice(name string) bool {
extention := filepath.Ext(name)
for _, typefile := range onlyOfficeSupported {
if extention == typefile {
return true
}
}
return false
}
+154
View File
@@ -0,0 +1,154 @@
package files
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
// Helper function to create error messages dynamically
func errorMsg(extension, expectedType string, expectedMatch bool) string {
matchStatus := "to match"
if !expectedMatch {
matchStatus = "to not match"
}
return fmt.Sprintf("Expected %s %s type '%s'", extension, matchStatus, expectedType)
}
func TestIsMatchingType(t *testing.T) {
// Test cases where IsMatchingType should return true
trueTestCases := []struct {
extension string
expectedType string
}{
{".pdf", "doc"},
{".doc", "doc"},
{".docx", "doc"},
{".json", "text"},
{".sh", "text"},
{".zip", "archive"},
{".rar", "archive"},
}
for _, tc := range trueTestCases {
assert.True(t, IsMatchingType(tc.extension, tc.expectedType), errorMsg(tc.extension, tc.expectedType, true))
}
// Test cases where IsMatchingType should return false
falseTestCases := []struct {
extension string
expectedType string
}{
{".mp4", "doc"},
{".mp4", "text"},
{".mp4", "archive"},
}
for _, tc := range falseTestCases {
assert.False(t, IsMatchingType(tc.extension, tc.expectedType), errorMsg(tc.extension, tc.expectedType, false))
}
}
func TestUpdateSize(t *testing.T) {
// Helper function for size error messages
sizeErrorMsg := func(input string, expected, actual int) string {
return fmt.Sprintf("Expected size for input '%s' to be %d, got %d", input, expected, actual)
}
// Test cases for updateSize
testCases := []struct {
input string
expected int
}{
{"150", 150},
{"invalid", 100},
{"", 100},
}
for _, tc := range testCases {
actual := updateSize(tc.input)
assert.Equal(t, tc.expected, actual, sizeErrorMsg(tc.input, tc.expected, actual))
}
}
func TestIsDoc(t *testing.T) {
// Test cases where IsMatchingType should return true for document types
docTrueTestCases := []struct {
extension string
expectedType string
}{
{".doc", "doc"},
{".pdf", "doc"},
}
for _, tc := range docTrueTestCases {
assert.True(t, IsMatchingType(tc.extension, tc.expectedType), errorMsg(tc.extension, tc.expectedType, true))
}
// Test case where IsMatchingType should return false for document types
docFalseTestCases := []struct {
extension string
expectedType string
}{
{".mp4", "doc"},
}
for _, tc := range docFalseTestCases {
assert.False(t, IsMatchingType(tc.extension, tc.expectedType), errorMsg(tc.extension, tc.expectedType, false))
}
}
func TestIsText(t *testing.T) {
// Test cases where IsMatchingType should return true for text types
textTrueTestCases := []struct {
extension string
expectedType string
}{
{".json", "text"},
{".sh", "text"},
}
for _, tc := range textTrueTestCases {
assert.True(t, IsMatchingType(tc.extension, tc.expectedType), errorMsg(tc.extension, tc.expectedType, true))
}
// Test case where IsMatchingType should return false for text types
textFalseTestCases := []struct {
extension string
expectedType string
}{
{".mp4", "text"},
}
for _, tc := range textFalseTestCases {
assert.False(t, IsMatchingType(tc.extension, tc.expectedType), errorMsg(tc.extension, tc.expectedType, false))
}
}
func TestIsArchive(t *testing.T) {
// Test cases where IsMatchingType should return true for archive types
archiveTrueTestCases := []struct {
extension string
expectedType string
}{
{".zip", "archive"},
{".rar", "archive"},
}
for _, tc := range archiveTrueTestCases {
assert.True(t, IsMatchingType(tc.extension, tc.expectedType), errorMsg(tc.extension, tc.expectedType, true))
}
// Test case where IsMatchingType should return false for archive types
archiveFalseTestCases := []struct {
extension string
expectedType string
}{
{".mp4", "archive"},
}
for _, tc := range archiveFalseTestCases {
assert.False(t, IsMatchingType(tc.extension, tc.expectedType), errorMsg(tc.extension, tc.expectedType, false))
}
}
+303 -426
View File
@@ -11,203 +11,151 @@ import (
"io"
"mime"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/rules"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
var (
bytesInMegabyte int64 = 1000000
pathMutexes = make(map[string]*sync.Mutex)
pathMutexesMu sync.Mutex // Mutex to protect the pathMutexes map
)
type ItemInfo struct {
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
}
// FileInfo describes a file.
// reduced item is non-recursive reduced "Items", used to pass flat items array
type FileInfo struct {
*Listing
Path string `json:"path,omitempty"`
Name string `json:"name"`
Size int64 `json:"size"`
Extension string `json:"-"`
ModTime time.Time `json:"modified"`
CacheTime time.Time `json:"-"`
Mode os.FileMode `json:"-"`
IsDir bool `json:"isDir,omitempty"`
IsSymlink bool `json:"isSymlink,omitempty"`
Type string `json:"type"`
Subtitles []string `json:"subtitles,omitempty"`
Content string `json:"content,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
Token string `json:"token,omitempty"`
ItemInfo
Files []ItemInfo `json:"files"` // files in the directory
Folders []ItemInfo `json:"folders"` // folders in the directory
Path string `json:"path"` // path scoped to the associated index
}
// for efficiency, a response will be a pointer to the data
// extra calculated fields can be added here
type ExtendedFileInfo struct {
FileInfo
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []string `json:"subtitles,omitempty"` // subtitles for video files
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source"` // associated index source for the file
RealPath string `json:"-"`
}
// FileOptions are the options when getting a file info.
type FileOptions struct {
Path string // realpath
Source string
IsDir bool
Modify bool
Expand bool
ReadHeader bool
Token string
Checker rules.Checker
Content bool
}
// Sorting constants
const (
SortingByName = "name"
SortingBySize = "size"
SortingByModified = "modified"
)
// Listing is a collection of files.
type Listing struct {
Items []*FileInfo `json:"items"`
Path string `json:"path"`
NumDirs int `json:"numDirs"`
NumFiles int `json:"numFiles"`
Sorting users.Sorting `json:"sorting"`
func (f FileOptions) Components() (string, string) {
return filepath.Dir(f.Path), filepath.Base(f.Path)
}
// NewFileInfo creates a File object from a path and a given user. This File
// object will be automatically filled depending on if it is a directory
// or a file. If it's a video file, it will also detect any subtitles.
func NewFileInfo(opts FileOptions) (*FileInfo, error) {
if !opts.Checker.Check(opts.Path) {
return nil, os.ErrPermission
func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
response := ExtendedFileInfo{}
if opts.Source == "" {
opts.Source = settings.Config.Server.DefaultSource.Name
}
file, err := stat(opts)
index := 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 nil, err
return response, err
}
if opts.Expand {
if file.IsDir {
if err = file.readListing(opts.Path, opts.Checker, opts.ReadHeader); err != nil {
return nil, err
}
return file, nil
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 && strings.HasPrefix(info.Type, "text") {
// Check file size
if info.Size > 50*1024*1024 { // 50 megabytes in bytes
logger.Debug(fmt.Sprintf("Reading large text file contents: "+info.Path, info.Name))
}
err = file.detectType(opts.Path, opts.Modify, opts.Content, true)
content, err := getContent(realPath)
if err != nil {
return nil, err
return response, err
}
response.Content = content
}
return file, err
}
func FileInfoFaster(opts FileOptions) (*FileInfo, error) {
// Lock access for the specific path
pathMutex := getMutex(opts.Path)
pathMutex.Lock()
defer pathMutex.Unlock()
if !opts.Checker.Check(opts.Path) {
return nil, os.ErrPermission
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && isOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
index := GetIndex(rootPath)
adjustedPath := index.makeIndexPath(opts.Path, opts.IsDir)
if opts.IsDir {
info, exists := index.GetMetadataInfo(adjustedPath)
if exists && !opts.Content {
// Let's not refresh if less than a second has passed
if time.Since(info.CacheTime) > time.Second {
go RefreshFileInfo(opts) //nolint:errcheck
}
// refresh cache after
return &info, nil
}
if strings.HasPrefix(info.Type, "video") {
response.detectSubtitles(realPath)
}
// don't bother caching content
if opts.Content {
file, err := NewFileInfo(opts)
return file, err
}
err := RefreshFileInfo(opts)
if err != nil {
file, err := NewFileInfo(opts)
return file, err
}
info, exists := index.GetMetadataInfo(adjustedPath)
if !exists || info.Name == "" {
return &FileInfo{}, errors.ErrEmptyKey
}
return &info, nil
return response, nil
}
func RefreshFileInfo(opts FileOptions) error {
if !opts.Checker.Check(opts.Path) {
return fmt.Errorf("permission denied: %s", opts.Path)
func generateOfficeId(realPath string) string {
key, ok := cache.OnlyOffice.Get(realPath).(string)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
cache.OnlyOffice.Set(realPath, documentKey)
return documentKey
}
index := GetIndex(rootPath)
adjustedPath := index.makeIndexPath(opts.Path, opts.IsDir)
file, err := stat(opts)
if err != nil {
return fmt.Errorf("File/folder does not exist to refresh data: %s", opts.Path)
}
_ = file.detectType(opts.Path, true, opts.Content, opts.ReadHeader)
if file.IsDir {
err := file.readListing(opts.Path, opts.Checker, opts.ReadHeader)
if err != nil {
return fmt.Errorf("Dir info could not be read: %s", opts.Path)
}
}
result := index.UpdateFileMetadata(adjustedPath, *file)
if !result {
return fmt.Errorf("File/folder does not exist in metadata: %s", adjustedPath)
}
return nil
}
func stat(opts FileOptions) (*FileInfo, error) {
info, err := os.Lstat(opts.Path)
if err != nil {
return nil, err
}
file := &FileInfo{
Path: opts.Path,
Name: info.Name(),
ModTime: info.ModTime(),
Mode: info.Mode(),
Size: info.Size(),
Extension: filepath.Ext(info.Name()),
Token: opts.Token,
}
if info.IsDir() {
file.IsDir = true
}
if info.Mode()&os.ModeSymlink != 0 {
file.IsSymlink = true
targetInfo, err := os.Stat(opts.Path)
if err == nil {
file.Size = targetInfo.Size()
file.IsDir = targetInfo.IsDir()
}
}
return file, nil
return key
}
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func (i *FileInfo) Checksum(algo string) error {
if i.IsDir {
return errors.ErrIsDirectory
}
if i.Checksums == nil {
i.Checksums = map[string]string{}
}
reader, err := os.Open(i.Path)
func GetChecksum(fullPath, algo string) (map[string]string, error) {
subs := map[string]string{}
reader, err := os.Open(fullPath)
if err != nil {
return err
return subs, err
}
defer reader.Close()
@@ -220,66 +168,114 @@ func (i *FileInfo) Checksum(algo string) error {
h, ok := hashFuncs[algo]
if !ok {
return errors.ErrInvalidOption
return subs, errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return err
return subs, err
}
i.Checksums[algo] = hex.EncodeToString(h.Sum(nil))
return nil
subs[algo] = hex.EncodeToString(h.Sum(nil))
return subs, nil
}
// RealPath gets the real path for the file, resolving symlinks if supported.
func (i *FileInfo) RealPath() string {
realPath, err := filepath.EvalSymlinks(i.Path)
if err == nil {
return realPath
}
return i.Path
}
func GetRealPath(relativePath ...string) (string, bool, error) {
combined := []string{settings.Config.Server.Root}
for _, path := range relativePath {
combined = append(combined, strings.TrimPrefix(path, settings.Config.Server.Root))
}
joinedPath := filepath.Join(combined...)
// Convert relative path to absolute path
absolutePath, err := filepath.Abs(joinedPath)
if err != nil {
return "", false, err
}
if !Exists(absolutePath) {
return absolutePath, false, nil // return without error
}
// Resolve symlinks and get the real path
return resolveSymlinks(absolutePath)
}
func DeleteFiles(absPath string, opts FileOptions) error {
func DeleteFiles(source, absPath string, absDirPath string) error {
err := os.RemoveAll(absPath)
if err != nil {
return err
}
opts.Path = filepath.Dir(absPath)
err = RefreshFileInfo(opts)
index := GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
refreshConfig := FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
return err
}
return nil
}
func WriteDirectory(opts FileOptions) error {
// Ensure the parent directories exist
err := os.MkdirAll(opts.Path, 0775)
func MoveResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
opts.Path = filepath.Dir(opts.Path)
err = RefreshFileInfo(opts)
idxSrc := GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := 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(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 := 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 := GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := 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 := GetIndex(sourceIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshConfig := 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 FileOptions) error {
idx := 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
}
@@ -287,13 +283,14 @@ func WriteDirectory(opts FileOptions) error {
}
func WriteFile(opts FileOptions, in io.Reader) error {
dst := opts.Path
idx := 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)
// Split the directory from the destination path
dir := filepath.Dir(dst)
// Create the directory and all necessary parents
err := os.MkdirAll(dir, 0775)
err := os.MkdirAll(parentDir, 0775)
if err != nil {
return err
}
@@ -310,246 +307,112 @@ func WriteFile(opts FileOptions, in io.Reader) error {
if err != nil {
return err
}
opts.Path = parentDir
err = RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
return nil
opts.Path = idx.MakeIndexPath(parentDir)
opts.IsDir = true
return idx.RefreshFileInfo(opts)
}
// resolveSymlinks resolves symlinks in the given path
func resolveSymlinks(path string) (string, bool, error) {
for {
// Get the file info
const maxSymlinks = 25
for i := 0; i < maxSymlinks; i++ {
info, err := os.Lstat(path)
if err != nil {
return "", false, err
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return "", false, err
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the target relative to the symlink's directory
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, so return the resolved path and check if it's a directory
return path, info.IsDir(), nil
path = filepath.Clean(filepath.Join(filepath.Dir(path), target))
if !filepath.IsAbs(path) {
path, err = filepath.Abs(path)
if err != nil {
return path, false, fmt.Errorf("could not resolve absolute path: %s, %v", path, err)
}
}
continue
}
return path, info.IsDir(), nil
}
return path, false, fmt.Errorf("too many symlink resolutions for path: %s", path)
}
// addContent reads and sets content based on the file type.
func (i *FileInfo) addContent(path string) error {
if !i.IsDir {
content, err := os.ReadFile(path)
if err != nil {
return err
}
stringContent := string(content)
if !utf8.ValidString(stringContent) {
return nil
}
if stringContent == "" {
i.Content = "empty-file-x6OlSil"
return nil
}
i.Content = stringContent
func getContent(realPath string) (string, error) {
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
return nil
stringContent := string(content)
if !utf8.ValidString(stringContent) {
return "", nil
}
if stringContent == "" {
return "empty-file-x6OlSil", nil
}
return stringContent, nil
}
// detectType detects the file type.
func (i *FileInfo) detectType(path string, modify, saveContent, readHeader bool) error {
if i.IsDir {
return nil
}
if IsNamedPipe(i.Mode) {
i.Type = "blob"
if saveContent {
return i.addContent(path)
}
return nil
}
// DetectType detects the MIME type of a file and updates the ItemInfo struct.
func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
name := i.Name
ext := filepath.Ext(name)
var buffer []byte
if readHeader {
buffer = i.readFirstBytes()
mimetype := mime.TypeByExtension(i.Extension)
if mimetype == "" {
http.DetectContentType(buffer)
}
}
ext := filepath.Ext(i.Name)
for _, fileType := range AllFiletypeOptions {
if IsMatchingType(ext, fileType) {
i.Type = fileType
}
switch i.Type {
case "text":
if !modify {
i.Type = "textImmutable"
}
if saveContent {
return i.addContent(path)
}
case "video":
parentDir := strings.TrimRight(path, i.Name)
i.detectSubtitles(parentDir)
case "doc":
if ext == ".pdf" {
i.Type = "pdf"
return nil
}
if saveContent {
return i.addContent(path)
}
}
// Attempt MIME detection by file extension
if ext == ".md" {
i.Type = "text/markdown"
return
}
i.Type = strings.Split(mime.TypeByExtension(ext), ";")[0]
if i.Type == "" {
i.Type = "blob"
if saveContent {
return i.addContent(path)
}
i.Type = extendedMimeTypeCheck(ext)
}
if i.Type == "blob" {
// Read only the first 512 bytes for efficient MIME detection
file, err := os.Open(realPath)
if err != nil {
return nil
}
// readFirstBytes reads the first bytes of the file.
func (i *FileInfo) readFirstBytes() []byte {
file, err := os.Open(i.Path)
if err != nil {
i.Type = "blob"
return nil
}
defer file.Close()
buffer := make([]byte, 512) //nolint:gomnd
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
i.Type = "blob"
return nil
}
return buffer[:n]
}
// detectSubtitles detects subtitles for video files.
func (i *FileInfo) detectSubtitles(parentDir string) {
if i.Type != "video" {
return
}
i.Subtitles = []string{}
ext := filepath.Ext(i.Name)
dir, err := os.Open(parentDir)
if err != nil {
// Directory must have been deleted, remove it from the index
return
}
defer dir.Close() // Ensure directory handle is closed
files, err := dir.Readdir(-1)
if err != nil {
return
}
base := strings.TrimSuffix(i.Name, ext)
subtitleExts := []string{".vtt", ".txt", ".srt", ".lrc"}
for _, f := range files {
if f.IsDir() || !strings.HasPrefix(f.Name(), base) {
continue
}
for _, subtitleExt := range subtitleExts {
if strings.HasSuffix(f.Name(), subtitleExt) {
i.Subtitles = append(i.Subtitles, filepath.Join(parentDir, f.Name()))
break
}
}
}
}
// readListing reads the contents of a directory and fills the listing.
func (i *FileInfo) readListing(path string, checker rules.Checker, readHeader bool) error {
dir, err := os.Open(i.Path)
if err != nil {
return err
}
defer dir.Close()
files, err := dir.Readdir(-1)
if err != nil {
return err
}
listing := &Listing{
Items: []*FileInfo{},
Path: i.Path,
NumDirs: 0,
NumFiles: 0,
}
for _, f := range files {
name := f.Name()
fPath := filepath.Join(i.Path, name)
if !checker.Check(fPath) {
continue
}
isSymlink, isInvalidLink := false, false
if IsSymlink(f.Mode()) {
isSymlink = true
info, err := os.Stat(fPath)
if err == nil {
f = info
} else {
isInvalidLink = true
}
}
file := &FileInfo{
Name: name,
Size: f.Size(),
ModTime: f.ModTime(),
Mode: f.Mode(),
}
if f.IsDir() {
file.IsDir = true
}
if isSymlink {
file.IsSymlink = true
}
if file.IsDir {
listing.NumDirs++
} else {
listing.NumFiles++
if isInvalidLink {
file.Type = "invalid_link"
} else {
err := file.detectType(path, true, false, readHeader)
if err != nil {
return err
}
}
defer file.Close()
buffer := make([]byte, 512)
n, _ := file.Read(buffer) // Ignore errors from Read
i.Type = strings.Split(http.DetectContentType(buffer[:n]), ";")[0]
}
}
}
listing.Items = append(listing.Items, file)
// TODO add subtitles back
// detectSubtitles detects subtitles for video files.
func (i *ExtendedFileInfo) detectSubtitles(path string) {
if !strings.HasPrefix(i.Type, "video") {
logger.Debug("subtitles are not supported for this file : " + path)
return
}
i.Listing = listing
return nil
idx := GetIndex(i.Source)
if idx == nil {
return
}
parentInfo, exists := idx.GetReducedMetadata(filepath.Dir(i.Path), true)
if !exists {
return
}
base := strings.Split(i.Name, ".")[0]
for _, f := range parentInfo.Files {
baseName := strings.Split(f.Name, ".")[0]
if baseName != base {
continue
}
for _, subtitleExt := range []string{".vtt", ".srt", ".lrc", ".sbv", ".ass", ".ssa", ".sub", ".smi"} {
if strings.HasSuffix(f.Name, subtitleExt) {
fullPathBase := strings.Split(i.Path, ".")[0]
i.Subtitles = append(i.Subtitles, fullPathBase+subtitleExt)
}
}
}
}
func IsNamedPipe(mode os.FileMode) bool {
@@ -560,19 +423,6 @@ func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0
}
func getMutex(path string) *sync.Mutex {
// Lock access to pathMutexes map
pathMutexesMu.Lock()
defer pathMutexesMu.Unlock()
// Create a mutex for the path if it doesn't exist
if pathMutexes[path] == nil {
pathMutexes[path] = &sync.Mutex{}
}
return pathMutexes[path]
}
func Exists(path string) bool {
_, err := os.Stat(path)
if err == nil {
@@ -583,3 +433,30 @@ func Exists(path string) bool {
}
return false
}
func (info *FileInfo) SortItems() {
sort.Slice(info.Folders, func(i, j int) bool {
nameWithoutExt := strings.Split(info.Folders[i].Name, ".")[0]
nameWithoutExt2 := strings.Split(info.Folders[j].Name, ".")[0]
// Convert strings to integers for numeric sorting if both are numeric
numI, errI := strconv.Atoi(nameWithoutExt)
numJ, errJ := strconv.Atoi(nameWithoutExt2)
if errI == nil && errJ == nil {
return numI < numJ
}
// Fallback to case-insensitive lexicographical sorting
return strings.ToLower(info.Folders[i].Name) < strings.ToLower(info.Folders[j].Name)
})
sort.Slice(info.Files, func(i, j int) bool {
nameWithoutExt := strings.Split(info.Files[i].Name, ".")[0]
nameWithoutExt2 := strings.Split(info.Files[j].Name, ".")[0]
// Convert strings to integers for numeric sorting if both are numeric
numI, errI := strconv.Atoi(nameWithoutExt)
numJ, errJ := strconv.Atoi(nameWithoutExt2)
if errI == nil && errJ == nil {
return numI < numJ
}
// Fallback to case-insensitive lexicographical sorting
return strings.ToLower(info.Files[i].Name) < strings.ToLower(info.Files[j].Name)
})
}
+108 -7
View File
@@ -3,8 +3,11 @@ package files
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
func Test_GetRealPath(t *testing.T) {
@@ -12,7 +15,7 @@ func Test_GetRealPath(t *testing.T) {
if err != nil {
return
}
trimPrefix := filepath.Dir(filepath.Dir(cwd)) + "/"
trimPrefix := filepath.Dir(filepath.Dir(cwd))
tests := []struct {
name string
paths []string
@@ -30,20 +33,20 @@ func Test_GetRealPath(t *testing.T) {
path string
isDir bool
}{
path: "backend/files",
path: "",
isDir: true,
},
},
{
name: "current directory",
paths: []string{
"./file.go",
"./files/file.go",
},
want: struct {
path string
isDir bool
}{
path: "backend/files/file.go",
path: "/files/file.go",
isDir: false,
},
},
@@ -61,15 +64,113 @@ func Test_GetRealPath(t *testing.T) {
},
},
}
idx := Index{
Source: settings.Source{
Path: trimPrefix,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
realPath, isDir, err := GetRealPath(tt.paths...)
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)
}
if err != nil {
t.Error("got error", err)
})
}
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
input FileInfo
expected FileInfo
}{
{
name: "Numeric and Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
{Name: "10.txt"},
{Name: "2.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
{Name: "File2.txt"},
{Name: "File10.txt"},
{Name: "File1"},
{Name: "banana"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
{Name: "2.txt"},
{Name: "10.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
{Name: "banana"},
{Name: "File1"},
{Name: "File10.txt"},
{Name: "File2.txt"},
},
},
},
{
name: "Only Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
{Name: "dog.txt"},
{Name: "Cat.txt"},
{Name: "apple"},
},
Files: []ItemInfo{
{Name: "Zebra"},
{Name: "apple"},
{Name: "cat"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
{Name: "apple"},
{Name: "Cat.txt"},
{Name: "dog.txt"},
},
Files: []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) []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)
}
})
}
-175
View File
@@ -1,175 +0,0 @@
package files
import (
"bytes"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/settings"
)
type Directory struct {
Metadata map[string]FileInfo
Files string
}
type File struct {
Name string
IsDir bool
}
type Index struct {
Root string
Directories map[string]Directory
NumDirs int
NumFiles int
inProgress bool
quickList []File
LastIndexed time.Time
mu sync.RWMutex
}
var (
rootPath string = "/srv"
indexes []*Index
indexesMutex sync.RWMutex
)
func InitializeIndex(intervalMinutes uint32, schedule bool) {
if schedule {
go indexingScheduler(intervalMinutes)
}
}
func indexingScheduler(intervalMinutes uint32) {
if settings.Config.Server.Root != "" {
rootPath = settings.Config.Server.Root
}
si := GetIndex(rootPath)
log.Printf("Indexing Files...")
log.Printf("Configured to run every %v minutes", intervalMinutes)
log.Printf("Indexing from root: %s", si.Root)
for {
startTime := time.Now()
// Set the indexing flag to indicate that indexing is in progress
si.resetCount()
// Perform the indexing operation
err := si.indexFiles(si.Root)
si.quickList = []File{}
// Reset the indexing flag to indicate that indexing has finished
si.inProgress = false
// Update the LastIndexed time
si.LastIndexed = time.Now()
if err != nil {
log.Printf("Error during indexing: %v", err)
}
if si.NumFiles+si.NumDirs > 0 {
timeIndexedInSeconds := int(time.Since(startTime).Seconds())
log.Println("Successfully indexed files.")
log.Printf("Time spent indexing: %v seconds\n", timeIndexedInSeconds)
log.Printf("Files found: %v\n", si.NumFiles)
log.Printf("Directories found: %v\n", si.NumDirs)
}
// Sleep for the specified interval
time.Sleep(time.Duration(intervalMinutes) * time.Minute)
}
}
// Define a function to recursively index files and directories
func (si *Index) indexFiles(path string) error {
// Check if the current directory has been modified since the last indexing
adjustedPath := si.makeIndexPath(path, false)
dir, err := os.Open(path)
if err != nil {
// Directory must have been deleted, remove it from the index
si.RemoveDirectory(adjustedPath)
}
dirInfo, err := dir.Stat()
if err != nil {
dir.Close()
return err
}
// Compare the last modified time of the directory with the last indexed time
lastIndexed := si.LastIndexed
if dirInfo.ModTime().Before(lastIndexed) {
dir.Close()
return nil
}
// Read the directory contents
files, err := dir.Readdir(-1)
if err != nil {
return err
}
dir.Close()
si.UpdateQuickList(files)
si.InsertFiles(path)
// done separately for memory efficiency on recursion
si.InsertDirs(path)
return nil
}
func (si *Index) InsertFiles(path string) {
adjustedPath := si.makeIndexPath(path, true)
subDirectory := Directory{}
buffer := bytes.Buffer{}
for _, f := range si.GetQuickList() {
if !f.IsDir {
buffer.WriteString(f.Name + ";")
si.UpdateCount("files")
}
}
// Use GetMetadataInfo and SetFileMetadata for safer read and write operations
subDirectory.Files = buffer.String()
si.SetDirectoryInfo(adjustedPath, subDirectory)
}
func (si *Index) InsertDirs(path string) {
for _, f := range si.GetQuickList() {
if f.IsDir {
adjustedPath := si.makeIndexPath(path, false)
if _, exists := si.Directories[adjustedPath]; exists {
si.UpdateCount("dirs")
// Add or update the directory in the map
if adjustedPath == "/" {
si.SetDirectoryInfo("/"+f.Name, Directory{})
} else {
si.SetDirectoryInfo(adjustedPath+"/"+f.Name, Directory{})
}
}
err := si.indexFiles(path + "/" + f.Name)
if err != nil {
if err.Error() == "invalid argument" {
log.Printf("Could not index \"%v\": %v \n", path, "Permission Denied")
} else {
log.Printf("Could not index \"%v\": %v \n", path, err)
}
}
}
}
}
func (si *Index) makeIndexPath(subPath string, isDir bool) string {
if si.Root == subPath {
return "/"
}
// clean path
subPath = strings.TrimSuffix(subPath, "/")
// remove index prefix
adjustedPath := strings.TrimPrefix(subPath, si.Root)
// remove trailing slash
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
// add leading slash for root of index
if adjustedPath == "" {
adjustedPath = "/"
} else if !isDir {
adjustedPath = filepath.Dir(adjustedPath)
}
return adjustedPath
}
+344
View File
@@ -0,0 +1,344 @@
package files
import (
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
type Index struct {
settings.Source
Directories map[string]*FileInfo
NumDirs uint64
NumFiles uint64
NumDeleted uint64
FilesChangedDuringIndexing bool
currentSchedule int
assessment string
indexingTime int
LastIndexed time.Time
SmartModifier time.Duration
mu sync.RWMutex
scannerMu sync.Mutex
}
var (
indexes map[string]*Index
indexesMutex sync.RWMutex
)
func init() {
indexes = make(map[string]*Index)
}
func Initialize(source settings.Source) {
indexesMutex.Lock()
newIndex := Index{
Source: source,
Directories: make(map[string]*FileInfo),
}
indexes[newIndex.Source.Name] = &newIndex
indexesMutex.Unlock()
if !newIndex.Source.Config.Disabled {
time.Sleep(time.Second)
logger.Info(fmt.Sprintf("initializing index: [%v]", newIndex.Source.Name))
newIndex.RunIndexing("/", false)
go newIndex.setupIndexingScanners()
} else {
logger.Debug("indexing disabled for source: " + newIndex.Source.Name)
}
}
// Define a function to recursively index files and directories
func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) error {
realPath := strings.TrimRight(idx.Source.Path, "/") + adjustedPath
// Open the directory
dir, err := os.Open(realPath)
if err != nil {
idx.RemoveDirectory(adjustedPath) // Remove, must have been deleted
return err
}
defer dir.Close()
dirInfo, err := dir.Stat()
if err != nil {
return err
}
combinedPath := adjustedPath + "/"
if adjustedPath == "/" {
combinedPath = "/"
}
// get whats currently in cache
idx.mu.RLock()
cacheDirItems := []ItemInfo{}
modChange := true // default to true
cachedDir, exists := idx.Directories[adjustedPath]
if exists && quick {
modChange = dirInfo.ModTime() != cachedDir.ModTime
cacheDirItems = cachedDir.Folders
}
idx.mu.RUnlock()
// If the directory has not been modified since the last index, skip expensive readdir
// recursively check cached dirs for mod time changes as well
if !modChange && recursive {
for _, item := range cacheDirItems {
err = idx.indexDirectory(combinedPath+item.Name, quick, true)
if err != nil {
logger.Error(fmt.Sprintf("error indexing directory %v : %v", combinedPath+item.Name, err))
}
}
return nil
}
if quick {
idx.mu.Lock()
idx.FilesChangedDuringIndexing = true
idx.mu.Unlock()
}
// Read directory contents
files, err := dir.Readdir(-1)
if err != nil {
return err
}
var totalSize int64
fileInfos := []ItemInfo{}
dirInfos := []ItemInfo{}
// Process each file and directory in the current directory
for _, file := range files {
isHidden := isHidden(file, idx.Source.Path+combinedPath)
isDir := file.IsDir()
fullCombined := combinedPath + file.Name()
if idx.shouldSkip(isDir, isHidden, fullCombined) {
continue
}
itemInfo := &ItemInfo{
Name: file.Name(),
ModTime: file.ModTime(),
Hidden: isHidden,
}
// fix for .app files on macos which are technically directories, but we don't want to treat them as such
if isDir && strings.HasSuffix(file.Name(), ".app") {
isDir = false
}
if isDir {
// skip non-indexable dirs.
if file.Name() == "$RECYCLE.BIN" || file.Name() == "System Volume Information" {
continue
}
dirPath := combinedPath + file.Name()
if recursive {
// Recursively index the subdirectory
err = idx.indexDirectory(dirPath, quick, recursive)
if err != nil {
logger.Error(fmt.Sprintf("Failed to index directory %s: %v", dirPath, err))
continue
}
}
realDirInfo, exists := idx.GetMetadataInfo(dirPath, true)
if exists {
itemInfo.Size = realDirInfo.Size
}
totalSize += itemInfo.Size
itemInfo.Type = "directory"
dirInfos = append(dirInfos, *itemInfo)
idx.NumDirs++
} else {
itemInfo.DetectType(fullCombined, false)
itemInfo.Size = file.Size()
fileInfos = append(fileInfos, *itemInfo)
totalSize += itemInfo.Size
idx.NumFiles++
}
}
if totalSize == 0 && idx.Source.Config.IgnoreZeroSizeFolders {
return nil
}
// Create FileInfo for the current directory
dirFileInfo := &FileInfo{
Path: adjustedPath,
Files: fileInfos,
Folders: dirInfos,
}
dirFileInfo.ItemInfo = ItemInfo{
Name: dirInfo.Name(),
Type: "directory",
Size: totalSize,
ModTime: dirInfo.ModTime(),
}
dirFileInfo.SortItems()
// Update the current directory metadata in the index
idx.UpdateMetadata(dirFileInfo)
return nil
}
// input should be non-index path.
func (idx *Index) MakeIndexPath(subPath string) string {
if strings.HasPrefix(subPath, "./") {
subPath = strings.TrimPrefix(subPath, ".")
}
if idx.Source.Path == subPath || subPath == "." {
return "/"
}
// clean path
subPath = strings.TrimSuffix(subPath, "/")
adjustedPath := strings.TrimPrefix(subPath, idx.Source.Path)
// remove index prefix
adjustedPath = strings.ReplaceAll(adjustedPath, "\\", "/")
// remove trailing slash
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
if !strings.HasPrefix(adjustedPath, "/") {
adjustedPath = "/" + adjustedPath
}
return adjustedPath
}
func (idx *Index) recursiveUpdateDirSizes(childInfo *FileInfo, previousSize int64) {
parentDir := utils.GetParentDirectoryPath(childInfo.Path)
parentInfo, exists := idx.GetMetadataInfo(parentDir, true)
if !exists || parentDir == "" {
return
}
newSize := parentInfo.Size - previousSize + childInfo.Size
parentInfo.Size += newSize
idx.UpdateMetadata(parentInfo)
idx.recursiveUpdateDirSizes(parentInfo, newSize)
}
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
combined := append([]string{idx.Source.Path}, relativePath...)
joinedPath := filepath.Join(combined...)
isDir, _ := cache.RealPath.Get(joinedPath + ":isdir").(bool)
cached, ok := cache.RealPath.Get(joinedPath).(string)
if ok && cached != "" {
return cached, isDir, nil
}
// Convert relative path to absolute path
absolutePath, err := filepath.Abs(joinedPath)
if err != nil {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
}
// Resolve symlinks and get the real path
realPath, isDir, err := resolveSymlinks(absolutePath)
if err == nil {
cache.RealPath.Set(joinedPath, realPath)
cache.RealPath.Set(joinedPath+":isdir", isDir)
}
return realPath, isDir, err
}
func (idx *Index) RefreshFileInfo(opts FileOptions) error {
refreshOptions := FileOptions{
Path: opts.Path,
IsDir: opts.IsDir,
}
if !refreshOptions.IsDir {
refreshOptions.Path = idx.MakeIndexPath(filepath.Dir(refreshOptions.Path))
refreshOptions.IsDir = true
}
err := idx.indexDirectory(refreshOptions.Path, false, false)
if err != nil {
return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path)
}
file, exists := idx.GetMetadataInfo(refreshOptions.Path, true)
if !exists {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
}
current, firstExisted := idx.GetMetadataInfo(refreshOptions.Path, true)
refreshParentInfo := firstExisted && current.Size != file.Size
//utils.PrintStructFields(*file)
result := idx.UpdateMetadata(file)
if !result {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
}
if !exists {
return nil
}
if refreshParentInfo {
idx.recursiveUpdateDirSizes(file, current.Size)
}
return nil
}
func isHidden(file os.FileInfo, srcPath string) bool {
// Check if the file starts with a dot (common on Unix systems)
if file.Name()[0] == '.' {
return true
}
if runtime.GOOS == "windows" {
return checkWindowsHidden(filepath.Join(srcPath, file.Name()))
}
// Default behavior for non-Windows systems
return false
}
func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined string) bool {
// check inclusions first
if isDir && len(idx.Source.Config.Include.Folders) > 0 {
if !slices.Contains(idx.Source.Config.Include.Folders, fullCombined) {
return true
}
}
if !isDir && len(idx.Source.Config.Include.Files) > 0 {
if !slices.Contains(idx.Source.Config.Include.Files, fullCombined) {
return true
}
}
if !isDir && len(idx.Source.Config.Include.FileEndsWith) > 0 {
shouldSkip := true
for _, end := range idx.Source.Config.Include.FileEndsWith {
if strings.HasSuffix(fullCombined, end) {
shouldSkip = false
break
}
}
if shouldSkip {
return true
}
}
// check exclusions
if isDir && slices.Contains(idx.Source.Config.Exclude.Folders, fullCombined) {
return true
}
if !isDir && slices.Contains(idx.Source.Config.Exclude.Files, fullCombined) {
return true
}
if idx.Source.Config.IgnoreHidden && isHidden {
return true
}
if !isDir && len(idx.Source.Config.Exclude.FileEndsWith) > 0 {
shouldSkip := false
for _, end := range idx.Source.Config.Exclude.FileEndsWith {
if strings.HasSuffix(fullCombined, end) {
shouldSkip = true
break
}
}
return shouldSkip
}
return false
}
+127
View File
@@ -0,0 +1,127 @@
package files
import (
"fmt"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// schedule in minutes
var scanSchedule = []time.Duration{
5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
10 * time.Minute,
20 * time.Minute,
40 * time.Minute, // [3] element is 40 minutes, reset anchor for full scan
1 * time.Hour,
2 * time.Hour,
3 * time.Hour,
4 * time.Hour, // 4 hours for quick scan & 20 hours for a full scan
}
func (idx *Index) newScanner(origin string) {
fullScanAnchor := 3
fullScanCounter := 0 // every 5th scan is a full scan
for {
// Determine sleep time with modifiers
fullScanCounter++
sleepTime := scanSchedule[idx.currentSchedule] + idx.SmartModifier
if idx.assessment == "simple" {
sleepTime = scanSchedule[idx.currentSchedule] - idx.SmartModifier
}
if idx.Source.Config.IndexingInterval > 0 {
sleepTime = time.Duration(idx.Source.Config.IndexingInterval) * time.Minute
}
// Log and sleep before indexing
logger.Debug(fmt.Sprintf("Next scan in %v", sleepTime))
time.Sleep(sleepTime)
idx.scannerMu.Lock()
if fullScanCounter == 5 {
idx.RunIndexing(origin, false) // Full scan
fullScanCounter = 0
} else {
idx.RunIndexing(origin, true) // Quick scan
}
idx.scannerMu.Unlock()
// Adjust schedule based on file changes
if idx.FilesChangedDuringIndexing {
logger.Debug(fmt.Sprintf("Files changed during indexing [%v], adjusting schedule.", idx.Name))
// Move to at least the full-scan anchor or reduce interval
if idx.currentSchedule > fullScanAnchor {
idx.currentSchedule = fullScanAnchor
} else if idx.currentSchedule > 0 {
idx.currentSchedule--
}
} else {
// Increment toward the longest interval if no changes
if idx.currentSchedule < len(scanSchedule)-1 {
idx.currentSchedule++
}
}
if idx.assessment == "simple" && idx.currentSchedule > 3 {
idx.currentSchedule = 3
}
// Ensure `currentSchedule` stays within bounds
if idx.currentSchedule < 0 {
idx.currentSchedule = 0
} else if idx.currentSchedule >= len(scanSchedule) {
idx.currentSchedule = len(scanSchedule) - 1
}
}
}
func (idx *Index) RunIndexing(origin string, quick bool) {
prevNumDirs := idx.NumDirs
prevNumFiles := idx.NumFiles
if quick {
logger.Debug(fmt.Sprintf("Starting quick scan for [%v]", idx.Source.Name))
} else {
logger.Debug(fmt.Sprintf("Starting full scan for [%v]", idx.Source.Name))
idx.NumDirs = 0
idx.NumFiles = 0
}
startTime := time.Now()
idx.FilesChangedDuringIndexing = false
// Perform the indexing operation
err := idx.indexDirectory("/", quick, true)
if err != nil {
logger.Error(fmt.Sprintf("Error during indexing: %v", err))
}
firstRun := idx.LastIndexed == time.Time{}
// Update the LastIndexed time
idx.LastIndexed = time.Now()
idx.indexingTime = int(time.Since(startTime).Seconds())
if !quick {
// update smart indexing
if idx.indexingTime < 3 || idx.NumDirs < 10000 {
idx.assessment = "simple"
idx.SmartModifier = 4 * time.Minute
} else if idx.indexingTime > 120 || idx.NumDirs > 500000 {
idx.assessment = "complex"
modifier := idx.indexingTime / 10 // seconds
idx.SmartModifier = time.Duration(modifier) * time.Minute
} else {
idx.assessment = "normal"
}
if firstRun {
logger.Info(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.assessment, idx.NumDirs, idx.NumFiles))
} else {
logger.Debug(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.assessment, idx.NumDirs, idx.NumFiles))
}
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
idx.FilesChangedDuringIndexing = true
}
}
if firstRun {
logger.Info(fmt.Sprintf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.indexingTime))
} else {
logger.Debug(fmt.Sprintf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.indexingTime))
}
}
func (idx *Index) setupIndexingScanners() {
go idx.newScanner("/")
}
+154 -68
View File
@@ -7,74 +7,47 @@ import (
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
func BenchmarkFillIndex(b *testing.B) {
InitializeIndex(5, false)
si := GetIndex(settings.Config.Server.Root)
Initialize(settings.Source{
Name: "test",
Path: "/srv",
})
idx := GetIndex("test")
if idx == nil {
b.Fatal("index is nil")
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
si.createMockData(50, 3) // 1000 dirs, 3 files per dir
idx.createMockData(50, 3) // 1000 dirs, 3 files per dir
}
}
func (si *Index) createMockData(numDirs, numFilesPerDir int) {
func (idx *Index) createMockData(numDirs, numFilesPerDir int) {
for i := 0; i < numDirs; i++ {
dirName := generateRandomPath(rand.Intn(3) + 1)
files := []File{}
// Append a new Directory to the slice
dirPath := generateRandomPath(rand.Intn(3) + 1)
files := []ItemInfo{} // Slice of FileInfo
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := File{
Name: "file-" + getRandomTerm() + getRandomExtension(),
IsDir: false,
newFile := 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",
}
files = append(files, newFile)
}
si.UpdateQuickListForTests(files)
si.InsertFiles(dirName)
si.InsertDirs(dirName)
}
}
dirInfo := &FileInfo{
Path: dirPath,
Files: files,
}
func generateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
for i := 0; i < levels; i++ {
dirName += "/" + getRandomTerm()
idx.UpdateMetadata(dirInfo)
}
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
}
// JSONBytesEqual compares the JSON in two byte slices.
@@ -105,36 +78,149 @@ func TestGetIndex(t *testing.T) {
}
}
func TestInitializeIndex(t *testing.T) {
type args struct {
intervalMinutes uint32
}
func TestMakeIndexPath(t *testing.T) {
tests := []struct {
name string
args args
name string
subPath string
expected string
}{
// TODO: Add test cases.
// Linux
{"Root path returns slash", "/", "/"},
{"Dot-prefixed returns slash", ".", "/"},
{"Double-dot prefix ignored", "./", "/"},
{"Dot prefix followed by text", "./test", "/test"},
{"Dot prefix followed by text", ".test", "/.test"},
{"Hidden file at root", "/.test", "/.test"},
{"Trailing slash removed", "/test/", "/test"},
{"Subpath without root prefix", "/other/test", "/other/test"},
{"Complex nested paths", "/nested/path", "/nested/path"},
// TODO fix {"has source name as start", "/srv.tar.gz", "/srv.tar.gz"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
InitializeIndex(tt.args.intervalMinutes, false)
idx := &Index{Source: settings.Source{Path: "/srv"}}
result := idx.MakeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
})
}
tests = []struct {
name string
subPath string
expected string
}{
// Windows
{"Mixed slash", "/first\\second", "/first/second"},
{"Windows slash", "\\first\\second", "/first/second"},
{"Windows full path", "C:\\Users\\testfolder\\nestedfolder", "/testfolder/nestedfolder"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "C:\\Users"}}
result := idx.MakeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
})
}
}
func Test_indexingScheduler(t *testing.T) {
type args struct {
intervalMinutes uint32
}
func TestMakeIndexPathRoot(t *testing.T) {
tests := []struct {
name string
args args
name string
subPath string
expected string
}{
// TODO: Add test cases.
// Linux
{"Root path returns slash", "/rootpath", "/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
indexingScheduler(tt.args.intervalMinutes)
idx := &Index{Source: settings.Source{Path: "/rootpath", Name: "default"}}
result := idx.MakeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
})
}
}
func BenchmarkCheckIndexExclude(b *testing.B) {
tests := []struct {
isDir bool
isHidden bool
fullPath string
}{
{false, false, "/test/.test"},
{true, false, "/test/.test"},
{true, true, "/test/.test"},
{false, false, "/test/filepath"},
{false, true, "/test/filepath"},
{true, true, "/test/filepath"},
}
b.ResetTimer()
b.ReportAllocs()
idx := Index{
Source: settings.Source{
Name: "files",
Config: settings.SourceConfig{
IgnoreHidden: true,
Exclude: settings.IndexFilter{
Files: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
Folders: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
FileEndsWith: []string{".zip", ".tar", ".jpeg"},
},
},
},
}
for i := 0; i < b.N; i++ {
for _, v := range tests {
idx.shouldSkip(v.isDir, v.isHidden, v.fullPath)
}
}
}
func BenchmarkCheckIndexConditionsInclude(b *testing.B) {
tests := []struct {
isDir bool
isHidden bool
fullPath string
}{
{false, false, "/test/.test"},
{true, false, "/test/.test"},
{true, true, "/test/.test"},
{false, false, "/test/filepath"},
{false, true, "/test/filepath"},
{true, true, "/test/filepath"},
}
b.ResetTimer()
b.ReportAllocs()
idx2 := Index{
Source: settings.Source{
Name: "files",
Config: settings.SourceConfig{
IgnoreHidden: true,
Include: settings.IndexFilter{
Files: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
Folders: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
FileEndsWith: []string{".zip", ".tar", ".jpeg"},
},
},
},
}
for i := 0; i < b.N; i++ {
for _, v := range tests {
idx2.shouldSkip(v.isDir, v.isHidden, v.fullPath)
}
}
}
+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)
}
}
+71
View File
@@ -0,0 +1,71 @@
package files
import (
"time"
"math/rand"
)
func CreateMockData(numDirs, numFilesPerDir int) FileInfo {
dir := FileInfo{}
dir.Path = "/here/is/your/mock/dir"
for i := 0; i < numDirs; i++ {
newFile := 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{
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
}
+128 -118
View File
@@ -1,13 +1,13 @@
package files
import (
"math/rand"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
var (
@@ -15,160 +15,170 @@ var (
maxSearchResults = 100
)
func (si *Index) Search(search string, scope string, sourceSession string) ([]string, map[string]map[string]bool) {
type SearchResult struct {
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
}
func (idx *Index) Search(search string, scope string, sourceSession string) []SearchResult {
scope = strings.TrimSuffix(scope, "/")
if search == "" {
scope = ""
}
// Remove slashes
scope = strings.TrimLeft(scope, "/")
scope = strings.TrimRight(scope, "/")
runningHash := generateRandomHash(4)
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := ParseSearch(search)
fileListTypes := make(map[string]map[string]bool)
matching := []string{}
results := make(map[string]SearchResult, 0)
count := 0
var directories []string
cachedDirs, ok := cache.SearchResults.Get(idx.Source.Path + scope).([]string)
if ok {
directories = cachedDirs
} else {
directories = idx.getDirsInScope(scope)
cache.SearchResults.Set(idx.Source.Path+scope, directories)
}
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" {
continue
}
si.mu.Lock()
defer si.mu.Unlock()
for dirName, dir := range si.Directories {
isDir := true
files := strings.Split(dir.Files, ";")
value, found := sessionInProgress.Load(sourceSession)
if !found || value != runningHash {
return []string{}, map[string]map[string]bool{}
if count > maxSearchResults {
break
}
idx.mu.Lock()
for _, dirName := range directories {
idx.mu.Unlock()
dir, found := idx.GetReducedMetadata(dirName, true)
idx.mu.Lock()
if !found {
continue
}
if count > maxSearchResults {
break
}
pathName := scopedPathNameFilter(dirName, scope, isDir)
if pathName == "" {
continue // path not matched
reducedDir := ItemInfo{
Name: filepath.Base(dirName),
Type: "directory",
Size: dir.Size,
}
fileTypes := map[string]bool{}
matches, fileType := containsSearchTerm(dirName, searchTerm, *searchOptions, isDir, fileTypes)
matches := reducedDir.containsSearchTerm(searchTerm, searchOptions)
if matches {
fileListTypes[pathName] = fileType
matching = append(matching, pathName)
results[dirName+"/"] = SearchResult{Path: dirName + "/", Type: "directory", Size: dir.Size}
count++
}
isDir = false
for _, file := range files {
if file == "" {
continue
// search files first
for _, item := range dir.Files {
fullPath := dirName + "/" + item.Name
if dirName == "/" {
fullPath = dirName + item.Name
}
value, found := sessionInProgress.Load(sourceSession)
if !found || value != runningHash {
return []string{}, map[string]map[string]bool{}
idx.mu.Unlock()
return []SearchResult{}
}
if count > maxSearchResults {
break
}
fullName := strings.TrimLeft(pathName+file, "/")
fileTypes := map[string]bool{}
matches, fileType := containsSearchTerm(fullName, searchTerm, *searchOptions, isDir, fileTypes)
if !matches {
continue
matches := item.containsSearchTerm(searchTerm, searchOptions)
if matches {
results[fullPath] = SearchResult{Path: fullPath, Type: item.Type, Size: item.Size}
count++
}
fileListTypes[fullName] = fileType
matching = append(matching, fullName)
count++
}
}
idx.mu.Unlock()
}
// Sort keys based on the number of elements in the path after splitting by "/"
sortedKeys := make([]SearchResult, 0, len(results))
for _, v := range results {
sortedKeys = append(sortedKeys, v)
}
// Sort the strings based on the number of elements after splitting by "/"
sort.Slice(matching, func(i, j int) bool {
parts1 := strings.Split(matching[i], "/")
parts2 := strings.Split(matching[j], "/")
sort.Slice(sortedKeys, func(i, j int) bool {
parts1 := strings.Split(sortedKeys[i].Path, "/")
parts2 := strings.Split(sortedKeys[j].Path, "/")
return len(parts1) < len(parts2)
})
return matching, fileListTypes
return sortedKeys
}
func scopedPathNameFilter(pathName string, scope string, isDir bool) string {
pathName = strings.TrimLeft(pathName, "/")
pathName = strings.TrimRight(pathName, "/")
if strings.HasPrefix(pathName, scope) || scope == "" {
pathName = strings.TrimPrefix(pathName, scope)
pathName = strings.TrimLeft(pathName, "/")
if isDir {
pathName = pathName + "/"
}
} else {
pathName = "" // return not matched
}
return pathName
}
// returns true if the file name contains the search term
// returns file type if the file name contains the search term
// returns size of file/dir if the file name contains the search term
func (fi ItemInfo) containsSearchTerm(searchTerm string, options SearchOptions) bool {
func containsSearchTerm(pathName string, searchTerm string, options SearchOptions, isDir bool, fileTypes map[string]bool) (bool, map[string]bool) {
fileTypes := map[string]bool{}
largerThan := int64(options.LargerThan) * 1024 * 1024
smallerThan := int64(options.SmallerThan) * 1024 * 1024
conditions := options.Conditions
path := getLastPathComponent(pathName)
// Convert to lowercase once
lowerFileName := strings.ToLower(fi.Name)
// Convert to lowercase if not exact match
if !conditions["exact"] {
path = strings.ToLower(path)
searchTerm = strings.ToLower(searchTerm)
}
if strings.Contains(path, searchTerm) {
// Calculate fileSize only if needed
var fileSize int64
matchesAllConditions := true
extension := filepath.Ext(path)
for _, k := range AllFiletypeOptions {
if IsMatchingType(extension, k) {
fileTypes[k] = true
// Check if the file name contains the search term
if !strings.Contains(lowerFileName, searchTerm) {
return false
}
// Initialize file size and fileTypes map
var fileSize int64
extension := filepath.Ext(lowerFileName)
// Collect file types
for _, k := range AllFiletypeOptions {
if IsMatchingType(extension, k) {
fileTypes[k] = true
}
}
isDir := fi.Type == "directory"
fileTypes["dir"] = isDir
fileSize = fi.Size
// Evaluate all conditions
for t, v := range conditions {
if t == "exact" {
continue
}
switch t {
case "larger":
if largerThan > 0 {
if fileSize <= largerThan {
return false
}
}
case "smaller":
if smallerThan > 0 {
if fileSize >= smallerThan {
return false
}
}
default:
// Handle other file type conditions
notMatchType := v != fileTypes[t]
if notMatchType {
return false
}
}
fileTypes["dir"] = isDir
for t, v := range conditions {
if t == "exact" {
continue
}
var matchesCondition bool
switch t {
case "larger":
if fileSize == 0 {
fileSize = getFileSize(pathName)
}
matchesCondition = fileSize > int64(options.LargerThan)*bytesInMegabyte
case "smaller":
if fileSize == 0 {
fileSize = getFileSize(pathName)
}
matchesCondition = fileSize < int64(options.SmallerThan)*bytesInMegabyte
default:
matchesCondition = v == fileTypes[t]
}
if !matchesCondition {
matchesAllConditions = false
}
}
return true
}
func (idx *Index) getDirsInScope(scope string) []string {
newList := []string{}
idx.mu.Lock()
defer idx.mu.Unlock()
for k := range idx.Directories {
if strings.HasPrefix(k, scope) || scope == "" {
newList = append(newList, k)
}
return matchesAllConditions, fileTypes
}
// Clear variables and return
return false, map[string]bool{}
}
func getFileSize(filepath string) int64 {
fileInfo, err := os.Stat(rootPath + "/" + filepath)
if err != nil {
return 0
}
return fileInfo.Size()
}
func getLastPathComponent(path string) string {
// Use filepath.Base to extract the last component of the path
return filepath.Base(path)
}
func generateRandomHash(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
rand.New(rand.NewSource(time.Now().UnixNano()))
result := make([]byte, length)
for i := range result {
result[i] = charset[rand.Intn(len(charset))]
}
return string(result)
return newList
}
+190 -223
View File
@@ -4,14 +4,15 @@ import (
"reflect"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/stretchr/testify/assert"
)
func BenchmarkSearchAllIndexes(b *testing.B) {
InitializeIndex(5, false)
si := GetIndex(rootPath)
Initialize(settings.Source{Name: "test", Path: "/srv"})
idx := GetIndex("test")
si.createMockData(50, 3) // 1000 dirs, 3 files per dir
idx.createMockData(50, 3) // 50 dirs, 3 files per dir
// Generate 100 random search terms
searchTerms := generateRandomSearchTerms(100)
@@ -21,276 +22,242 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
for i := 0; i < b.N; i++ {
// Execute the SearchAllIndexes function
for _, term := range searchTerms {
si.Search(term, "/", "test")
idx.Search(term, "/", "test")
}
}
}
// loop over test files and compare output
func TestParseSearch(t *testing.T) {
value := ParseSearch("my test search")
want := &SearchOptions{
Conditions: map[string]bool{
"exact": false,
tests := []struct {
input string
want SearchOptions
}{
{
input: "my test search",
want: SearchOptions{
Conditions: map[string]bool{"exact": false},
Terms: []string{"my test search"},
},
},
Terms: []string{"my test search"},
}
if !reflect.DeepEqual(value, want) {
t.Fatalf("\n got: %+v\n want: %+v", value, want)
}
value = ParseSearch("case:exact my|test|search")
want = &SearchOptions{
Conditions: map[string]bool{
"exact": true,
{
input: "case:exact my|test|search",
want: SearchOptions{
Conditions: map[string]bool{"exact": true},
Terms: []string{"my", "test", "search"},
},
},
Terms: []string{"my", "test", "search"},
}
if !reflect.DeepEqual(value, want) {
t.Fatalf("\n got: %+v\n want: %+v", value, want)
}
value = ParseSearch("type:largerThan=100 type:smallerThan=1000 test")
want = &SearchOptions{
Conditions: map[string]bool{
"exact": false,
"larger": true,
{
input: "type:largerThan=100 type:smallerThan=1000 test",
want: SearchOptions{
Conditions: map[string]bool{"exact": false, "larger": true, "smaller": true},
Terms: []string{"test"},
LargerThan: 100,
SmallerThan: 1000,
},
},
Terms: []string{"test"},
LargerThan: 100,
SmallerThan: 1000,
}
if !reflect.DeepEqual(value, want) {
t.Fatalf("\n got: %+v\n want: %+v", value, want)
}
value = ParseSearch("type:audio thisfile")
want = &SearchOptions{
Conditions: map[string]bool{
"exact": false,
"audio": true,
{
input: "type:audio thisfile",
want: SearchOptions{
Conditions: map[string]bool{"exact": false, "audio": true},
Terms: []string{"thisfile"},
},
},
Terms: []string{"thisfile"},
}
if !reflect.DeepEqual(value, want) {
t.Fatalf("\n got: %+v\n want: %+v", value, want)
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
value := ParseSearch(tt.input)
if !reflect.DeepEqual(value, tt.want) {
t.Fatalf("\n got: %+v\n want: %+v", value, tt.want)
}
})
}
}
func TestSearchWhileIndexing(t *testing.T) {
InitializeIndex(5, false)
si := GetIndex(rootPath)
// Generate 100 random search terms
// Generate 100 random search terms
Initialize(settings.Source{Name: "test", Path: "/srv"})
idx := GetIndex("test")
searchTerms := generateRandomSearchTerms(10)
for i := 0; i < 5; i++ {
// Execute the SearchAllIndexes function
go si.createMockData(100, 100) // 1000 dirs, 3 files per dir
go idx.createMockData(100, 100) // Creating mock data concurrently
for _, term := range searchTerms {
go si.Search(term, "/", "test")
go idx.Search(term, "/", "test") // Search concurrently
}
}
}
func TestSearchIndexes(t *testing.T) {
index := Index{
Directories: map[string]Directory{
"test": {
Files: "audio1.wav;",
Directories: map[string]*FileInfo{
"/": {Files: []ItemInfo{{Name: "audio-one.wav", Type: "audio"}}},
"/test": {Files: []ItemInfo{{Name: "audio-one.wav", Type: "audio"}}},
"/test/path": {Files: []ItemInfo{{Name: "file.txt", Type: "text"}}},
"/new/test": {Files: []ItemInfo{
{Name: "audio.wav", Type: "audio"},
{Name: "video.mp4", Type: "video"},
{Name: "video.MP4", Type: "video"},
}},
"/first Dir": {
Files: []ItemInfo{
{Name: "space jam.zip", Size: 100, Type: "archive"},
},
},
"test/path": {
Files: "file.txt;",
"/new/test/path": {Files: []ItemInfo{{Name: "archive.zip", Type: "archive"}}},
"/firstDir": {
Files: []ItemInfo{
{Name: "archive.zip", Size: 100, Type: "archive"},
},
Folders: []ItemInfo{
{Name: "thisIsDir", Type: "directory", Size: 2 * 1024 * 1024},
},
},
"new": {},
"new/test": {
Files: "audio.wav;video.mp4;video.MP4;",
},
"new/test/path": {
Files: "archive.zip;",
"/firstDir/thisIsDir": {
Files: []ItemInfo{
{Name: "hi.txt", Type: "text"},
},
ItemInfo: ItemInfo{
Size: 2 * 1024 * 1024,
},
},
},
}
tests := []struct {
search string
scope string
expectedResult []string
expectedTypes map[string]map[string]bool
expectedResult []SearchResult
}{
{
search: "audio",
scope: "/new/",
expectedResult: []string{"test/audio.wav"},
expectedTypes: map[string]map[string]bool{
"test/audio.wav": map[string]bool{"audio": true, "dir": false},
search: "audio",
scope: "/new/",
expectedResult: []SearchResult{
{
Path: "/new/test/audio.wav",
Type: "audio",
Size: 0,
},
},
},
{
search: "test",
scope: "/",
expectedResult: []string{"test/", "new/test/"},
expectedTypes: map[string]map[string]bool{
"test/": map[string]bool{"dir": true},
"new/test/": map[string]bool{"dir": true},
search: "test",
scope: "/",
expectedResult: []SearchResult{
{
Path: "/test/",
Type: "directory",
Size: 0,
},
{
Path: "/new/test/",
Type: "directory",
Size: 0,
},
},
},
{
search: "archive",
scope: "/",
expectedResult: []string{"new/test/path/archive.zip"},
expectedTypes: map[string]map[string]bool{
"new/test/path/archive.zip": map[string]bool{"archive": true, "dir": false},
search: "archive",
scope: "/",
expectedResult: []SearchResult{
{
Path: "/firstDir/archive.zip",
Type: "archive",
Size: 100,
},
{
Path: "/new/test/path/archive.zip",
Type: "archive",
Size: 0,
},
},
},
{
search: "arch",
scope: "/firstDir",
expectedResult: []SearchResult{
{
Path: "/firstDir/archive.zip",
Type: "archive",
Size: 100,
},
},
},
{
search: "space jam",
scope: "/first Dir/",
expectedResult: []SearchResult{
{
Path: "/first Dir/space jam.zip",
Type: "archive",
Size: 100,
},
},
},
{
search: "isdir",
scope: "/",
expectedResult: []SearchResult{
{
Path: "/firstDir/thisIsDir/",
Type: "directory",
Size: 2097152,
},
},
},
{
search: "IsDir type:largerThan=1",
scope: "/",
expectedResult: []SearchResult{
{
Path: "/firstDir/thisIsDir/",
Type: "directory",
Size: 2097152,
},
},
},
{
search: "video",
scope: "/",
expectedResult: []string{
"new/test/video.mp4",
"new/test/video.MP4",
expectedResult: []SearchResult{
{
Path: "/new/test/video.MP4",
Type: "video",
Size: 0,
},
{
Path: "/new/test/video.mp4",
Type: "video",
Size: 0,
},
},
expectedTypes: map[string]map[string]bool{
"new/test/video.MP4": map[string]bool{"video": true, "dir": false},
"new/test/video.mp4": map[string]bool{"video": true, "dir": false},
},
{
search: "audio",
scope: "/",
expectedResult: []SearchResult{
{
Path: "/audio-one.wav",
Type: "audio",
Size: 0,
},
{
Path: "/test/audio-one.wav",
Type: "audio",
Size: 0,
},
{
Path: "/new/test/audio.wav",
Type: "audio",
Size: 0,
},
},
},
}
for _, tt := range tests {
t.Run(tt.search, func(t *testing.T) {
actualResult, actualTypes := index.Search(tt.search, tt.scope, "")
assert.Equal(t, tt.expectedResult, actualResult)
if !reflect.DeepEqual(tt.expectedTypes, actualTypes) {
t.Fatalf("\n got: %+v\n want: %+v", actualTypes, tt.expectedTypes)
}
})
}
}
func Test_scopedPathNameFilter(t *testing.T) {
tests := []struct {
name string
args struct {
pathName string
scope string
isDir bool // Assuming isDir should be included in args
}
want string
}{
{
name: "scope test",
args: struct {
pathName string
scope string
isDir bool
}{
pathName: "/",
scope: "/",
isDir: false,
},
want: "", // Update this with the expected result
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := scopedPathNameFilter(tt.args.pathName, tt.args.scope, tt.args.isDir); got != tt.want {
t.Errorf("scopedPathNameFilter() = %v, want %v", got, tt.want)
}
})
}
}
func Test_isDoc(t *testing.T) {
type args struct {
extension string
}
tests := []struct {
name string
args args
want bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isDoc(tt.args.extension); got != tt.want {
t.Errorf("isDoc() = %v, want %v", got, tt.want)
}
})
}
}
func Test_getFileSize(t *testing.T) {
type args struct {
filepath string
}
tests := []struct {
name string
args args
want int64
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := getFileSize(tt.args.filepath); got != tt.want {
t.Errorf("getFileSize() = %v, want %v", got, tt.want)
}
})
}
}
func Test_isArchive(t *testing.T) {
type args struct {
extension string
}
tests := []struct {
name string
args args
want bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isArchive(tt.args.extension); got != tt.want {
t.Errorf("isArchive() = %v, want %v", got, tt.want)
}
})
}
}
func Test_getLastPathComponent(t *testing.T) {
type args struct {
path string
}
tests := []struct {
name string
args args
want string
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := getLastPathComponent(tt.args.path); got != tt.want {
t.Errorf("getLastPathComponent() = %v, want %v", got, tt.want)
}
})
}
}
func Test_generateRandomHash(t *testing.T) {
type args struct {
length int
}
tests := []struct {
name string
args args
want string
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := generateRandomHash(tt.args.length); got != tt.want {
t.Errorf("generateRandomHash() = %v, want %v", got, tt.want)
}
result := index.Search(tt.search, tt.scope, "")
assert.ElementsMatch(t, tt.expectedResult, result)
})
}
}
+71 -127
View File
@@ -1,151 +1,95 @@
package files
import (
"io/fs"
"log"
"time"
"fmt"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
func (si *Index) UpdateFileMetadata(adjustedPath string, info FileInfo) bool {
si.mu.Lock()
defer si.mu.Unlock()
dir, exists := si.Directories[adjustedPath]
if !exists {
// Initialize the Metadata map if it is nil
if dir.Metadata == nil {
dir.Metadata = make(map[string]FileInfo)
}
si.Directories[adjustedPath] = dir
// Release the read lock before calling SetFileMetadata
}
return si.SetFileMetadata(adjustedPath, info)
}
// SetFileMetadata sets the FileInfo for the specified directory in the index.
// internal use only
func (si *Index) SetFileMetadata(adjustedPath string, info FileInfo) bool {
_, exists := si.Directories[adjustedPath]
if !exists {
return false
}
info.CacheTime = time.Now()
si.Directories[adjustedPath].Metadata[adjustedPath] = info
func (idx *Index) UpdateMetadata(info *FileInfo) bool {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.Directories[info.Path] = info
return true
}
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
func (si *Index) GetMetadataInfo(adjustedPath string) (FileInfo, bool) {
fi := FileInfo{}
si.mu.RLock()
dir, exists := si.Directories[adjustedPath]
si.mu.RUnlock()
if exists {
// Initialize the Metadata map if it is nil
if dir.Metadata == nil {
dir.Metadata = make(map[string]FileInfo)
si.SetDirectoryInfo(adjustedPath, dir)
} else {
fi = dir.Metadata[adjustedPath]
// GetMetadataInfo retrieves the FileInfo from the specified file or directory in the index.
func (idx *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
idx.mu.Lock()
defer idx.mu.Unlock()
checkDir := idx.MakeIndexPath(target)
if !isDir {
checkDir = idx.MakeIndexPath(filepath.Dir(target))
}
if checkDir == "" {
checkDir = "/"
}
dir, exists := idx.Directories[checkDir]
if !exists {
return nil, false
}
if isDir {
return dir, true
}
// handle file
if checkDir == "/" {
checkDir = ""
}
baseName := filepath.Base(target)
for _, item := range dir.Files {
if item.Name == baseName {
return &FileInfo{
Path: checkDir + "/" + item.Name,
ItemInfo: item,
}, true
}
}
return fi, exists
return nil, false
}
// SetDirectoryInfo sets the directory information in the index.
func (si *Index) SetDirectoryInfo(adjustedPath string, dir Directory) {
si.mu.Lock()
si.Directories[adjustedPath] = dir
si.mu.Unlock()
}
// SetDirectoryInfo sets the directory information in the index.
func (si *Index) GetDirectoryInfo(adjustedPath string) (Directory, bool) {
si.mu.RLock()
dir, exists := si.Directories[adjustedPath]
si.mu.RUnlock()
// raw directory info retrieval -- does not work on files, only returns a directory
func (idx *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
checkDir := idx.MakeIndexPath(target)
if !isDir {
checkDir = idx.MakeIndexPath(filepath.Dir(target))
}
if checkDir == "" {
checkDir = "/"
}
dir, exists := idx.Directories[checkDir]
return dir, exists
}
func (si *Index) RemoveDirectory(path string) {
si.mu.Lock()
defer si.mu.Unlock()
delete(si.Directories, path)
func (idx *Index) RemoveDirectory(path string) {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.NumDeleted++
delete(idx.Directories, path)
}
func (si *Index) UpdateCount(given string) {
si.mu.Lock()
defer si.mu.Unlock()
if given == "files" {
si.NumFiles++
} else if given == "dirs" {
si.NumDirs++
} else {
log.Println("could not update unknown type: ", given)
}
}
func (si *Index) resetCount() {
si.mu.Lock()
defer si.mu.Unlock()
si.NumDirs = 0
si.NumFiles = 0
si.inProgress = true
}
func GetIndex(root string) *Index {
for _, index := range indexes {
if index.Root == root {
return index
}
}
if settings.Config.Server.Root != "" {
rootPath = settings.Config.Server.Root
}
newIndex := &Index{
Root: rootPath,
Directories: make(map[string]Directory), // Initialize the map
NumDirs: 0,
NumFiles: 0,
inProgress: false,
}
func GetIndex(name string) *Index {
indexesMutex.Lock()
indexes = append(indexes, newIndex)
indexesMutex.Unlock()
return newIndex
}
func (si *Index) UpdateQuickList(files []fs.FileInfo) {
si.mu.Lock()
defer si.mu.Unlock()
si.quickList = []File{}
for _, file := range files {
newFile := File{
Name: file.Name(),
IsDir: file.IsDir(),
defer indexesMutex.Unlock()
index, ok := indexes[name]
if !ok {
// try path if name fails
// todo: update everywhere else so this isn't needed.
source, ok := settings.Config.Server.SourceMap[name]
if !ok {
logger.Error(fmt.Sprintf("index %s not found", name))
}
si.quickList = append(si.quickList, newFile)
}
}
func (si *Index) UpdateQuickListForTests(files []File) {
si.mu.Lock()
defer si.mu.Unlock()
si.quickList = []File{}
for _, file := range files {
newFile := File{
Name: file.Name,
IsDir: file.IsDir,
index, ok = indexes[source.Name]
if !ok {
logger.Error(fmt.Sprintf("index %s not found", name))
}
si.quickList = append(si.quickList, newFile)
}
}
func (si *Index) GetQuickList() []File {
si.mu.Lock()
defer si.mu.Unlock()
newQuickList := si.quickList
return newQuickList
}
return index
}
+173 -149
View File
@@ -1,119 +1,158 @@
package files
import (
"io/fs"
"os"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/stretchr/testify/assert"
)
// Mock for fs.FileInfo
type mockFileInfo struct {
name string
isDir bool
}
func (m mockFileInfo) Name() string { return m.name }
func (m mockFileInfo) Size() int64 { return 0 }
func (m mockFileInfo) Mode() os.FileMode { return 0 }
func (m mockFileInfo) ModTime() time.Time { return time.Now() }
func (m mockFileInfo) IsDir() bool { return m.isDir }
func (m mockFileInfo) Sys() interface{} { return nil }
var testIndex Index
// Test for GetFileMetadata
//func TestGetFileMetadata(t *testing.T) {
// t.Parallel()
// tests := []struct {
// name string
// adjustedPath string
// fileName string
// expectedName string
// expectedExists bool
// }{
// {
// name: "testpath exists",
// adjustedPath: "/testpath",
// fileName: "testfile.txt",
// expectedName: "testfile.txt",
// expectedExists: true,
// },
// {
// name: "testpath not exists",
// adjustedPath: "/testpath",
// fileName: "nonexistent.txt",
// expectedName: "",
// expectedExists: false,
// },
// {
// name: "File exists in /anotherpath",
// adjustedPath: "/anotherpath",
// fileName: "afile.txt",
// expectedName: "afile.txt",
// expectedExists: true,
// },
// {
// name: "File does not exist in /anotherpath",
// adjustedPath: "/anotherpath",
// fileName: "nonexistentfile.txt",
// expectedName: "",
// expectedExists: false,
// },
// {
// name: "Directory does not exist",
// adjustedPath: "/nonexistentpath",
// fileName: "testfile.txt",
// expectedName: "",
// expectedExists: false,
// },
// }
//
// for _, tt := range tests {
// t.Run(tt.name, func(t *testing.T) {
// fileInfo, exists := testIndex.GetFileMetadata(tt.adjustedPath)
// if exists != tt.expectedExists || fileInfo.Name != tt.expectedName {
// t.Errorf("expected %v:%v but got: %v:%v", tt.expectedName, tt.expectedExists, //fileInfo.Name, exists)
// }
// })
// }
//}
// Test for GetFileMetadata// Test for GetFileMetadata
func TestGetFileMetadataSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
adjustedPath string
expectedName string
expectedSize int64
}{
{
name: "testpath exists",
adjustedPath: "/testpath",
expectedName: "testfile.txt",
expectedSize: 100,
},
{
name: "testpath exists",
adjustedPath: "/testpath",
expectedName: "directory",
expectedSize: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, true)
// Iterate over fileInfo.Items to look for expectedName
for _, item := range fileInfo.Files {
// Assert the existence and the name
if item.Name == tt.expectedName {
assert.Equal(t, tt.expectedSize, item.Size)
break
}
}
})
}
}
// Test for GetFileMetadata// Test for GetFileMetadata
func TestGetFileMetadata(t *testing.T) {
t.Parallel()
tests := []struct {
name string
adjustedPath string
expectedName string
expectedExists bool
isDir bool
}{
{
name: "testpath exists",
adjustedPath: "/testpath/testfile.txt",
expectedName: "testfile.txt",
expectedExists: true,
},
{
name: "testpath not exists",
adjustedPath: "/testpath/nonexistent.txt",
expectedName: "nonexistent.txt",
expectedExists: false,
},
{
name: "File exists in /anotherpath",
adjustedPath: "/anotherpath/afile.txt",
expectedName: "afile.txt",
expectedExists: true,
},
{
name: "File does not exist in /anotherpath",
adjustedPath: "/anotherpath/nonexistentfile.txt",
expectedName: "nonexistentfile.txt",
expectedExists: false,
},
{
name: "Directory does not exist",
adjustedPath: "/nonexistentpath",
expectedName: "",
expectedExists: false,
isDir: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fileInfo, exists := testIndex.GetReducedMetadata(tt.adjustedPath, tt.isDir)
if !exists {
found := false
assert.Equal(t, tt.expectedExists, found)
return
}
found := false
if tt.isDir {
// Iterate over fileInfo.Items to look for expectedName
for _, item := range fileInfo.Files {
// Assert the existence and the name
if item.Name == tt.expectedName {
found = true
break
}
}
} else {
if fileInfo.Name == tt.expectedName {
found = true
}
}
assert.Equal(t, tt.expectedExists, found)
})
}
}
// Test for UpdateFileMetadata
func TestUpdateFileMetadata(t *testing.T) {
index := &Index{
Directories: map[string]Directory{
"/testpath": {
Metadata: map[string]FileInfo{
"testfile.txt": {Name: "testfile.txt"},
"anotherfile.txt": {Name: "anotherfile.txt"},
},
},
info := &FileInfo{
Path: "/testpath",
Files: []ItemInfo{
{Name: "testfile.txt"},
{Name: "anotherfile.txt"},
},
}
info := FileInfo{Name: "testfile.txt"}
index := &Index{
Directories: map[string]*FileInfo{
"/testpath": info,
},
}
success := index.UpdateFileMetadata("/testpath", info)
success := index.UpdateMetadata(info)
if !success {
t.Fatalf("expected UpdateFileMetadata to succeed")
}
dir, exists := index.Directories["/testpath"]
if !exists || dir.Metadata["testfile.txt"].Name != "testfile.txt" {
t.Fatalf("expected testfile.txt to be updated in the directory metadata")
fileInfo, exists := index.GetReducedMetadata("/testpath/testfile.txt", false)
if !exists || fileInfo.Name != "testfile.txt" {
t.Fatalf("expected '%v' to exist in the directory metadata. %v ", info.Name, exists)
}
}
// Test for GetDirMetadata
func TestGetDirMetadata(t *testing.T) {
t.Parallel()
_, exists := testIndex.GetMetadataInfo("/testpath")
_, exists := testIndex.GetReducedMetadata("/testpath", true)
if !exists {
t.Fatalf("expected GetDirMetadata to return initialized metadata map")
}
_, exists = testIndex.GetMetadataInfo("/nonexistent")
_, exists = testIndex.GetReducedMetadata("/nonexistent", true)
if exists {
t.Fatalf("expected GetDirMetadata to return false for nonexistent directory")
}
@@ -122,41 +161,41 @@ func TestGetDirMetadata(t *testing.T) {
// Test for SetDirectoryInfo
func TestSetDirectoryInfo(t *testing.T) {
index := &Index{
Directories: map[string]Directory{
Directories: map[string]*FileInfo{
"/testpath": {
Metadata: map[string]FileInfo{
"testfile.txt": {Name: "testfile.txt"},
"anotherfile.txt": {Name: "anotherfile.txt"},
Path: "/testpath",
ItemInfo: ItemInfo{
Name: "testpath",
Type: "directory",
},
Files: []ItemInfo{
{Name: "testfile.txt"},
{Name: "anotherfile.txt"},
},
},
},
}
dir := Directory{Metadata: map[string]FileInfo{"testfile.txt": {Name: "testfile.txt"}}}
index.SetDirectoryInfo("/newPath", dir)
dir := &FileInfo{
Path: "/newPath",
ItemInfo: ItemInfo{
Name: "newPath",
Type: "directory",
},
Files: []ItemInfo{
{Name: "testfile.txt"},
},
}
index.UpdateMetadata(dir)
storedDir, exists := index.Directories["/newPath"]
if !exists || storedDir.Metadata["testfile.txt"].Name != "testfile.txt" {
if !exists || storedDir.Files[0].Name != "testfile.txt" {
t.Fatalf("expected SetDirectoryInfo to store directory info correctly")
}
}
// Test for GetDirectoryInfo
func TestGetDirectoryInfo(t *testing.T) {
t.Parallel()
dir, exists := testIndex.GetDirectoryInfo("/testpath")
if !exists || dir.Metadata["testfile.txt"].Name != "testfile.txt" {
t.Fatalf("expected GetDirectoryInfo to return correct directory info")
}
_, exists = testIndex.GetDirectoryInfo("/nonexistent")
if exists {
t.Fatalf("expected GetDirectoryInfo to return false for nonexistent directory")
}
}
// Test for RemoveDirectory
func TestRemoveDirectory(t *testing.T) {
index := &Index{
Directories: map[string]Directory{
Directories: map[string]*FileInfo{
"/testpath": {},
},
}
@@ -167,54 +206,39 @@ func TestRemoveDirectory(t *testing.T) {
}
}
// Test for UpdateCount
func TestUpdateCount(t *testing.T) {
index := &Index{}
index.UpdateCount("files")
if index.NumFiles != 1 {
t.Fatalf("expected NumFiles to be 1 after UpdateCount('files')")
}
if index.NumFiles != 1 {
t.Fatalf("expected NumFiles to be 1 after UpdateCount('files')")
}
index.UpdateCount("dirs")
if index.NumDirs != 1 {
t.Fatalf("expected NumDirs to be 1 after UpdateCount('dirs')")
}
index.UpdateCount("unknown")
// Just ensure it does not panic or update any counters
if index.NumFiles != 1 || index.NumDirs != 1 {
t.Fatalf("expected counts to remain unchanged for unknown type")
}
index.resetCount()
if index.NumFiles != 0 || index.NumDirs != 0 || !index.inProgress {
t.Fatalf("expected resetCount to reset counts and set inProgress to true")
}
}
func init() {
testIndex = Index{
NumFiles: 10,
NumDirs: 5,
inProgress: false,
Directories: map[string]Directory{
Source: settings.Source{
Path: "/",
Name: "test",
},
NumFiles: 10,
NumDirs: 5,
Directories: map[string]*FileInfo{
"/testpath": {
Metadata: map[string]FileInfo{
"testfile.txt": {Name: "testfile.txt"},
"anotherfile.txt": {Name: "anotherfile.txt"},
Path: "/testpath",
ItemInfo: ItemInfo{
Name: "testpath",
Type: "directory",
},
Files: []ItemInfo{
{Name: "testfile.txt", Size: 100},
{Name: "anotherfile.txt", Size: 100},
},
},
"/anotherpath": {
Metadata: map[string]FileInfo{
"afile.txt": {Name: "afile.txt"},
Path: "/anotherpath",
ItemInfo: ItemInfo{
Name: "anotherpath",
Type: "directory",
},
Files: []ItemInfo{
{Name: "afile.txt", Size: 100},
},
Folders: []ItemInfo{
{Name: "directory", Type: "directory", Size: 100},
},
},
},
}
files := []fs.FileInfo{
mockFileInfo{name: "file1.txt", isDir: false},
mockFileInfo{name: "dir1", isDir: true},
}
testIndex.UpdateQuickList(files)
}
+49
View File
@@ -0,0 +1,49 @@
package files
import (
"fmt"
"os"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// todo fix this!!
// 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) error {
cleanedUserName := users.CleanUsername(u.Username)
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
logger.Error(fmt.Sprintf("create user: invalid user for home dir creation: [%s]", u.Username))
}
for i, scope := range u.Scopes {
source := settings.Config.Server.SourceMap[scope.Name]
if source.Config.CreateUserDir {
if !source.Config.CreateUserDir {
continue
}
fullPath := ""
if filepath.Base(scope.Scope) != cleanedUserName && !u.Perm.Admin {
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
fullPath = filepath.Join(source.Path, scope.Scope)
} else {
fullPath = filepath.Join(source.Path, scope.Scope, cleanedUserName)
}
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: failed to create user home dir: %s", err)
}
// update scope to reflect new user home dir
u.Scopes[i] = scope
}
}
return nil
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
)
// Copy copies a file or folder from one place to another.
func Copy(src, dst string) error {
func CopyHelper(src, dst string) error {
src = filepath.Clean(src)
if src == "" {
return os.ErrNotExist
+90 -9
View File
@@ -1,34 +1,61 @@
package fileutils
import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// 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 {
if os.Rename(src, dst) == nil {
err := os.Rename(src, dst)
if err == nil {
return nil
}
// fallback
err := CopyFile(src, dst)
err = CopyFile(src, dst)
if err != nil {
_ = os.Remove(dst)
return err
}
if err := os.Remove(src); err != nil {
logger.Error(fmt.Sprintf("CopyFile failed %v %v %v ", src, dst, err))
return err
}
go func() {
err = os.RemoveAll(src)
if err != nil {
logger.Error(fmt.Sprintf("os.Remove failed %v %v ", src, err))
}
}()
return nil
}
// CopyFile copies a file from source to dest and returns
// an error if any.
// 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 {
@@ -36,7 +63,7 @@ func CopyFile(source, dest string) error {
}
defer src.Close()
// Makes the directory needed to create the dst file.
// Create the destination directory if needed.
err = os.MkdirAll(filepath.Dir(dest), 0775) //nolint:gomnd
if err != nil {
return err
@@ -68,6 +95,43 @@ func CopyFile(source, dest string) error {
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.
@@ -112,3 +176,20 @@ func CommonPrefix(sep byte, paths ...string) string {
return string(c)
}
func ClearCacheDir() {
cacheDir := settings.Config.Server.CacheDir
entries, err := os.ReadDir(cacheDir)
if err != nil {
logger.Error(fmt.Sprintf("failed clear cache dir: %v", err))
}
for _, entry := range entries {
path := filepath.Join(cacheDir, entry.Name())
err = os.RemoveAll(path)
if err != nil {
logger.Error(fmt.Sprintf("failed clear cache dir: %v", err))
}
}
}
-1
View File
@@ -1 +0,0 @@
../../frontend/dist
-6
View File
@@ -1,6 +0,0 @@
{
"name": "frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+31 -33
View File
@@ -1,56 +1,54 @@
module github.com/gtsteffaniak/filebrowser
module github.com/gtsteffaniak/filebrowser/backend
go 1.22.5
go 1.23.0
toolchain go1.24.1
require (
github.com/asdine/storm/v3 v3.2.1
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568
github.com/goccy/go-yaml v1.12.0
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/google/go-cmp v0.6.0
github.com/gorilla/mux v1.8.1
github.com/marusama/semaphore/v2 v2.5.0
github.com/mholt/archiver/v3 v3.5.1
github.com/go-playground/validator/v10 v10.26.0
github.com/goccy/go-yaml v1.17.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/afero v1.11.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.9.0
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
golang.org/x/crypto v0.26.0
golang.org/x/image v0.19.0
golang.org/x/text v0.17.0
github.com/stretchr/testify v1.10.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.37.0
golang.org/x/image v0.26.0
golang.org/x/sys v0.32.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/fatih/color v1.17.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang/geo v0.0.0-20250410091149-9ff723126794 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.3.11 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.32.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+88 -93
View File
@@ -1,20 +1,15 @@
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
@@ -32,10 +27,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
@@ -45,148 +38,150 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/goccy/go-yaml v1.12.0 h1:/1WHjnMsI1dlIBQutrvSMGZRQufVO3asrHfTwfACoPM=
github.com/goccy/go-yaml v1.12.0/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I=
github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U=
github.com/golang/geo v0.0.0-20250410091149-9ff723126794 h1:sYWK0P7YCim7xkB7IqMX6k4YtrcqLVSdCZqmMnqhBAI=
github.com/golang/geo v0.0.0-20250410091149-9ff723126794/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/marusama/semaphore/v2 v2.5.0 h1:o/1QJD9DBYOWRnDhPwDVAXQn6mQYD0gZaS1Tpx6DJGM=
github.com/marusama/semaphore/v2 v2.5.0/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk=
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+162
View File
@@ -0,0 +1,162 @@
package http
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// createApiKeyHandler creates an API key for the user.
// @Summary Create API key
// @Description Create an API key with specified name, duration, and permissions.
// @Tags API Keys
// @Accept json
// @Produce json
// @Param name query string true "Name of the API key"
// @Param days query string true "Duration of the API key in days"
// @Param permissions query string true "Permissions for the API key (comma-separated)"
// @Success 200 {object} HttpResponse "Token created successfully, resoponse contains json object with token key"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 409 {object} map[string]string "Conflict"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/createApiKey [post]
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
durationStr := r.URL.Query().Get("days")
permissionsStr := r.URL.Query().Get("permissions")
if !d.user.Perm.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api keys")
}
if name == "" {
return http.StatusBadRequest, fmt.Errorf("api name must be valid")
}
if durationStr == "" {
return http.StatusBadRequest, fmt.Errorf("api duration must be valid")
}
if permissionsStr == "" {
return http.StatusBadRequest, fmt.Errorf("api permissions must be valid")
}
// Parse permissions from the query parameter
permissions := users.Permissions{
Api: strings.Contains(permissionsStr, "api") && d.user.Perm.Api,
Admin: strings.Contains(permissionsStr, "admin") && d.user.Perm.Admin,
Modify: strings.Contains(permissionsStr, "modify") && d.user.Perm.Modify,
Share: strings.Contains(permissionsStr, "share") && d.user.Perm.Share,
}
// Convert the duration string to an int64
durationInt, err := strconv.ParseInt(durationStr, 10, 64) // Base 10 and bit size of 64
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid duration value: %w", err)
}
// Here we assume the duration is in seconds; convert to time.Duration
duration := time.Duration(durationInt) * time.Hour * 24
// get request body like:
token, err := makeSignedTokenAPI(d.user, name, duration, permissions)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
}
return http.StatusInternalServerError, err
}
response := HttpResponse{
Message: "here is your token!",
Token: token.Key,
}
return renderJSON(w, r, response)
}
// deleteApiKeyHandler deletes an API key for the user.
// @Summary Delete API key
// @Description Delete an API key with specified name.
// @Tags API Keys
// @Accept json
// @Produce json
// @Param name query string true "Name of the API key to delete"
// @Success 200 {object} HttpResponse "API key deleted successfully"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/deleteApiKey [delete]
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
if !d.user.Perm.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api keys")
}
keyInfo, ok := d.user.ApiKeys[name]
if !ok {
return http.StatusNotFound, fmt.Errorf("api key not found")
}
// Perform the user update
err := store.Users.DeleteApiKey(d.user.ID, name)
if err != nil {
return http.StatusNotFound, err
}
revokeAPIKey(keyInfo.Key) // add to blacklist
response := HttpResponse{
Message: "successfully deleted api key from user",
}
return renderJSON(w, r, response)
}
type AuthTokenMin struct {
Key string `json:"key"`
Name string `json:"name"`
Created int64 `json:"created"`
Expires int64 `json:"expires"`
Permissions users.Permissions `json:"Permissions"`
}
// listApiKeysHandler lists all API keys or retrieves details for a specific key.
// @Summary List API keys
// @Description List all API keys or retrieve details for a specific key.
// @Tags API Keys
// @Accept json
// @Produce json
// @Param name query string false "Name of the API to retrieve details"
// @Success 200 {object} AuthTokenMin "List of API keys or specific key details"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/listApiKeys [get]
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
key := r.URL.Query().Get("name")
if !d.user.Perm.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api keys")
}
if key != "" {
keyInfo, ok := d.user.ApiKeys[key]
if !ok {
return http.StatusNotFound, fmt.Errorf("api key not found")
}
modifiedKey := AuthTokenMin{
Key: keyInfo.Key,
Name: key,
Created: keyInfo.Created,
Expires: keyInfo.Expires,
Permissions: keyInfo.Permissions,
}
return renderJSON(w, r, modifiedKey)
}
modifiedList := map[string]AuthTokenMin{}
for key, value := range d.user.ApiKeys {
modifiedList[key] = AuthTokenMin{
Key: value.Key,
Created: value.Created,
Expires: value.Expires,
Permissions: value.Permissions,
}
}
return renderJSON(w, r, modifiedList)
}
+201 -114
View File
@@ -2,103 +2,106 @@ package http
import (
"encoding/json"
"log"
libError "errors"
"fmt"
"net/http"
"os"
"net/url"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v4"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
type authToken struct {
User users.User `json:"user"`
jwt.RegisteredClaims
}
var (
revokedApiKeyList map[string]bool
revokeMu sync.Mutex
)
type extractor []string
func (e extractor) ExtractToken(r *http.Request) (string, error) {
token, _ := request.HeaderExtractor{"X-Auth"}.ExtractToken(r)
// Checks if the token isn't empty and if it contains two dots.
// The former prevents incompatibility with URLs that previously
// used basic auth.
if token != "" && strings.Count(token, ".") == 2 {
return token, nil
// first checks for cookie
// then checks for header Authorization as Bearer token
// then checks for query parameter
func extractToken(r *http.Request) (string, error) {
hasToken := false
tokenObj, err := r.Cookie("auth")
if err == nil {
hasToken = true
token := tokenObj.Value
// Checks if the token isn't empty and if it contains two dots.
// The former prevents incompatibility with URLs that previously
// used basic auth.
if token != "" && strings.Count(token, ".") == 2 {
return token, nil
}
}
auth := r.URL.Query().Get("auth")
if auth != "" && strings.Count(auth, ".") == 2 {
return auth, nil
if auth != "" {
hasToken = true
if strings.Count(auth, ".") == 2 {
return auth, nil
}
}
if r.Method == http.MethodGet {
cookie, _ := r.Cookie("auth")
if cookie != nil && strings.Count(cookie.Value, ".") == 2 {
return cookie.Value, nil
// Check for Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
hasToken = true
// Split the header to get "Bearer {token}"
parts := strings.Split(authHeader, " ")
if len(parts) == 2 && parts[0] == "Bearer" {
token := parts[1]
return token, nil
}
}
if hasToken {
return "", fmt.Errorf("invalid token provided")
}
return "", request.ErrNoTokenInRequest
}
func withUser(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return d.settings.Auth.Key, nil
}
var tk authToken
token, err := request.ParseFromRequest(r, &extractor{}, keyFunc, request.WithClaims(&tk))
if err != nil || !token.Valid {
return http.StatusUnauthorized, nil
}
expired := !tk.VerifyExpiresAt(time.Now().Add(time.Hour), true)
updated := tk.IssuedAt != nil && tk.IssuedAt.Unix() < d.store.Users.LastUpdate(tk.User.ID)
if expired || updated {
w.Header().Add("X-Renew-Token", "true")
}
d.user, err = d.store.Users.Get(d.server.Root, tk.User.ID)
if err != nil {
return http.StatusInternalServerError, err
}
return fn(w, r, d)
// loginHandler handles user authentication via password.
// @Summary User login
// @Description Authenticate a user with a username and password.
// @Tags Auth
// @Accept json
// @Produce json
// @Success 200 {string} string "JWT token for authentication"
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/login [post]
func loginHandler(w http.ResponseWriter, r *http.Request) {
if !config.Auth.Methods.PasswordAuth.Enabled {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
}
func withAdmin(fn handleFunc) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Admin {
return http.StatusForbidden, nil
}
return fn(w, r, d)
})
}
var loginHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
auther, err := d.store.Auth.Get(d.settings.Auth.Method)
// currently only supports user/pass
// Get the authentication method from the settings
auther, err := store.Auth.Get("password")
if err != nil {
return http.StatusInternalServerError, err
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
user, err := auther.Auth(r, d.store.Users)
if err == os.ErrPermission {
return http.StatusForbidden, nil
} else if err != nil {
return http.StatusInternalServerError, err
} else {
return printToken(w, r, d, user)
// Authenticate the user based on the request
user, err := auther.Auth(r, store.Users)
if err != nil {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
http.Error(w, http.StatusText(status), status)
}
}
@@ -107,73 +110,157 @@ type signupBody struct {
Password string `json:"password"`
}
var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !settings.Config.Auth.Signup {
return http.StatusMethodNotAllowed, nil
// signupHandler registers a new user account.
// @Summary User signup
// @Description Register a new user account with a username and password.
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body signupBody true "User signup details"
// @Success 201 {string} string "User created successfully"
// @Failure 400 {object} map[string]string "Bad request - invalid input"
// @Failure 405 {object} map[string]string "Method not allowed - signup is disabled"
// @Failure 409 {object} map[string]string "Conflict - user already exists"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/signup [post]
func signupHandler(w http.ResponseWriter, r *http.Request) {
if !settings.Config.Auth.Methods.PasswordAuth.Signup {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if r.Body == nil {
return http.StatusBadRequest, nil
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
info := &signupBody{}
err := json.NewDecoder(r.Body).Decode(info)
if err != nil {
return http.StatusBadRequest, err
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if info.Password == "" || info.Username == "" {
return http.StatusBadRequest, nil
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
user := users.ApplyDefaults(users.User{})
user := &users.User{}
settings.ApplyUserDefaults(user)
user.Username = info.Username
user.Password = info.Password
userHome, err := d.settings.MakeUserDir(user.Username, user.Scope, d.server.Root)
if err != nil {
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
return http.StatusInternalServerError, err
}
user.Scope = userHome
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
err = d.store.Users.Save(&user)
err = store.Users.Save(user, true)
if err == errors.ErrExist {
return http.StatusConflict, err
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
return
} else if err != nil {
return http.StatusInternalServerError, err
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
return http.StatusOK, nil
}
var renewHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
return printToken(w, r, d, d.user)
})
// renewHandler refreshes the authentication token for a logged-in user.
// @Summary Renew authentication token
// @Description Refresh the authentication token for a logged-in user.
// @Tags Auth
// @Accept json
// @Produce json
// @Success 200 {string} string "New JWT token generated"
// @Failure 401 {object} map[string]string "Unauthorized - invalid token"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/renew [post]
func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// check if x-auth header is present and token is
return printToken(w, r, d.user)
}
func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.User) (int, error) {
duration, err := time.ParseDuration(settings.Config.Auth.TokenExpirationTime)
if err != nil {
duration = time.Hour * 2
}
claims := &authToken{
User: *user,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
Issuer: "File Browser",
},
}
func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(d.settings.Auth.Key)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Perm)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
}
return http.StatusInternalServerError, err
}
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte(signed)); err != nil {
if _, err := w.Write([]byte(signed.Key)); err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
func isRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
}
func revokeAPIKey(key string) {
revokeMu.Lock()
delete(revokedApiKeyList, key)
revokeMu.Unlock()
}
func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions) (users.AuthToken, error) {
_, ok := user.ApiKeys[name]
if ok {
return users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
}
now := time.Now()
expires := now.Add(duration)
claim := users.AuthToken{
Permissions: perms,
Created: now.Unix(),
Expires: expires.Unix(),
Name: name,
BelongsTo: user.ID,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: "FileBrowser Quantum",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
tokenString, err := token.SignedString([]byte(config.Auth.Key))
if err != nil {
return claim, err
}
claim.Key = tokenString
if strings.HasPrefix(name, "WEB_TOKEN") {
// don't add to api tokens, its a short lived web token
return claim, err
}
// Perform the user update
err = store.Users.AddApiKey(user.ID, name, claim)
if err != nil {
return claim, err
}
return claim, err
}
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
if l.PasswordHash == "" {
return 200, nil
}
if r.URL.Query().Get("token") == l.Token {
return 200, nil
}
password := r.Header.Get("X-SHARE-PASSWORD")
password, err := url.QueryUnescape(password)
if err != nil {
return http.StatusUnauthorized, err
}
if password == "" {
return http.StatusUnauthorized, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
}
return 401, err
}
return 200, nil
}
-75
View File
@@ -1,75 +0,0 @@
package http
import (
"log"
"net/http"
"strconv"
"github.com/tomasen/realip"
"github.com/gtsteffaniak/filebrowser/runner"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
)
type handleFunc func(w http.ResponseWriter, r *http.Request, d *data) (int, error)
type data struct {
*runner.Runner
settings *settings.Settings
server *settings.Server
store *storage.Storage
user *users.User
raw interface{}
}
// Check implements rules.Checker.
func (d *data) Check(path string) bool {
allow := true
for _, rule := range d.settings.Rules {
if rule.Matches(path) {
allow = rule.Allow
}
}
for _, rule := range d.user.Rules {
if rule.Matches(path) {
allow = rule.Allow
}
}
return allow
}
func handle(fn handleFunc, prefix string, store *storage.Storage, server *settings.Server) http.Handler {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
settings, err := store.Settings.Get()
if err != nil {
log.Fatalf("ERROR: couldn't get settings: %v\n", err)
return
}
status, err := fn(w, r, &data{
Runner: &runner.Runner{Enabled: server.EnableExec, Settings: settings},
store: store,
settings: settings,
server: server,
})
if status >= 400 || err != nil {
clientIP := realip.FromRequest(r)
log.Printf("%s: %v %s %v", r.URL.Path, status, clientIP, err)
}
if status != 0 {
txt := http.StatusText(status)
http.Error(w, strconv.Itoa(status)+" "+txt, status)
return
}
})
return stripPrefix(prefix, handler)
}
+1
View File
@@ -0,0 +1 @@
*
-76
View File
@@ -1,76 +0,0 @@
package http
import (
"io/fs"
"net/http"
"github.com/gorilla/mux"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
)
type modifyRequest struct {
What string `json:"what"` // Answer to: what data type?
Which []string `json:"which"` // Answer to: which fields?
}
func NewHandler(
imgSvc ImgService,
fileCache FileCache,
store *storage.Storage,
server *settings.Server,
assetsFs fs.FS,
) (http.Handler, error) {
server.Clean()
r := mux.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
next.ServeHTTP(w, r)
})
})
index, static := getStaticHandlers(store, server, assetsFs)
// NOTE: This fixes the issue where it would redirect if people did not put a
// trailing slash in the end. I hate this decision since this allows some awful
// URLs https://www.gorillatoolkit.org/pkg/mux#Router.SkipClean
r = r.SkipClean(true)
monkey := func(fn handleFunc, prefix string) http.Handler {
return handle(fn, prefix, store, server)
}
r.HandleFunc("/health", healthHandler)
r.PathPrefix("/static").Handler(static)
r.NotFoundHandler = index
api := r.PathPrefix("/api").Subrouter()
api.Handle("/login", monkey(loginHandler, ""))
api.Handle("/signup", monkey(signupHandler, ""))
api.Handle("/renew", monkey(renewHandler, ""))
users := api.PathPrefix("/users").Subrouter()
users.Handle("", monkey(usersGetHandler, "")).Methods("GET")
users.Handle("", monkey(userPostHandler, "")).Methods("POST")
users.Handle("/{id:[0-9]+}", monkey(userPutHandler, "")).Methods("PUT")
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
api.PathPrefix("/resources").Handler(monkey(resourcePostHandler(fileCache), "/api/resources")).Methods("POST")
api.PathPrefix("/resources").Handler(monkey(resourcePutHandler, "/api/resources")).Methods("PUT")
api.PathPrefix("/resources").Handler(monkey(resourcePatchHandler(fileCache), "/api/resources")).Methods("PATCH")
api.PathPrefix("/usage").Handler(monkey(diskUsage, "/api/usage")).Methods("GET")
api.Path("/shares").Handler(monkey(shareListHandler, "/api/shares")).Methods("GET")
api.PathPrefix("/share").Handler(monkey(shareGetsHandler, "/api/share")).Methods("GET")
api.PathPrefix("/share").Handler(monkey(sharePostHandler, "/api/share")).Methods("POST")
api.PathPrefix("/share").Handler(monkey(shareDeleteHandler, "/api/share")).Methods("DELETE")
api.Handle("/settings", monkey(settingsGetHandler, "")).Methods("GET")
api.Handle("/settings", monkey(settingsPutHandler, "")).Methods("PUT")
api.PathPrefix("/raw").Handler(monkey(rawHandler, "/api/raw")).Methods("GET")
api.PathPrefix("/preview/{size}/{path:.*}").
Handler(monkey(previewHandler(imgSvc, fileCache, server.EnableThumbnails, server.ResizePreview), "/api/preview")).Methods("GET")
api.PathPrefix("/search").Handler(monkey(searchHandler, "/api/search")).Methods("GET")
public := api.PathPrefix("/public").Subrouter()
public.Handle("/publicUser", monkey(publicUserGetHandler, "")).Methods("GET")
public.PathPrefix("/dl").Handler(monkey(publicDlHandler, "/api/public/dl/")).Methods("GET")
public.PathPrefix("/share").Handler(monkey(publicShareHandler, "/api/public/share/")).Methods("GET")
return stripPrefix(server.BaseURL, r), nil
}
+368
View File
@@ -0,0 +1,368 @@
package http
import (
"compress/gzip"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type requestContext struct {
user *users.User
raw interface{}
path string
token string
share *share.Link
}
type HttpResponse struct {
Status int `json:"status,omitempty"`
Message string `json:"message,omitempty"`
Token string `json:"token,omitempty"`
}
var FileInfoFasterFunc = files.FileInfoFaster
// Updated handleFunc to match the new signature
type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error)
// Middleware to handle file requests by hash and pass it to the handler
func withHashFileHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
path := r.URL.Query().Get("path")
hash := r.URL.Query().Get("hash")
// Retrieve the user (using the public user by default)
data.user = &users.PublicUser
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share not found")
}
data.share = link
// Authenticate the share request if needed
var status int
if link.Hash != "" {
status, err = authenticateShareRequest(r, link)
if err != nil || status != http.StatusOK {
return status, fmt.Errorf("could not authenticate share request")
}
}
data.path = strings.TrimSuffix(link.Path, "/") + "/" + strings.TrimPrefix(path, "/")
if path == "" || path == "/" {
data.path = link.Path
}
// Get file information with options
file, err := FileInfoFasterFunc(files.FileOptions{
Path: data.path,
Source: link.Source,
Modify: false,
Expand: true,
})
file.Token = link.Token
if err != nil {
logger.Error(fmt.Sprintf("error fetching file info for share. hash=%v path=%v error=%v", hash, data.path, err))
return errToStatus(err), fmt.Errorf("error fetching share from server")
}
// Set the file info in the `data` object
data.raw = file
// Call the next handler with the data
return fn(w, r, data)
}
}
// Middleware to ensure the user is an admin
func withAdminHelper(fn handleFunc) handleFunc {
return withUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// Ensure the user has admin permissions
if !data.user.Perm.Admin {
return http.StatusForbidden, nil
}
return fn(w, r, data)
})
}
// Middleware to retrieve and authenticate user
func withUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
if config.Auth.Methods.NoAuth {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(uint(1))
if err != nil {
logger.Error(fmt.Sprintf("no auth: %v", err))
return http.StatusInternalServerError, err
}
return fn(w, r, data)
}
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(proxyUser)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
hashpass, err := users.HashPwd(proxyUser)
if err != nil {
return http.StatusInternalServerError, err
}
err = storage.CreateUser(users.User{
Username: proxyUser,
NonAdminEditable: users.NonAdminEditable{
Password: hashpass, // hashed password that can't actually be used
},
}, false)
if err != nil {
return http.StatusInternalServerError, err
}
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return http.StatusInternalServerError, err
}
} else {
return http.StatusUnauthorized, fmt.Errorf("proxy authentication failed - no user found")
}
}
setUserInResponseWriter(w, data.user)
return fn(w, r, data)
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(config.Auth.Key), nil
}
tokenString, err := extractToken(r)
if err != nil {
return http.StatusUnauthorized, err
}
data.token = tokenString
var tk users.AuthToken
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
if err != nil {
return http.StatusUnauthorized, fmt.Errorf("error processing token, %v", err)
}
if !token.Valid {
return http.StatusUnauthorized, fmt.Errorf("invalid token")
}
if isRevokedApiKey(tk.Key) || tk.Expires < time.Now().Unix() {
return http.StatusUnauthorized, fmt.Errorf("token expired or revoked")
}
// Check if the token is about to expire and send a header to renew it
if tk.Expires < time.Now().Add(time.Hour).Unix() {
w.Header().Add("X-Renew-Token", "true")
}
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(tk.BelongsTo)
if err != nil {
return http.StatusInternalServerError, err
}
setUserInResponseWriter(w, data.user)
// Call the handler function, passing in the context
return fn(w, r, data)
}
}
// Middleware to ensure the user is either the requested user or an admin
func withSelfOrAdminHelper(fn handleFunc) handleFunc {
return withUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// Check if the current user is the same as the requested user or if they are an admin
if !data.user.Perm.Admin {
return http.StatusForbidden, nil
}
// Call the actual handler function with the updated context
return fn(w, r, data)
})
}
func wrapHandler(fn handleFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := &requestContext{}
// Call the actual handler function and get status code and error
status, err := fn(w, r, data)
// Handle the error case if there is one
if err != nil {
// Create an error response in JSON format
response := &HttpResponse{
Status: status, // Use the status code from the middleware
Message: err.Error(),
}
// Set the content type to JSON and status code
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
// Marshal the error response to JSON
errorBytes, marshalErr := json.Marshal(response)
if marshalErr != nil {
logger.Error(fmt.Sprintf("Error marshalling error response: %v", marshalErr))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Write the JSON error response
if _, writeErr := w.Write(errorBytes); writeErr != nil {
logger.Debug(fmt.Sprintf("Error writing error response: %v", writeErr))
}
return
}
// No error, proceed to write status if non-zero
if status != 0 {
w.WriteHeader(status)
}
}
}
func withPermShareHelper(fn handleFunc) handleFunc {
return withUserHelper(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Perm.Share {
return http.StatusForbidden, nil
}
return fn(w, r, d)
})
}
func withPermShare(fn handleFunc) http.HandlerFunc {
return wrapHandler(withPermShareHelper(fn))
}
// Example of wrapping specific middleware functions for use with http.HandleFunc
func withHashFile(fn handleFunc) http.HandlerFunc {
return wrapHandler(withHashFileHelper(fn))
}
func withAdmin(fn handleFunc) http.HandlerFunc {
return wrapHandler(withAdminHelper(fn))
}
func withUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withUserHelper(fn))
}
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
return wrapHandler(withSelfOrAdminHelper(fn))
}
func muxWithMiddleware(mux *http.ServeMux) *http.ServeMux {
wrappedMux := http.NewServeMux()
wrappedMux.Handle("/", LoggingMiddleware(mux))
return wrappedMux
}
// ResponseWriterWrapper wraps the standard http.ResponseWriter to capture the status code
type ResponseWriterWrapper struct {
http.ResponseWriter
StatusCode int
wroteHeader bool
PayloadSize int
User string
}
// WriteHeader captures the status code and ensures it's only written once
func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
if !w.wroteHeader { // Prevent WriteHeader from being called multiple times
if statusCode == 0 {
statusCode = http.StatusInternalServerError
}
w.StatusCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
w.wroteHeader = true
}
}
// Write is the method to write the response body and ensure WriteHeader is called
func (w *ResponseWriterWrapper) Write(b []byte) (int, error) {
if !w.wroteHeader { // Default to 200 if WriteHeader wasn't called explicitly
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
// Helper function to set the user in the ResponseWriterWrapper
func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
// Wrap the response writer to set the user field
if wrappedWriter, ok := w.(*ResponseWriterWrapper); ok {
if user != nil {
wrappedWriter.User = user.Username
}
}
}
// LoggingMiddleware logs each request and its status code.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap the ResponseWriter to capture the status code.
wrappedWriter := &ResponseWriterWrapper{ResponseWriter: w, StatusCode: http.StatusOK}
// Call the next handler.
next.ServeHTTP(wrappedWriter, r)
// Capture the full URL path including the query parameters.
fullURL := r.URL.Path
if r.URL.RawQuery != "" {
fullURL += "?" + r.URL.RawQuery
}
truncUser := wrappedWriter.User
if len(truncUser) > 12 {
truncUser = truncUser[:10] + ".."
}
duration := time.Since(start)
logger.Api(
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode, // Captured status code
r.RemoteAddr,
truncUser,
fmt.Sprintf("%vms", duration.Milliseconds()),
fullURL), wrappedWriter.StatusCode)
})
}
func renderJSON(w http.ResponseWriter, r *http.Request, data interface{}) (int, error) {
marsh, err := json.Marshal(data)
if err != nil {
return http.StatusInternalServerError, err
}
// Calculate size in KB
payloadSizeKB := len(marsh) / 1024
// Check if the client accepts gzip encoding and hasn't explicitly disabled it
if acceptsGzip(r) && payloadSizeKB > 10 {
// Enable gzip compression
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
if _, err := gz.Write(marsh); err != nil {
return http.StatusInternalServerError, err
}
} else {
// Normal response without compression
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if _, err := w.Write(marsh); err != nil {
return http.StatusInternalServerError, err
}
}
return 0, nil
}
func acceptsGzip(r *http.Request) bool {
ae := r.Header.Get("Accept-Encoding")
return ae != "" && strings.Contains(ae, "gzip")
}
+271
View File
@@ -0,0 +1,271 @@
package http
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func setupTestEnv(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "db")
db, err := storm.Open(dbPath)
if err != nil {
t.Fatal(err)
}
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
if err != nil {
t.Fatal(err)
}
store = &storage.Storage{
Auth: authStore,
Users: userStore,
Share: shareStore,
Settings: settingsStore,
}
fileCache = diskcache.NewNoOp() // mocked
imgSvc = img.New(1) // mocked
config = &settings.Config // mocked
mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test
}
func mockFileInfoFaster(t *testing.T) {
// Backup the original function
originalFileInfoFaster := FileInfoFasterFunc
// Defer restoration of the original function
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
// Mock the function to skip execution
FileInfoFasterFunc = func(opts files.FileOptions) (files.ExtendedFileInfo, error) {
return files.ExtendedFileInfo{
FileInfo: files.FileInfo{
Path: opts.Path,
ItemInfo: files.ItemInfo{
Name: "mocked_file",
Size: 12345,
},
},
}, nil
}
}
func TestWithAdminHelper(t *testing.T) {
setupTestEnv(t)
// Mock a user who has admin permissions
adminUser := &users.User{
ID: 1,
Username: "admin",
Perm: users.Permissions{Admin: true}, // Ensure the user is an admin
}
nonAdminUser := &users.User{
ID: 2,
Username: "non-admin",
Perm: users.Permissions{Admin: false}, // Non-admin user
}
// Save the users to the mock database
if err := store.Users.Save(adminUser, true); err != nil {
t.Fatal("failed to save admin user:", err)
}
if err := store.Users.Save(nonAdminUser, true); err != nil {
t.Fatal("failed to save non-admin user:", err)
}
// Test cases for different scenarios
testCases := []struct {
name string
expectedStatusCode int
user *users.User
}{
{
name: "Admin access allowed",
expectedStatusCode: http.StatusOK, // Admin should be able to access
user: adminUser,
},
{
name: "Non-admin access forbidden",
expectedStatusCode: http.StatusForbidden, // Non-admin should be forbidden
user: nonAdminUser,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Mock the context with the current user
data := &requestContext{
user: tc.user,
}
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm)
if err != nil {
t.Fatalf("Error making token for request: %v", err)
}
// Wrap the usersGetHandler with the middleware
handler := withAdminHelper(mockHandler)
// Create a response recorder to capture the handler's output
recorder := httptest.NewRecorder()
// Create the request and apply the token as a cookie
req, err := http.NewRequest(http.MethodGet, "/users", http.NoBody)
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
req.AddCookie(&http.Cookie{
Name: "auth",
Value: token.Key,
})
// Call the handler with the test request and mock context
status, err := handler(recorder, req, data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the status code
if status != tc.expectedStatusCode {
t.Errorf("\"%v\" expected status code %d, got %d", tc.name, tc.expectedStatusCode, status)
}
})
}
}
func TestPublicShareHandlerAuthentication(t *testing.T) {
setupTestEnv(t)
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" // bcrypt hashed password
testCases := []struct {
name string
share *share.Link
token string
password string
extraHeaders map[string]string
expectedStatusCode int
}{
{
name: "Public share, no auth required",
share: &share.Link{
Hash: "public_hash",
},
expectedStatusCode: 0, // zero means 200 on helpers
},
{
name: "Private share, no auth provided",
share: &share.Link{
Hash: "private_hash",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
},
expectedStatusCode: http.StatusUnauthorized,
},
{
name: "Private share, valid token",
share: &share.Link{
Hash: "token_hash",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
},
token: "123",
expectedStatusCode: 0, // zero means 200 on helpers
},
{
name: "Private share, invalid password",
share: &share.Link{
Hash: "pw_hash",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
},
extraHeaders: map[string]string{
"X-SHARE-PASSWORD": "wrong-password",
},
expectedStatusCode: http.StatusUnauthorized,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Save the share in the mock store
if err := store.Share.Save(tc.share); err != nil {
t.Fatal("failed to save share:", err)
}
// Create a response recorder to capture handler output
recorder := httptest.NewRecorder()
// Wrap the handler with authentication middleware
handler := withHashFileHelper(publicShareHandler)
if err := store.Settings.Save(&settings.Settings{
Auth: settings.Auth{
Key: "key",
},
}); err != nil {
t.Fatalf("failed to save settings: %v", err)
}
// Prepare the request with query parameters and optional headers
req := newTestRequest(t, tc.share.Hash, tc.token, tc.password, tc.extraHeaders)
// Serve the request
status, _ := handler(recorder, req, &requestContext{})
// Check if the response matches the expected status code
if status != tc.expectedStatusCode {
t.Errorf("expected status code %d, got %d", tc.expectedStatusCode, status)
}
})
}
}
// Helper function to create a new HTTP request with optional parameters
func newTestRequest(t *testing.T, hash, token, password string, headers map[string]string) *http.Request {
req := newHTTPRequest(t, hash, func(r *http.Request) {
// Set query parameters based on provided values
q := r.URL.Query()
q.Set("path", "/")
q.Set("hash", hash)
if token != "" {
q.Set("token", token)
}
if password != "" {
q.Set("password", password)
}
r.URL.RawQuery = q.Encode()
// Set any extra headers if provided
for key, value := range headers {
r.Header.Set(key, value)
}
})
return req
}
func mockHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
return http.StatusOK, nil // mock response
}
// Modify newHTTPRequest to accept the hash and use it in the URL path.
func newHTTPRequest(t *testing.T, hash string, requestModifiers ...func(*http.Request)) *http.Request {
t.Helper()
url := "/public/share/" + hash + "/" // Dynamically include the hash in the URL path
r, err := http.NewRequest(http.MethodGet, url, http.NoBody)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
for _, modify := range requestModifiers {
modify(r)
}
return r
}
+210
View File
@@ -0,0 +1,210 @@
package http
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
const (
onlyOfficeStatusDocumentClosedWithChanges = 2
onlyOfficeStatusDocumentClosedWithNoChanges = 4
onlyOfficeStatusForceSaveWhileDocumentStillOpen = 6
)
type OnlyOfficeCallback struct {
ChangesURL string `json:"changesurl,omitempty"`
Key string `json:"key,omitempty"`
Status int `json:"status,omitempty"`
URL string `json:"url,omitempty"`
Users []string `json:"users,omitempty"`
UserData string `json:"userdata,omitempty"`
}
func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if settings.Config.Integrations.OnlyOffice.Url == "" {
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
}
encodedUrl := r.URL.Query().Get("url")
// Decode the URL-encoded path
url, err := url.QueryUnescape(encodedUrl)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// get path from url
pathParts := strings.Split(url, "/api/raw?files=")
sourceFile := pathParts[len(pathParts)-1]
sourceSplit := strings.Split(sourceFile, "::")
if len(sourceSplit) != 2 {
return http.StatusBadRequest, fmt.Errorf("invalid url path %v", url)
}
source := sourceSplit[0]
path := sourceSplit[1]
urlFirst := pathParts[0]
if settings.Config.Server.InternalUrl != "" {
urlFirst = strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
replacement := strings.Split(url, "/api/raw")[0]
url = strings.Replace(url, replacement, settings.Config.Server.InternalUrl, 1)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: false,
})
if err != nil {
return errToStatus(err), err
}
id, err := getOnlyOfficeId(source, fileInfo.Path)
if err != nil {
return http.StatusNotFound, err
}
split := strings.Split(fileInfo.Name, ".")
fileType := split[len(split)-1]
theme := "light"
if d.user.DarkMode {
theme = "dark"
}
callbackURL := fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, path, d.token)
clientConfig := map[string]interface{}{
"document": map[string]interface{}{
"fileType": fileType,
"key": id,
"title": fileInfo.Name,
"url": url + "&auth=" + d.token,
"permissions": map[string]interface{}{
"edit": d.user.Perm.Modify,
"download": true,
"print": true,
},
},
"editorConfig": map[string]interface{}{
"callbackUrl": callbackURL,
"user": map[string]interface{}{
"id": strconv.FormatUint(uint64(d.user.ID), 10),
"name": d.user.Username,
},
"customization": map[string]interface{}{
"autosave": true,
"forcesave": true,
"uiTheme": theme,
},
"lang": d.user.Locale,
"mode": "edit",
},
}
if settings.Config.Integrations.OnlyOffice.Secret != "" {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(clientConfig))
signature, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret))
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to sign JWT")
}
clientConfig["token"] = signature
}
return renderJSON(w, r, clientConfig)
}
func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, err
}
var data OnlyOfficeCallback
err = json.Unmarshal(body, &data)
if err != nil {
return http.StatusInternalServerError, err
}
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
data.Status == onlyOfficeStatusDocumentClosedWithNoChanges {
// Refer to only-office documentation
// - https://api.onlyoffice.com/editors/coedit
// - https://api.onlyoffice.com/editors/callback
//
// When the document is fully closed by all editors,
// then the document key should no longer be re-used.
deleteOfficeId(source, path)
}
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen {
if !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
doc, err := http.Get(data.URL)
if err != nil {
return http.StatusInternalServerError, err
}
defer doc.Body.Close()
fileOpts := files.FileOptions{
Path: path,
Source: source,
}
writeErr := files.WriteFile(fileOpts, doc.Body)
if writeErr != nil {
return http.StatusInternalServerError, writeErr
}
}
resp := map[string]int{
"error": 0,
}
return renderJSON(w, r, resp)
}
func getOnlyOfficeId(source, path string) (string, error) {
idx := files.GetIndex(source)
if idx == nil {
return "", fmt.Errorf("source not found")
}
realpath, _, _ := idx.GetRealPath(path)
// error is intentionally ignored in order treat errors
// the same as a cache-miss
cachedDocumentKey, ok := cache.OnlyOffice.Get(realpath).(string)
if ok {
return cachedDocumentKey, nil
}
return "", fmt.Errorf("document key not found")
}
func deleteOfficeId(source, path string) {
idx := files.GetIndex(source)
if idx == nil {
logger.Error(fmt.Sprintf("deleteOfficeId: failed to find source index for user home dir creation: %s", source))
return
}
realpath, _, _ := idx.GetRealPath(path)
cache.OnlyOffice.Delete(realpath)
}
+95 -76
View File
@@ -1,4 +1,3 @@
//go:generate go-enum --sql --marshal --names --file $GOFILE
package http
import (
@@ -8,21 +7,17 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/img"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
/*
ENUM(
thumb
big
)
*/
type PreviewSize int
type ImgService interface {
FormatFromExtension(ext string) (img.Format, error)
Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...img.Option) error
@@ -34,83 +29,87 @@ type FileCache interface {
Delete(ctx context.Context, key string) error
}
func previewHandler(imgSvc ImgService, fileCache FileCache, enableThumbnails, resizePreview bool) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
vars := mux.Vars(r)
previewSize, err := ParsePreviewSize(vars["size"])
if err != nil {
return http.StatusBadRequest, err
}
file, err := files.FileInfoFaster(files.FileOptions{
Path: "/" + vars["path"],
Modify: d.user.Perm.Modify,
Expand: true,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
})
if err != nil {
return errToStatus(err), err
}
setContentDisposition(w, r, file)
switch file.Type {
case "image":
return handleImagePreview(w, r, imgSvc, fileCache, file, previewSize, enableThumbnails, resizePreview)
default:
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type)
}
})
}
func handleImagePreview(
w http.ResponseWriter,
r *http.Request,
imgSvc ImgService,
fileCache FileCache,
file *files.FileInfo,
previewSize PreviewSize,
enableThumbnails, resizePreview bool,
) (int, error) {
if (previewSize == PreviewSizeBig && !resizePreview) ||
(previewSize == PreviewSizeThumb && !enableThumbnails) {
return rawFileHandler(w, r, file)
// previewHandler handles the preview request for images.
// @Summary Get image preview
// @Description Returns a preview image based on the requested path and size.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "File path of the image to preview"
// @Param size query string false "Preview size ('small' or 'large'). Default is based on server config."
// @Success 200 {file} file "Preview image content"
// @Failure 202 {object} map[string]string "Download permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File not found"
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/preview [get]
func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
format, err := imgSvc.FormatFromExtension(file.Extension)
previewSize := r.URL.Query().Get("size")
if previewSize != "small" {
previewSize = "large"
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
})
if err != nil {
return errToStatus(err), err
}
if fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
}
setContentDisposition(w, r, fileInfo.Name)
if !strings.HasPrefix(fileInfo.Type, "image") {
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", fileInfo.Type)
}
if (previewSize == "large" && !config.Server.ResizePreview) ||
(previewSize == "small" && !config.Server.EnableThumbnails) {
return rawFileHandler(w, r, fileInfo)
}
format, err := imgSvc.FormatFromExtension(filepath.Ext(fileInfo.Name))
// Unsupported extensions directly return the raw data
if err == img.ErrUnsupportedFormat || format == img.FormatGif {
return rawFileHandler(w, r, file)
return rawFileHandler(w, r, fileInfo)
}
if err != nil {
return errToStatus(err), err
}
cacheKey := previewCacheKey(file, previewSize)
cacheKey := previewCacheKey(fileInfo.RealPath, previewSize, fileInfo.ModTime)
resizedImage, ok, err := fileCache.Load(r.Context(), cacheKey)
if err != nil {
return errToStatus(err), err
}
if !ok {
resizedImage, err = createPreview(imgSvc, fileCache, file, previewSize)
resizedImage, err = createPreview(imgSvc, fileCache, fileInfo, previewSize)
if err != nil {
return errToStatus(err), err
}
}
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, file.Name, file.ModTime, bytes.NewReader(resizedImage))
http.ServeContent(w, r, fileInfo.RealPath, fileInfo.ModTime, bytes.NewReader(resizedImage))
return 0, nil
}
func createPreview(imgSvc ImgService, fileCache FileCache,
file *files.FileInfo, previewSize PreviewSize) ([]byte, error) {
fd, err := os.Open(file.Path)
func createPreview(imgSvc ImgService, fileCache FileCache, file files.ExtendedFileInfo, previewSize string) ([]byte, error) {
fd, err := os.Open(file.RealPath)
if err != nil {
return nil, err
}
@@ -123,11 +122,11 @@ func createPreview(imgSvc ImgService, fileCache FileCache,
)
switch {
case previewSize == PreviewSizeBig:
case previewSize == "large":
width = 1080
height = 1080
options = append(options, img.WithMode(img.ResizeModeFit), img.WithQuality(img.QualityMedium))
case previewSize == PreviewSizeThumb:
case previewSize == "small":
width = 256
height = 256
options = append(options, img.WithMode(img.ResizeModeFill), img.WithQuality(img.QualityLow), img.WithFormat(img.FormatJpeg))
@@ -141,15 +140,35 @@ func createPreview(imgSvc ImgService, fileCache FileCache,
}
go func() {
cacheKey := previewCacheKey(file, previewSize)
cacheKey := previewCacheKey(file.RealPath, previewSize, file.FileInfo.ModTime)
if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil {
fmt.Printf("failed to cache resized image: %v", err)
logger.Error(fmt.Sprintf("failed to cache resized image: %v", err))
}
}()
return buf.Bytes(), nil
}
func previewCacheKey(f *files.FileInfo, previewSize PreviewSize) string {
return fmt.Sprintf("%x%x%x", f.RealPath(), f.ModTime.Unix(), previewSize)
// Generates a cache key for the preview image
func previewCacheKey(realPath, previewSize string, modTime time.Time) string {
return fmt.Sprintf("%x%x%x", realPath, modTime.Unix(), previewSize)
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file files.ExtendedFileInfo) (int, error) {
idx := files.GetIndex(file.Source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
}
realPath, _, _ := idx.GetRealPath(file.Path)
fd, err := os.Open(realPath)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
setContentDisposition(w, r, file.Name)
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, file.Name, file.ModTime, fd)
return 0, nil
}
-100
View File
@@ -1,100 +0,0 @@
// Code generated by go-enum
// DO NOT EDIT!
package http
import (
"database/sql/driver"
"fmt"
"strings"
)
const (
// PreviewSizeThumb is a PreviewSize of type Thumb
PreviewSizeThumb PreviewSize = iota
// PreviewSizeBig is a PreviewSize of type Big
PreviewSizeBig
)
const _PreviewSizeName = "thumbbig"
var _PreviewSizeNames = []string{
_PreviewSizeName[0:5],
_PreviewSizeName[5:8],
}
// PreviewSizeNames returns a list of possible string values of PreviewSize.
func PreviewSizeNames() []string {
tmp := make([]string, len(_PreviewSizeNames))
copy(tmp, _PreviewSizeNames)
return tmp
}
var _PreviewSizeMap = map[PreviewSize]string{
0: _PreviewSizeName[0:5],
1: _PreviewSizeName[5:8],
}
// String implements the Stringer interface.
func (x PreviewSize) String() string {
if str, ok := _PreviewSizeMap[x]; ok {
return str
}
return fmt.Sprintf("PreviewSize(%d)", x)
}
var _PreviewSizeValue = map[string]PreviewSize{
_PreviewSizeName[0:5]: 0,
_PreviewSizeName[5:8]: 1,
}
// ParsePreviewSize attempts to convert a string to a PreviewSize
func ParsePreviewSize(name string) (PreviewSize, error) {
if x, ok := _PreviewSizeValue[name]; ok {
return x, nil
}
return PreviewSize(0), fmt.Errorf("%s is not a valid PreviewSize, try [%s]", name, strings.Join(_PreviewSizeNames, ", "))
}
// MarshalText implements the text marshaller method
func (x PreviewSize) MarshalText() ([]byte, error) {
return []byte(x.String()), nil
}
// UnmarshalText implements the text unmarshaller method
func (x *PreviewSize) UnmarshalText(text []byte) error {
name := string(text)
tmp, err := ParsePreviewSize(name)
if err != nil {
return err
}
*x = tmp
return nil
}
// Scan implements the Scanner interface.
func (x *PreviewSize) Scan(value interface{}) error {
var name string
switch v := value.(type) {
case string:
name = v
case []byte:
name = string(v)
case nil:
*x = PreviewSize(0)
return nil
}
tmp, err := ParsePreviewSize(name)
if err != nil {
return err
}
*x = tmp
return nil
}
// Value implements the driver Valuer interface.
func (x PreviewSize) Value() (driver.Value, error) {
return x.String(), nil
}
+66 -98
View File
@@ -1,124 +1,92 @@
package http
import (
"errors"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/share"
"github.com/gtsteffaniak/filebrowser/users"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
)
var withHashFile = func(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
id, path := ifPathWithName(r)
link, err := d.store.Share.GetByHash(id)
if err != nil {
return errToStatus(err), err
}
if link.Hash != "" {
var status int
status, err = authenticateShareRequest(r, link)
if err != nil || status != 0 {
return status, err
}
}
d.user = &users.PublicUser
realPath, isDir, err := files.GetRealPath(d.user.Scope, link.Path, path)
if err != nil {
return http.StatusNotFound, err
}
file, err := files.FileInfoFaster(files.FileOptions{
Path: realPath,
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: true,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
Token: link.Token,
})
if err != nil {
return errToStatus(err), err
}
d.raw = file
return fn(w, r, d)
// rawHandler serves the raw content of a file, multiple files, or directory in various formats.
// @Summary Get raw content of a file, multiple files, or directory
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
// @Tags Resources
// @Accept json
// @Produce json
// @Param files query string true "a list of files in the following format 'filename' and separated by '||' with additional items in the list. (required)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Modify permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File or directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/dl [get]
func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedFiles := r.URL.Query().Get("files")
// Decode the URL-encoded path
f, err := url.QueryUnescape(encodedFiles)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
}
func ifPathWithName(r *http.Request) (id, filePath string) {
pathElements := strings.Split(r.URL.Path, "/")
id = pathElements[0]
allButFirst := path.Join(pathElements[1:]...)
return id, allButFirst
}
var publicShareHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
file, ok := d.raw.(*files.FileInfo)
fileInfo, ok := d.raw.(files.ExtendedFileInfo)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type *files.FileInfo")
logger.Error("public share handler: failed to assert type files.FileInfo")
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
}
file.Path = strings.TrimPrefix(file.Path, settings.Config.Server.Root)
if file.IsDir {
return renderJSON(w, r, file)
fileList := []string{}
for _, file := range strings.Split(f, "||") {
fileList = append(fileList, fileInfo.Source+"::"+fileInfo.Path+file)
}
var status int
status, err = rawFilesHandler(w, r, d, fileList)
if err != nil {
logger.Error(fmt.Sprintf("public share handler: error processing filelist: %v", err))
return status, fmt.Errorf("error processing filelist: %v", f)
}
return status, nil
}
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
file, ok := d.raw.(files.ExtendedFileInfo)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
}
file.Path = strings.TrimPrefix(file.Path, file.Source)
return renderJSON(w, r, file)
})
}
var publicUserGetHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
func publicUserGetHandler(w http.ResponseWriter, r *http.Request) {
// Call the actual handler logic here (e.g., renderJSON, etc.)
// You may need to replace `fn` with the actual handler logic.
return renderJSON(w, r, users.PublicUser)
}
var publicDlHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
file, ok := d.raw.(*files.FileInfo)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type *files.FileInfo")
}
if !file.IsDir {
return rawFileHandler(w, r, file)
}
return rawDirHandler(w, r, d, file)
})
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
if l.PasswordHash == "" {
return 0, nil
}
if r.URL.Query().Get("token") == l.Token {
return 0, nil
}
password := r.Header.Get("X-SHARE-PASSWORD")
password, err := url.QueryUnescape(password)
status, err := renderJSON(w, r, users.PublicUser)
if err != nil {
return 0, err
http.Error(w, http.StatusText(status), status)
}
if password == "" {
return http.StatusUnauthorized, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
}
return 0, err
}
return 0, nil
}
func healthHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"OK"}`))
// health godoc
// @Summary Health Check
// @Schemes
// @Description Returns the health status of the API.
// @Tags Health
// @Accept json
// @Produce json
// @Success 200 {object} HttpResponse "successful health check response"
// @Router /health [get]
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
response := HttpResponse{Message: "ok"} // Create response with status "ok"
err := json.NewEncoder(w).Encode(response) // Encode the response into JSON
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
-131
View File
@@ -1,131 +0,0 @@
package http
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/share"
"github.com/gtsteffaniak/filebrowser/storage/bolt"
"github.com/gtsteffaniak/filebrowser/users"
)
func TestPublicShareHandlerAuthentication(t *testing.T) {
t.Parallel()
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" //nolint:gosec
testCases := map[string]struct {
share *share.Link
req *http.Request
expectedStatusCode int
}{
"Public share, no auth required": {
share: &share.Link{Hash: "h"},
req: newHTTPRequest(t),
expectedStatusCode: 200,
},
"Private share, no auth provided, 401": {
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
req: newHTTPRequest(t),
expectedStatusCode: 401,
},
"Private share, authentication via token": {
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=123" }),
expectedStatusCode: 200,
},
"Private share, authentication via invalid token, 401": {
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=1234" }),
expectedStatusCode: 401,
},
"Private share, authentication via password": {
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "password") }),
expectedStatusCode: 200,
},
"Private share, authentication via invalid password, 401": {
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "wrong-password") }),
expectedStatusCode: 401,
},
}
for name, tc := range testCases {
for handlerName, handler := range map[string]handleFunc{"public share handler": publicShareHandler, "public dl handler": publicDlHandler} {
name, tc, handlerName, handler := name, tc, handlerName, handler
t.Run(fmt.Sprintf("%s: %s", handlerName, name), func(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "db")
db, err := storm.Open(dbPath)
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil { //nolint:govet
t.Errorf("failed to close db: %v", err)
}
})
storage, err := bolt.NewStorage(db)
if err != nil {
t.Fatalf("failed to get storage: %v", err)
}
if err := storage.Share.Save(tc.share); err != nil {
t.Fatalf("failed to save share: %v", err)
}
if err := storage.Settings.Save(&settings.Settings{
Auth: settings.Auth{
Key: []byte("key"),
},
}); err != nil {
t.Fatalf("failed to save settings: %v", err)
}
storage.Users = &customFSUser{
Store: storage.Users,
}
recorder := httptest.NewRecorder()
handler := handle(handler, "", storage, &settings.Server{})
handler.ServeHTTP(recorder, tc.req)
result := recorder.Result()
defer result.Body.Close()
if result.StatusCode != tc.expectedStatusCode {
t.Errorf("expected status code %d, got status code %d", tc.expectedStatusCode, result.StatusCode)
}
})
}
}
}
func newHTTPRequest(t *testing.T, requestModifiers ...func(*http.Request)) *http.Request {
t.Helper()
r, err := http.NewRequest(http.MethodGet, "h", http.NoBody)
if err != nil {
t.Fatalf("failed to construct request: %v", err)
}
for _, modify := range requestModifiers {
modify(r)
}
return r
}
type customFSUser struct {
users.Store
}
func (cu *customFSUser) Get(baseScope string, id interface{}) (*users.User, error) {
user, err := cu.Store.Get(baseScope, id)
if err != nil {
return nil, err
}
return user, nil
}
+338 -159
View File
@@ -1,216 +1,395 @@
package http
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"errors"
"log"
"fmt"
"io"
"net/http"
"net/url"
"os"
gopath "path"
"path/filepath"
"strings"
"github.com/mholt/archiver/v3"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/fileutils"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func slashClean(name string) string {
if name == "" || name[0] != '/' {
name = "/" + name
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline; filename*=utf-8''"+url.PathEscape(fileName))
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName))
}
return gopath.Clean(name)
}
func parseQueryFiles(r *http.Request, f *files.FileInfo, _ *users.User) ([]string, error) {
var fileSlice []string
names := strings.Split(r.URL.Query().Get("files"), ",")
// rawHandler serves the raw content of a file, multiple files, or directory in various formats.
// @Summary Get raw content of a file, multiple files, or directory
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
// @Tags Resources
// @Accept json
// @Produce json
// @Param files query string true "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Modify permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File or directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/raw [get]
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedFiles := r.URL.Query().Get("files")
// Decode the URL-encoded path
files, err := url.QueryUnescape(encodedFiles)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fileList := strings.Split(files, "||")
return rawFilesHandler(w, r, d, fileList)
}
if len(names) == 0 {
fileSlice = append(fileSlice, f.Path)
} else {
for _, name := range names {
name, err := url.QueryUnescape(strings.Replace(name, "+", "%2B", -1)) //nolint:govet
if err != nil {
return nil, err
}
name = slashClean(name)
fileSlice = append(fileSlice, filepath.Join(f.Path, name))
func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
splitFile := strings.Split(path, "::")
if len(splitFile) != 2 {
return fmt.Errorf("invalid file in files requested: %v", splitFile)
}
source := splitFile[0]
path = splitFile[1]
var err error
userScope := "/"
if d.user.Username != "publicUser" {
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
return fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
}
return fileSlice, nil
}
// nolint: goconst,nolintlint
func parseQueryAlgorithm(r *http.Request) (string, archiver.Writer, error) {
// TODO: use enum
switch r.URL.Query().Get("algo") {
case "zip", "true", "":
return ".zip", archiver.NewZip(), nil
case "tar":
return ".tar", archiver.NewTar(), nil
case "targz":
return ".tar.gz", archiver.NewTarGz(), nil
case "tarbz2":
return ".tar.bz2", archiver.NewTarBz2(), nil
case "tarxz":
return ".tar.xz", archiver.NewTarXz(), nil
case "tarlz4":
return ".tar.lz4", archiver.NewTarLz4(), nil
case "tarsz":
return ".tar.sz", archiver.NewTarSz(), nil
default:
return "", nil, errors.New("format not implemented")
idx := files.GetIndex(source)
if idx == nil {
return fmt.Errorf("source %s is not available", source)
}
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.FileInfo) {
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline")
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(file.Name))
}
}
var rawHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
realPath, _, err := idx.GetRealPath(userScope, path)
if err != nil {
return http.StatusInternalServerError, err
return fmt.Errorf("failed to get real path for %s: %v", path, err)
}
file, err := files.FileInfoFaster(files.FileOptions{
Path: realPath,
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
})
if err != nil {
return errToStatus(err), err
}
if files.IsNamedPipe(file.Mode) {
setContentDisposition(w, r, file)
return 0, nil
}
if !file.IsDir {
return rawFileHandler(w, r, file)
}
return rawDirHandler(w, r, d, file)
})
func addFile(ar archiver.Writer, d *data, path, commonPath string) error {
if !d.Check(path) {
return nil
}
info, err := os.Stat(path)
info, err := os.Stat(realPath)
if err != nil {
return err
}
if !info.IsDir() && !info.Mode().IsRegular() {
return nil
// Get the base name of the top-level folder or file
baseName := filepath.Base(realPath)
if info.IsDir() {
// Walk through directory contents
return filepath.Walk(realPath, func(filePath string, fileInfo os.FileInfo, err error) error {
if err != nil {
return err
}
// Calculate the relative path
relPath, err := filepath.Rel(realPath, filePath) // Use realPath directly
if err != nil {
return err
}
// Normalize for tar: convert \ to /
relPath = filepath.ToSlash(relPath)
// Skip adding `.` (current directory)
if relPath == "." {
return nil
}
// Prepend base folder name unless flatten is true
if !flatten {
relPath = filepath.Join(baseName, relPath)
relPath = filepath.ToSlash(relPath) // Ensure normalized separators
}
if fileInfo.IsDir() {
if tarWriter != nil {
header := &tar.Header{
Name: relPath + "/",
Mode: 0755,
Typeflag: tar.TypeDir,
ModTime: fileInfo.ModTime(),
}
return tarWriter.WriteHeader(header)
}
if zipWriter != nil {
_, err := zipWriter.Create(relPath + "/")
return err
}
return nil
}
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
})
}
file, err := os.Open(path)
// For a single file, use the base name as the archive path
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
}
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
file, err := os.Open(realPath)
if err != nil {
return err
}
defer file.Close()
if path != commonPath {
filename := strings.TrimPrefix(path, commonPath)
filename = strings.TrimPrefix(filename, string(filepath.Separator))
err = ar.Write(archiver.File{
FileInfo: archiver.FileInfo{
FileInfo: info,
CustomName: filename,
},
ReadCloser: file,
})
if err != nil {
return err
}
info, err := file.Stat()
if err != nil {
return err
}
if info.IsDir() {
names, err := file.Readdirnames(0)
if tarWriter != nil {
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(archivePath)
if err = tarWriter.WriteHeader(header); err != nil {
return err
}
_, err = io.Copy(tarWriter, file)
return err
}
for _, name := range names {
fPath := filepath.Join(path, name)
err = addFile(ar, d, fPath, commonPath)
if err != nil {
log.Printf("Failed to archive %s: %v", fPath, err)
if zipWriter != nil {
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = archivePath
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(writer, file)
return err
}
return nil
}
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, fileList []string) (int, error) {
splitFile := strings.Split(fileList[0], "::")
if len(splitFile) != 2 {
return http.StatusBadRequest, fmt.Errorf("invalid file in files request: %v", fileList[0])
}
firstFileSource := splitFile[0]
firstFilePath := splitFile[1]
fileName := filepath.Base(firstFilePath)
var err error
userscope := "/"
if d.user.Username != "publicUser" {
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, firstFileSource)
if err != nil {
return http.StatusForbidden, err
}
}
idx := files.GetIndex(firstFileSource)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
}
realPath, isDir, err := idx.GetRealPath(userscope, firstFilePath)
if err != nil {
return http.StatusInternalServerError, err
}
// Compute estimated download size
estimatedSize, err := computeArchiveSize(fileList, d)
if err != nil {
return http.StatusInternalServerError, err
}
// ** Single file download with Content-Length **
if len(fileList) == 1 && !isDir {
fd, err2 := os.Open(realPath)
if err2 != nil {
return http.StatusInternalServerError, err2
}
defer fd.Close()
// Get file size
fileInfo, err2 := fd.Stat()
if err2 != nil {
return http.StatusInternalServerError, err2
}
// Set headers
setContentDisposition(w, r, fileName)
w.Header().Set("Cache-Control", "private")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
sizeInMB := estimatedSize / 1024 / 1024
// if larger than 500 megabytes, log it
if sizeInMB > 500 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
}
// Stream file to response
_, err2 = io.Copy(w, fd)
if err2 != nil {
return http.StatusInternalServerError, err2
}
return 200, nil
}
if config.Server.MaxArchiveSizeGB > 0 {
maxSize := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
if estimatedSize > maxSize {
return http.StatusRequestEntityTooLarge, fmt.Errorf("pre-archive combined size of files exceeds maximum limit of %d GB", config.Server.MaxArchiveSizeGB)
}
}
// ** Archive (ZIP/TAR.GZ) handling **
algo := r.URL.Query().Get("algo")
var extension string
switch algo {
case "zip", "true", "":
extension = ".zip"
case "tar.gz":
extension = ".tar.gz"
default:
return http.StatusInternalServerError, errors.New("format not implemented")
}
baseDirName := filepath.Base(filepath.Dir(firstFilePath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
if len(fileList) == 1 && isDir {
baseDirName = filepath.Base(realPath)
}
fileName = url.PathEscape(baseDirName + extension)
archiveData := filepath.Join(config.Server.CacheDir, utils.InsecureRandomIdentifier(10))
if extension == ".zip" {
archiveData = archiveData + ".zip"
err = createZip(d, archiveData, fileList...)
} else {
archiveData = archiveData + ".tar.gz"
err = createTarGz(d, archiveData, fileList...)
}
if err != nil {
return http.StatusInternalServerError, err
}
// stream archive to response
fd, err := os.Open(archiveData)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
// Get file size
fileInfo, err := fd.Stat()
if err != nil {
os.Remove(archiveData) // Remove the file if stat fails
return http.StatusInternalServerError, err
}
sizeInMB := fileInfo.Size() / 1024 / 1024
if sizeInMB > 100 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
}
// Set headers AFTER computing actual archive size
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+fileName)
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
w.Header().Set("Content-Type", "application/octet-stream")
// Stream the file
_, err = io.Copy(w, fd)
os.Remove(archiveData) // Remove the file after streaming
if err != nil {
logger.Error(fmt.Sprintf("Failed to copy archive data to response: %v", err))
return http.StatusInternalServerError, err
}
return 0, nil
}
func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
var estimatedSize int64
for _, fname := range fileList {
splitFile := strings.Split(fname, "::")
if len(splitFile) != 2 {
return http.StatusBadRequest, fmt.Errorf("invalid file in files request: %v", fileList[0])
}
source := splitFile[0]
path := splitFile[1]
idx := files.GetIndex(source)
if idx == nil {
return 0, fmt.Errorf("source %s is not available", source)
}
var err error
userScope := "/"
if d.user.Username != "publicUser" {
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
}
_, isDir, err := idx.GetRealPath(userScope, path)
if err != nil {
return http.StatusInternalServerError, err
}
info, ok := idx.GetReducedMetadata(path, isDir)
if !ok {
return 0, fmt.Errorf("failed to get metadata info for %s", path)
}
estimatedSize += info.Size
}
return estimatedSize, nil
}
func createZip(d *requestContext, tmpDirPath string, filenames ...string) error {
file, err := os.Create(tmpDirPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
defer zipWriter.Close()
for _, fname := range filenames {
err := addFile(fname, d, nil, zipWriter, false)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add %s to ZIP: %v", fname, err))
return err
}
}
return nil
}
func rawDirHandler(w http.ResponseWriter, r *http.Request, d *data, file *files.FileInfo) (int, error) {
filenames, err := parseQueryFiles(r, file, d.user)
func createTarGz(d *requestContext, tmpDirPath string, filenames ...string) error {
file, err := os.Create(tmpDirPath)
if err != nil {
return http.StatusInternalServerError, err
return err
}
defer file.Close()
extension, ar, err := parseQueryAlgorithm(r)
if err != nil {
return http.StatusInternalServerError, err
}
err = ar.Create(w)
if err != nil {
return http.StatusInternalServerError, err
}
defer ar.Close()
commonDir := fileutils.CommonPrefix(filepath.Separator, filenames...)
name := filepath.Base(commonDir)
if name == "." || name == "" || name == string(filepath.Separator) {
name = file.Name
}
// Prefix used to distinguish a filelist generated
// archive from the full directory archive
if len(filenames) > 1 {
name = "_" + name
}
name += extension
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(name))
gzWriter := gzip.NewWriter(file)
defer gzWriter.Close()
tarWriter := tar.NewWriter(gzWriter)
defer tarWriter.Close()
for _, fname := range filenames {
err = addFile(ar, d, fname, commonDir)
err := addFile(fname, d, tarWriter, nil, false)
if err != nil {
log.Printf("Failed to archive %s: %v", fname, err)
logger.Error(fmt.Sprintf("Failed to add %s to TAR.GZ: %v", fname, err))
return err
}
}
return 0, nil
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
fd, err := os.Open(file.Path)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
setContentDisposition(w, r, file)
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, file.Name, file.ModTime, fd)
return 0, nil
return nil
}
+388 -208
View File
@@ -8,189 +8,354 @@ import (
"os"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/shirou/gopsutil/v3/disk"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
if err != nil {
return http.StatusNotFound, err
// resourceGetHandler retrieves information about a resource.
// @Summary Get resource information
// @Description Returns metadata and optionally file contents for a specified resource path.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param content query string false "Include file content if true"
// @Param checksum query string false "Optional checksum validation"
// @Success 200 {object} files.FileInfo "Resource metadata"
// @Failure 404 {object} map[string]string "Resource not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [get]
func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
file, err := files.FileInfoFaster(files.FileOptions{
Path: realPath,
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: true,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
Content: r.URL.Query().Get("content") == "true",
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
Content: r.URL.Query().Get("content") == "true",
})
if err != nil {
return errToStatus(err), err
}
if !file.IsDir {
if checksum := r.URL.Query().Get("checksum"); checksum != "" {
err := file.Checksum(checksum)
if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil
} else if err != nil {
return http.StatusInternalServerError, err
}
}
if fileInfo.Type == "directory" {
return renderJSON(w, r, fileInfo)
}
return renderJSON(w, r, file)
})
if algo := r.URL.Query().Get("checksum"); algo != "" {
idx := files.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(userscope, path)
checksums, err := files.GetChecksum(realPath, algo)
if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil
} else if err != nil {
return http.StatusInternalServerError, err
}
fileInfo.Checksums = checksums
}
return renderJSON(w, r, fileInfo)
func resourceDeleteHandler(fileCache FileCache) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if r.URL.Path == "/" || !d.user.Perm.Delete {
return http.StatusForbidden, nil
}
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
if err != nil {
return http.StatusNotFound, err
}
fileOpts := files.FileOptions{
Path: realPath,
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
}
file, err := files.FileInfoFaster(fileOpts)
if err != nil {
return errToStatus(err), err
}
}
// delete thumbnails
err = delThumbs(r.Context(), fileCache, file)
if err != nil {
return errToStatus(err), err
}
// resourceDeleteHandler deletes a resource at a specified path.
// @Summary Delete a resource
// @Description Deletes a resource located at the specified path.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Success 200 "Resource deleted successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [delete]
func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if path == "/" {
return http.StatusForbidden, nil
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
})
if err != nil {
return errToStatus(err), err
}
err = files.DeleteFiles(realPath, fileOpts)
// delete thumbnails
delThumbs(r.Context(), fileCache, fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
if err != nil {
return errToStatus(err), err
}
return http.StatusOK, nil
}
// resourcePostHandler creates or uploads a new resource.
// @Summary Create or upload a resource
// @Description Creates a new resource or uploads a file at the specified path. Supports file uploads and directory creation.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param override query bool false "Override existing file if true"
// @Success 200 "Resource created successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
// @Failure 409 {object} map[string]string "Conflict - Resource already exists"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [post]
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
if !d.user.Perm.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileOpts := files.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
}
// Directories creation on POST.
if strings.HasSuffix(path, "/") {
err = files.WriteDirectory(fileOpts)
if err != nil {
return errToStatus(err), err
}
return http.StatusOK, nil
})
}
}
fileInfo, err := files.FileInfoFaster(fileOpts)
if err == nil {
if r.URL.Query().Get("override") != "true" {
logger.Debug(fmt.Sprintf("Resource already exists: %v", fileInfo.RealPath))
return http.StatusConflict, nil
}
func resourcePostHandler(fileCache FileCache) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
// Permission for overwriting the file
if !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
if err != nil {
return http.StatusNotFound, err
}
fileOpts := files.FileOptions{
Path: realPath,
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
}
// Directories creation on POST.
if strings.HasSuffix(r.URL.Path, "/") {
err = files.WriteDirectory(fileOpts) // Assign to the existing `err` variable
if err != nil {
return errToStatus(err), err
}
return http.StatusOK, nil
}
file, err := files.FileInfoFaster(fileOpts)
if err == nil {
if r.URL.Query().Get("override") != "true" {
return http.StatusConflict, nil
}
// Permission for overwriting the file
if !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
err = delThumbs(r.Context(), fileCache, file)
if err != nil {
return errToStatus(err), err
}
}
err = files.WriteFile(fileOpts, r.Body)
delThumbs(r.Context(), fileCache, fileInfo)
}
err = files.WriteFile(fileOpts, r.Body)
if err != nil {
return errToStatus(err), err
})
}
return http.StatusOK, nil
}
var resourcePutHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Modify || !d.Check(r.URL.Path) {
return http.StatusForbidden, nil
// resourcePutHandler updates an existing file resource.
// @Summary Update a file resource
// @Description Updates an existing file at the specified path.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Success 200 "Resource updated successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
// @Failure 405 {object} map[string]string "Method not allowed"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [put]
func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
if !d.user.Perm.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Only allow PUT for files.
if strings.HasSuffix(r.URL.Path, "/") {
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
}
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusNotFound, err
return http.StatusForbidden, err
}
fileOpts := files.FileOptions{
Path: realPath,
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
}
err = files.WriteFile(fileOpts, r.Body)
return errToStatus(err), err
})
}
// TODO fix and verify this function still works in tests
func resourcePatchHandler(fileCache FileCache) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
src := r.URL.Path
dst := r.URL.Query().Get("destination")
action := r.URL.Query().Get("action")
dst, err := url.QueryUnescape(dst)
if !d.Check(src) || !d.Check(dst) {
return http.StatusForbidden, nil
}
if err != nil {
return errToStatus(err), err
}
if dst == "/" || src == "/" {
return http.StatusForbidden, nil
}
override := r.URL.Query().Get("override") == "true"
rename := r.URL.Query().Get("rename") == "true"
if !override && !rename {
if _, err = os.Stat(dst); err == nil {
return http.StatusConflict, nil
}
}
if rename {
dst = addVersionSuffix(dst)
}
// Permission for overwriting the file
if override && !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
err = d.RunHook(func() error {
return patchAction(r.Context(), action, src, dst, d, fileCache)
}, action, src, dst, d.user)
// resourcePatchHandler performs a patch operation (e.g., move, rename) on a resource.
// @Summary Patch resource (move/rename)
// @Description Moves or renames a resource to a new destination.
// @Tags Resources
// @Accept json
// @Produce json
// @Param from query string true "Path from resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param destination query string true "Destination path for the resource"
// @Param action query string true "Action to perform (copy, rename)"
// @Param overwrite query bool false "Overwrite if destination exists"
// @Param rename query bool false "Rename if destination exists"
// @Success 200 "Resource moved/renamed successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
// @Failure 409 {object} map[string]string "Conflict - Destination exists"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [patch]
func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
action := r.URL.Query().Get("action")
if !d.user.Perm.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
encodedFrom := r.URL.Query().Get("from")
// Decode the URL-encoded path
src, err := url.QueryUnescape(encodedFrom)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
dst := r.URL.Query().Get("destination")
dst, err = url.QueryUnescape(dst)
if err != nil {
return errToStatus(err), err
})
}
splitSrc := strings.Split(src, "::")
if len(splitSrc) <= 1 {
return http.StatusBadRequest, fmt.Errorf("invalid source path: %v", src)
}
srcIndex := splitSrc[0]
src = splitSrc[1]
splitDst := strings.Split(dst, "::")
if len(splitDst) <= 1 {
return http.StatusBadRequest, fmt.Errorf("invalid destination path: %v", dst)
}
dstIndex := splitDst[0]
dst = splitDst[1]
if dst == "/" || src == "/" {
return http.StatusForbidden, fmt.Errorf("forbidden: source or destination is attempting to modify root")
}
userscopeDst, err := settings.GetScopeFromSourceName(d.user.Scopes, dstIndex)
if err != nil {
return http.StatusForbidden, err
}
userscopeSrc, err := settings.GetScopeFromSourceName(d.user.Scopes, srcIndex)
if err != nil {
return http.StatusForbidden, err
}
idx := files.GetIndex(dstIndex)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", dstIndex)
}
// check target dir exists
parentDir, _, err := idx.GetRealPath(userscopeDst, filepath.Dir(dst))
if err != nil {
logger.Debug(fmt.Sprintf("Could not get real path for parent dir: %v %v %v", userscopeDst, filepath.Dir(dst), err))
return http.StatusNotFound, err
}
realDest := parentDir + "/" + filepath.Base(dst)
idx2 := files.GetIndex(srcIndex)
if idx2 == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", srcIndex)
}
realSrc, isSrcDir, err := idx2.GetRealPath(userscopeSrc, src)
if err != nil {
return http.StatusNotFound, err
}
overwrite := r.URL.Query().Get("overwrite") == "true"
rename := r.URL.Query().Get("rename") == "true"
if rename {
realDest = addVersionSuffix(realDest)
}
// Permission for overwriting the file
if overwrite && !d.user.Perm.Modify {
return http.StatusForbidden, fmt.Errorf("forbidden: user does not have permission to overwrite file")
}
err = patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, srcIndex, dstIndex)
if err != nil {
logger.Debug(fmt.Sprintf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err))
}
return errToStatus(err), err
}
func addVersionSuffix(source string) string {
@@ -209,59 +374,37 @@ func addVersionSuffix(source string) string {
return source
}
func delThumbs(ctx context.Context, fileCache FileCache, file *files.FileInfo) error {
for _, previewSizeName := range PreviewSizeNames() {
size, _ := ParsePreviewSize(previewSizeName)
if err := fileCache.Delete(ctx, previewCacheKey(file, size)); err != nil {
return err
}
func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) {
err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime))
if err != nil {
logger.Debug(fmt.Sprintf("Could not delete small thumbnail: %v", err))
}
return nil
}
func patchAction(ctx context.Context, action, src, dst string, d *data, fileCache FileCache) error {
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, srcIndex, destIndex string) error {
switch action {
// TODO: use enum
case "copy":
if !d.user.Perm.Create {
return errors.ErrPermissionDenied
}
return fileutils.Copy(src, dst)
case "rename":
if !d.user.Perm.Rename {
return errors.ErrPermissionDenied
}
src = path.Clean("/" + src)
dst = path.Clean("/" + dst)
realDest, _, err := files.GetRealPath(d.user.Scope, dst)
if err != nil {
return err
}
realSrc, isDir, err := files.GetRealPath(d.user.Scope, src)
if err != nil {
return err
}
file, err := files.FileInfoFaster(files.FileOptions{
Path: realSrc,
IsDir: isDir,
err := files.CopyResource(srcIndex, destIndex, src, dst)
return err
case "rename", "move":
idx := files.GetIndex(srcIndex)
srcPath := idx.MakeIndexPath(src)
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: srcPath,
Source: srcIndex,
IsDir: isSrcDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: false,
Checker: d,
})
if err != nil {
return err
}
// delete thumbnails
err = delThumbs(ctx, fileCache, file)
if err != nil {
return err
}
return fileutils.MoveFile(realSrc, realDest)
delThumbs(ctx, fileCache, fileInfo)
return files.MoveResource(srcIndex, destIndex, src, dst)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
}
@@ -272,35 +415,72 @@ type DiskUsageResponse struct {
Used uint64 `json:"used"`
}
var diskUsage = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
if err != nil {
return http.StatusNotFound, err
// diskUsage returns the disk usage information for a given directory.
// @Summary Get disk usage
// @Description Returns the total and used disk space for a specified directory.
// @Tags Resources
// @Accept json
// @Produce json
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Success 200 {object} DiskUsageResponse "Disk usage details"
// @Failure 404 {object} map[string]string "Directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/usage [get]
func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
if sourceName == "" {
sourceName = config.Server.DefaultSource.Name
}
file, err := files.FileInfoFaster(files.FileOptions{
Path: realPath,
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: false,
Checker: d,
})
value, ok := cache.DiskUsage.Get(sourceName).(DiskUsageResponse)
if ok {
return renderJSON(w, r, &value)
}
source, ok := config.Server.NameToSource[sourceName]
if !ok {
return 403, fmt.Errorf("source '%s' either does not exist or user does not have permission to it", sourceName)
}
_, err := settings.GetScopeFromSourceName(d.user.Scopes, sourceName)
if err != nil {
return 403, fmt.Errorf("user doesn't have access to '%s', contact your administrator for access", sourceName)
}
usage, err := disk.UsageWithContext(r.Context(), source.Path)
if err != nil {
return errToStatus(err), err
}
fPath := file.RealPath()
if !file.IsDir {
return renderJSON(w, r, &DiskUsageResponse{
Total: 0,
Used: 0,
})
}
usage, err := disk.UsageWithContext(r.Context(), fPath)
if err != nil {
return errToStatus(err), err
}
return renderJSON(w, r, &DiskUsageResponse{
latestUsage := DiskUsageResponse{
Total: usage.Total,
Used: usage.Used,
})
})
}
cache.DiskUsage.Set(sourceName, latestUsage)
return renderJSON(w, r, &latestUsage)
}
func inspectIndex(w http.ResponseWriter, r *http.Request) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
index := files.GetIndex(source)
if index == nil {
http.Error(w, "source not found", http.StatusNotFound)
return
}
info, _ := index.GetReducedMetadata(path, !isNotDir)
renderJSON(w, r, info) // nolint:errcheck
}
func mockData(w http.ResponseWriter, r *http.Request) {
d := r.URL.Query().Get("numDirs")
f := r.URL.Query().Get("numFiles")
NumDirs, err := strconv.Atoi(d)
numFiles, err2 := strconv.Atoi(f)
if err != nil || err2 != nil {
return
}
mockDir := files.CreateMockData(NumDirs, numFiles)
renderJSON(w, r, mockDir) // nolint:errcheck
}
+214
View File
@@ -0,0 +1,214 @@
package http
import (
"context"
"crypto/tls"
"embed"
"fmt"
"io/fs"
"net/http"
"os"
"text/template"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/version"
httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware
)
// Embed the files in the frontend/dist directory
//
//go:embed embed/*
var assets embed.FS
// Boolean flag to determine whether to use the embedded FS or not
var embeddedFS = os.Getenv("FILEBROWSER_NO_EMBEDED") != "true"
// Custom dirFS to handle both embedded and non-embedded file systems
type dirFS struct {
http.Dir
}
// Implement the Open method for dirFS, which wraps http.Dir
func (d dirFS) Open(name string) (fs.File, error) {
return d.Dir.Open(name)
}
var (
store *storage.Storage
config *settings.Settings
fileCache FileCache
imgSvc ImgService
assetFs fs.FS
)
func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage, cache FileCache, shutdownComplete chan struct{}) {
store = storage
fileCache = cache
imgSvc = Service
config = &settings.Config
var err error
if embeddedFS {
// Embedded mode: Serve files from the embedded assets
assetFs, err = fs.Sub(assets, "embed")
if err != nil {
logger.Fatal("Could not embed frontend. Does dist exist?")
}
} else {
assetFs = dirFS{Dir: http.Dir("http/dist")}
}
templateRenderer = &TemplateRenderer{
templates: template.Must(template.ParseFS(assetFs, "public/index.html")),
}
router := http.NewServeMux()
// API group routing
api := http.NewServeMux()
// User routes
api.HandleFunc("GET /users", withUser(userGetHandler))
api.HandleFunc("POST /users", withSelfOrAdmin(usersPostHandler))
api.HandleFunc("PUT /users", withUser(userPutHandler))
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
// Auth routes
api.HandleFunc("POST /auth/login", loginHandler)
api.HandleFunc("GET /auth/signup", signupHandler)
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
api.HandleFunc("PUT /auth/token", withUser(createApiKeyHandler))
api.HandleFunc("GET /auth/token", withUser(createApiKeyHandler))
api.HandleFunc("DELETE /auth/token", withUser(deleteApiKeyHandler))
api.HandleFunc("GET /auth/tokens", withUser(listApiKeysHandler))
// Resources routes
api.HandleFunc("GET /resources", withUser(resourceGetHandler))
api.HandleFunc("DELETE /resources", withUser(resourceDeleteHandler))
api.HandleFunc("POST /resources", withUser(resourcePostHandler))
api.HandleFunc("PUT /resources", withUser(resourcePutHandler))
api.HandleFunc("PATCH /resources", withUser(resourcePatchHandler))
api.HandleFunc("GET /usage", withUser(diskUsage))
api.HandleFunc("GET /raw", withUser(rawHandler))
api.HandleFunc("GET /preview", withUser(previewHandler))
if version.Version == "testing" || version.Version == "untracked" {
api.HandleFunc("GET /inspectIndex", inspectIndex)
api.HandleFunc("GET /mockData", mockData)
}
// Share routes
api.HandleFunc("GET /shares", withPermShare(shareListHandler))
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
// Public routes
api.HandleFunc("GET /public/publicUser", publicUserGetHandler)
api.HandleFunc("GET /public/dl", withHashFile(publicRawHandler))
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
api.HandleFunc("GET /search", withUser(searchHandler))
apiPath := config.Server.BaseURL + "api"
router.Handle(apiPath+"/", http.StripPrefix(apiPath, api))
// Static and index file handlers
router.HandleFunc(fmt.Sprintf("GET %vstatic/", config.Server.BaseURL), staticFilesHandler)
router.HandleFunc(config.Server.BaseURL, indexHandler)
// health
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
// Swagger
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL),
httpSwagger.Handler(
httpSwagger.URL(config.Server.BaseURL+"swagger/doc.json"), //The url pointing to API definition
httpSwagger.DeepLinking(true),
httpSwagger.DocExpansion("none"),
httpSwagger.DomID("swagger-ui"),
),
)
var scheme string
port := ""
srv := &http.Server{
Addr: fmt.Sprintf(":%v", config.Server.Port),
Handler: muxWithMiddleware(router),
}
go func() {
// Determine whether to use HTTPS (TLS) or HTTP
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
// Load the TLS certificate and key
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
if err != nil {
logger.Fatal(fmt.Sprintf("Could not load certificate: %v", err))
}
// Create a custom TLS configuration
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cer},
}
// Set HTTPS scheme and default port for TLS
scheme = "https"
if config.Server.Port != 443 {
port = fmt.Sprintf(":%d", config.Server.Port)
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
// Create a TLS listener and serve
listener, err := tls.Listen("tcp", srv.Addr, tlsConfig)
if err != nil {
logger.Fatal(fmt.Sprintf("Could not start TLS server: %v", err))
}
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatal(fmt.Sprintf("Server error: %v", err))
}
} else {
// Set HTTP scheme and the default port for HTTP
scheme = "http"
if config.Server.Port != 80 {
port = fmt.Sprintf(":%d", config.Server.Port)
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
// Start HTTP server
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal(fmt.Sprintf("Server error: %v", err))
}
}
}()
// Wait for context cancellation to shut down the server
<-ctx.Done()
logger.Info("Shutting down HTTP server...")
// Graceful shutdown with a timeout - 30 seconds, in case downloads are happening
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error(fmt.Sprintf("HTTP server forced to shut down: %v", err))
} else {
logger.Info("HTTP server shut down gracefully.")
}
// Signal that shutdown is complete
close(shutdownComplete)
}
+73 -23
View File
@@ -1,36 +1,86 @@
package http
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
var searchHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
response := []map[string]interface{}{}
// searchHandler handles search requests for files based on the provided query.
//
// This endpoint processes a search query, retrieves relevant file paths, and
// returns a JSON response with the search results. The search is performed
// against the file index, which is built from the root directory specified in
// the server's configuration. The results are filtered based on the user's scope.
//
// The handler expects the following headers in the request:
// - SessionId: A unique identifier for the user's session.
// - UserScope: The scope of the user, which influences the search context.
//
// The request URL should include a query parameter named `query` that specifies
// the search terms to use. The response will include an array of searchResponse objects
// containing the path, type, and dir status.
//
// Example request:
//
// GET api/search?query=myfile
//
// Example response:
// [
//
// {
// "path": "/path/to/myfile.txt",
// "type": "text"
// },
// {
// "path": "/path/to/mydir/",
// "type": "directory"
// }
//
// ]
//
// @Summary Search Files
// @Description Searches for files matching the provided query. Returns file paths and metadata based on the user's session and scope.
// @Tags Search
// @Accept json
// @Produce json
// @Param query query string true "Search query"
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only"
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
// @Success 200 {array} files.SearchResult "List of search results"
// @Failure 400 {object} map[string]string "Bad Request"
// @Router /api/search [get]
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
query := r.URL.Query().Get("query")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
scope := r.URL.Query().Get("scope")
unencodedScope, err := url.QueryUnescape(scope)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
searchScope := strings.TrimPrefix(unencodedScope, ".")
// Retrieve the User-Agent and X-Auth headers from the request
sessionId := r.Header.Get("SessionId")
userScope := r.Header.Get("UserScope")
index := files.GetIndex(settings.Config.Server.Root)
combinedScope := strings.TrimPrefix(userScope+r.URL.Path, ".")
results, fileTypes := index.Search(query, combinedScope, sessionId)
for _, path := range results {
responseObj := map[string]interface{}{
"path": path,
"dir": true,
}
if _, ok := fileTypes[path]; ok {
responseObj["dir"] = false
for filterType, value := range fileTypes[path] {
if value {
responseObj[filterType] = value
}
}
}
response = append(response, responseObj)
index := files.GetIndex(source)
if index == nil {
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", source)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope))
// Perform the search using the provided query and user scope
response := index.Search(query, combinedPath, sessionId)
// Set the Content-Type header to application/json
w.Header().Set("Content-Type", "application/json")
return renderJSON(w, r, response)
})
}
+29 -42
View File
@@ -1,49 +1,36 @@
package http
import (
"encoding/json"
"net/http"
"github.com/gtsteffaniak/filebrowser/rules"
"github.com/gtsteffaniak/filebrowser/settings"
)
type settingsData struct {
Signup bool `json:"signup"`
CreateUserDir bool `json:"createUserDir"`
UserHomeBasePath string `json:"userHomeBasePath"`
Defaults settings.UserDefaults `json:"defaults"`
Rules []rules.Rule `json:"rules"`
Frontend settings.Frontend `json:"frontend"`
Commands map[string][]string `json:"commands"`
// settingsGetHandler retrieves the current system settings.
// @Summary Get system settings
// @Description Returns the current configuration settings for signup, user directories, rules, frontend.
// @Tags Settings
// @Accept json
// @Produce json
// @Param property query string false "Property to retrieve: `userDefaults`, `frontend`, `auth`, `server`, `sources`"
// @Success 200 {object} settings.Settings "System settings data"
// @Router /api/settings [get]
func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
property := r.URL.Query().Get("property")
if property != "" {
// get property by name
switch property {
case "userDefaults":
return renderJSON(w, r, config.UserDefaults)
case "frontend":
return renderJSON(w, r, config.Frontend)
case "auth":
return renderJSON(w, r, config.Auth)
case "server":
return renderJSON(w, r, config.Server)
case "sources":
return renderJSON(w, r, config.Server.Sources)
default:
return http.StatusNotFound, nil
}
}
return renderJSON(w, r, config)
}
var settingsGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
data := &settingsData{
Signup: d.settings.Auth.Signup,
CreateUserDir: d.settings.Server.CreateUserDir,
UserHomeBasePath: d.settings.Server.UserHomeBasePath,
Defaults: d.settings.UserDefaults,
Rules: d.settings.Rules,
Frontend: d.settings.Frontend,
}
return renderJSON(w, r, data)
})
var settingsPutHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
req := &settingsData{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
return http.StatusBadRequest, err
}
d.settings.Server.CreateUserDir = req.CreateUserDir
d.settings.Server.UserHomeBasePath = req.UserHomeBasePath
d.settings.UserDefaults = req.Defaults
d.settings.Rules = req.Rules
d.settings.Frontend = req.Frontend
d.settings.Auth.Signup = req.Signup
err = d.store.Settings.Save(d.settings)
return errToStatus(err), err
})
+95 -40
View File
@@ -8,38 +8,37 @@ import (
"net/http"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/share"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
)
func withPermShare(fn handleFunc) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Share {
return http.StatusForbidden, nil
}
return fn(w, r, d)
})
}
var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
// shareListHandler returns a list of all share links.
// @Summary List share links
// @Description Returns a list of share links for the current user, or all links if the user is an admin.
// @Tags Shares
// @Accept json
// @Produce json
// @Success 200 {array} share.Link "List of share links"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/shares [get]
func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
var (
s []*share.Link
err error
)
if d.user.Perm.Admin {
s, err = d.store.Share.All()
s, err = store.Share.All()
} else {
s, err = d.store.Share.FindByUserID(d.user.ID)
s, err = store.Share.FindByUserID(d.user.ID)
}
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
}
if err != nil {
return http.StatusInternalServerError, err
}
@@ -51,39 +50,76 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
return s[i].Expire < s[j].Expire
})
return renderJSON(w, r, s)
})
}
var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
s, err := d.store.Share.Gets(r.URL.Path, d.user.ID)
// shareGetsHandler retrieves share links for a specific resource path.
// @Summary Get share links by path
// @Description Retrieves all share links associated with a specific resource path for the current user.
// @Tags Shares
// @Accept json
// @Produce json
// @Param path query string true "Resource path for which to retrieve share links"
// @Param source query string true "Source name for share links"
// @Success 200 {array} share.Link "List of share links for the specified path"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/share [get]
func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
s, err := store.Share.Gets(path, source, d.user.ID)
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
}
if err != nil {
return http.StatusInternalServerError, err
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
}
return renderJSON(w, r, s)
})
}
var shareDeleteHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
hash := strings.TrimSuffix(r.URL.Path, "/")
hash = strings.TrimPrefix(hash, "/")
// shareDeleteHandler deletes a specific share link by its hash.
// @Summary Delete a share link
// @Description Deletes a share link specified by its hash.
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash path string true "Hash of the share link to delete"
// @Success 200 "Share link deleted successfully"
// @Failure 400 {object} map[string]string "Bad request - missing or invalid hash"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/shares/{hash} [delete]
func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
if hash == "" {
return http.StatusBadRequest, nil
}
err := d.store.Share.Delete(hash)
err := store.Share.Delete(hash)
if err != nil {
return errToStatus(err), err
}
return errToStatus(err), err
})
var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
}
// sharePostHandler creates a new share link.
// @Summary Create a share link
// @Description Creates a new share link with an optional expiration time and password protection.
// @Tags Shares
// @Accept json
// @Produce json
// @Param body body share.CreateBody true "Share link creation parameters"
// @Param path path string true "Source Path of the files to share"
// @Param source path string true "Source name of the files to share"
// @Success 200 {object} share.Link "Created share link"
// @Failure 400 {object} map[string]string "Bad request - failed to decode body"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/shares [post]
func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
var s *share.Link
var body share.CreateBody
if r.Body != nil {
@@ -93,14 +129,11 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
defer r.Body.Close()
}
bytes := make([]byte, 6) //nolint:gomnd
_, err := rand.Read(bytes)
secure_hash, err := generateShortUUID()
if err != nil {
return http.StatusInternalServerError, err
}
str := base64.URLEncoding.EncodeToString(bytes)
var expire int64 = 0
if body.Expires != "" {
@@ -133,38 +166,60 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
var token string
if len(hash) > 0 {
tokenBuffer := make([]byte, 24) //nolint:gomnd
if _, err := rand.Read(tokenBuffer); err != nil {
if _, err = rand.Read(tokenBuffer); err != nil {
return http.StatusInternalServerError, err
}
token = base64.URLEncoding.EncodeToString(tokenBuffer)
stringHash = string(hash)
}
path := r.URL.Query().Get("path")
sourceName := r.URL.Query().Get("source")
if sourceName == "" {
sourceName = config.Server.DefaultSource.Name
}
source := config.Server.NameToSource[sourceName]
s = &share.Link{
Path: strings.TrimSuffix(r.URL.Path, "/"),
Hash: str,
Path: path,
Hash: secure_hash,
Source: source.Path,
Expire: expire,
UserID: d.user.ID,
PasswordHash: stringHash,
Token: token,
}
if err := d.store.Share.Save(s); err != nil {
if err := store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, r, s)
})
}
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
if body.Password == "" {
return nil, 0, nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password: %w", err)
return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password")
}
return hash, 0, nil
}
func generateShortUUID() (string, error) {
// Generate 16 random bytes (128 bits of entropy)
bytes := make([]byte, 16)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
// Encode the bytes to a URL-safe base64 string
uuid := base64.RawURLEncoding.EncodeToString(bytes)
// Trim the length to 22 characters for a shorter ID
return uuid[:22], nil
}
+72 -120
View File
@@ -4,153 +4,105 @@ import (
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"text/template"
"github.com/gtsteffaniak/filebrowser/auth"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/version"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, fSys fs.FS, file, contentType string) (int, error) {
var templateRenderer *TemplateRenderer
type TemplateRenderer struct {
templates *template.Template
}
// Render renders a template document with headers and data
func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data interface{}) error {
// Set headers
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("X-Accel-Expires", "0")
w.Header().Set("Transfer-Encoding", "identity")
// Execute the template with the provided data
return t.templates.ExecuteTemplate(w, name, data)
}
func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentType string) {
w.Header().Set("Content-Type", contentType)
auther, err := d.store.Auth.Get(d.settings.Auth.Method)
auther, err := store.Auth.Get("password")
if err != nil {
return http.StatusInternalServerError, err
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Name": d.settings.Frontend.Name,
"DisableExternal": d.settings.Frontend.DisableExternal,
"DisableUsedPercentage": d.settings.Frontend.DisableUsedPercentage,
"Name": config.Frontend.Name,
"DisableExternal": config.Frontend.DisableDefaultLinks,
"DisableUsedPercentage": config.Frontend.DisableUsedPercentage,
"darkMode": settings.Config.UserDefaults.DarkMode,
"Color": d.settings.Frontend.Color,
"BaseURL": d.server.BaseURL,
"BaseURL": config.Server.BaseURL,
"Version": version.Version,
"CommitSHA": version.CommitSHA,
"StaticURL": path.Join(d.server.BaseURL, "/static"),
"Signup": settings.Config.Auth.Signup,
"NoAuth": d.settings.Auth.Method == "noauth",
"AuthMethod": d.settings.Auth.Method,
"StaticURL": config.Server.BaseURL + "static",
"Signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
"NoAuth": config.Auth.Methods.NoAuth,
"PasswordAuth": config.Auth.Methods.PasswordAuth,
"LoginPage": auther.LoginPage(),
"CSS": false,
"ReCaptcha": false,
"EnableThumbs": d.server.EnableThumbnails,
"ResizePreview": d.server.ResizePreview,
"EnableExec": d.server.EnableExec,
}
if d.settings.Frontend.Files != "" {
fPath := filepath.Join(d.settings.Frontend.Files, "custom.css")
_, err := os.Stat(fPath) //nolint:govet
if err != nil && !os.IsNotExist(err) {
log.Printf("couldn't load custom styles: %v", err)
}
if err == nil {
data["CSS"] = true
}
}
if d.settings.Auth.Method == "password" {
raw, err := d.store.Auth.Get(d.settings.Auth.Method) //nolint:govet
if err != nil {
return http.StatusInternalServerError, err
}
auther, ok := raw.(*auth.JSONAuth)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type *auth.JSONAuth")
}
if auther.ReCaptcha != nil {
data["ReCaptcha"] = auther.ReCaptcha.Key != "" && auther.ReCaptcha.Secret != ""
data["ReCaptchaHost"] = auther.ReCaptcha.Host
data["ReCaptchaKey"] = auther.ReCaptcha.Key
}
"EnableThumbs": config.Server.EnableThumbnails,
"ResizePreview": config.Server.ResizePreview,
"ExternalLinks": config.Frontend.ExternalLinks,
"ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"SourceCount": len(config.Server.Sources),
}
b, err := json.Marshal(data)
if err != nil {
return http.StatusInternalServerError, err
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
data["Json"] = strings.ReplaceAll(string(b), `'`, `\'`)
data["globalVars"] = strings.ReplaceAll(string(b), `'`, `\'`)
fileContents, err := fs.ReadFile(fSys, file)
if err != nil {
if err == os.ErrNotExist {
return http.StatusNotFound, err
}
return http.StatusInternalServerError, err
// Render the template with global variables
if err := templateRenderer.Render(w, file, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
index := template.Must(template.New("index").Delims("[{[", "]}]").Parse(string(fileContents)))
err = index.Execute(w, data)
if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
func getStaticHandlers(store *storage.Storage, server *settings.Server, assetsFs fs.FS) (index, static http.Handler) {
index = handle(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if r.Method != http.MethodGet {
return http.StatusNotFound, nil
}
w.Header().Set("x-xss-protection", "1; mode=block")
return handleWithStaticData(w, r, d, assetsFs, "public/index.html", "text/html; charset=utf-8")
}, "", store, server)
static = handle(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if r.Method != http.MethodGet {
return http.StatusNotFound, nil
}
const maxAge = 86400 // 1 day
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
if d.settings.Frontend.Files != "" {
if strings.HasPrefix(r.URL.Path, "img/") {
fPath := filepath.Join(d.settings.Frontend.Files, r.URL.Path)
if _, err := os.Stat(fPath); err == nil {
http.ServeFile(w, r, fPath)
return 0, nil
}
} else if r.URL.Path == "custom.css" && d.settings.Frontend.Files != "" {
http.ServeFile(w, r, filepath.Join(d.settings.Frontend.Files, "custom.css"))
return 0, nil
}
}
if !strings.HasSuffix(r.URL.Path, ".js") {
http.FileServer(http.FS(assetsFs)).ServeHTTP(w, r)
return 0, nil
}
fileContents, err := fs.ReadFile(assetsFs, r.URL.Path+".gz")
if err != nil {
return http.StatusNotFound, err
}
w.Header().Set("Content-Encoding", "gzip")
func staticFilesHandler(w http.ResponseWriter, r *http.Request) {
const maxAge = 86400 // 1 day
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
// Remove "/static/" from the request path
adjustedPath := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("%vstatic/", config.Server.BaseURL))
adjustedCompressed := adjustedPath + ".gz"
if strings.HasSuffix(adjustedPath, ".js") {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8") // Set the correct MIME type for JavaScript files
if _, err := w.Write(fileContents); err != nil {
return http.StatusInternalServerError, err
}
// Check if the gzipped version of the file exists
fileContents, err := fs.ReadFile(assetFs, adjustedCompressed)
if err == nil {
w.Header().Set("Content-Encoding", "gzip") // Let the browser know the file is compressed
status, err := w.Write(fileContents) // Write the gzipped file content to the response
if err != nil {
http.Error(w, http.StatusText(status), status)
}
return 0, nil
}, "/static/", store, server)
return index, static
} else {
// Otherwise, serve the regular file
http.StripPrefix(fmt.Sprintf("%vstatic/", config.Server.BaseURL), http.FileServer(http.FS(assetFs))).ServeHTTP(w, r)
}
}
func indexHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
handleWithStaticData(w, r, "index.html", "text/html")
}
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff. Show more