Compare commits

...
77 Commits
Author SHA1 Message Date
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
Graham Steffaniak c272eb1e61 hotfix: search path adjustment 2024-09-16 20:19:25 -05:00
Graham Steffaniak bae39c8586 Update main.yaml workflow
add fetch depth
2024-09-16 17:26:57 -05:00
Graham Steffaniak 62d1cd88a1 V0.2.9 release (#205) 2024-09-16 16:01:16 -05:00
Graham Steffaniak 0bad14b51e workflow 2024-08-24 19:20:54 -05:00
Graham Steffaniak 74ccfab070 Update search behavior (#195) 2024-08-24 19:12:47 -05:00
Graham Steffaniak 5f185597c9 fix main workflow 2024-08-24 17:06:55 -05:00
Graham Steffaniak 9e9109984d v0.2.8 release (#193) 2024-08-24 17:02:33 -05:00
Graham Steffaniak 5ebaf2a45b State fix (#183) 2024-08-04 14:02:09 -05:00
Graham Steffaniak c7cce0694b workflow update and go releaser (#177) 2024-08-04 12:50:35 -05:00
Graham Steffaniak 43b45203d0 workflow update (#169) 2024-08-04 10:28:38 -05:00
Graham Steffaniak af407068b6 Fix workflows (#165) 2024-08-04 09:56:18 -05:00
Graham Steffaniak 865fb4e243 ismobile fix (#163) 2024-08-03 15:43:08 -05:00
Graham Steffaniak c616b1473a updated ismobile 2024-08-03 15:38:43 -05:00
Graham Steffaniak 0479670c52 fix overlay bug, share bug (#161) 2024-08-03 15:04:50 -05:00
Graham Steffaniak fa3ed6b948 v0.2.7 update 2024-08-03 10:34:12 -05:00
Graham Steffaniak d8085c1f1b updated default config (#152) 2024-07-30 13:13:47 -05:00
Graham Steffaniak dc5da5662f fix default config (#151) 2024-07-30 13:09:34 -05:00
Graham Steffaniak b4bba3391d V0.2.6 (#150) 2024-07-30 12:45:27 -05:00
Graham Steffaniak 62f3953aea V0.2.5 (#125) 2024-06-12 14:51:13 -05:00
dependabot[bot] bc87dcd32c Bump express from 4.18.2 to 4.19.2 in /frontend (#123)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-09 11:07:56 -05:00
dependabot[bot] cdb49680e3 Bump webpack-dev-middleware from 5.3.3 to 5.3.4 in /frontend (#124)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-09 11:07:47 -05:00
Graham Steffaniak d9e583fea7 v0.2.4 release (#116) (#117) 2024-02-09 18:13:02 -06:00
0281aee56f hide dotfile tweak (#102)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-21 15:25:37 -06:00
7aa3230649 V0.2.3 - update css (#99)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-20 14:58:20 -06:00
398ac49aa9 merge initial v0.2.3 commit (#97) (#98)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-20 14:44:25 -06:00
Graham Steffaniak ac544738a2 Update configuration.md (#91) 2023-12-06 15:03:20 -06:00
Graham Steffaniak d574be9e89 Update README.md (#89) 2023-12-05 16:46:47 -06:00
b747ab19b7 updated listing view limit in v0.2.2 (#88)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-02 10:27:06 -06:00
e8091dcb24 v0.2.2 (#86)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-01 17:47:00 -06:00
Graham SteffaniakandGraham Steffaniak 8e4629a0c4 V0.2.1 (#64)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-22 19:23:38 -05:00
Graham SteffaniakandGraham Steffaniak 9b57eb1060 V0.2.1 - small bugfixes (#58)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-18 20:15:01 -05:00
Graham SteffaniakandGraham Steffaniak 47e1975c12 V0.2.1 - search scope bugfix (#56)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-18 10:32:22 -05:00
Graham SteffaniakandGraham Steffaniak 4bab354c99 V0.2.1 (#54)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-17 16:44:34 -05:00
Graham SteffaniakandGraham Steffaniak 8a84a42cf5 V0.2.1 - bugfix (#52)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-14 08:44:32 -05:00
Graham SteffaniakandGraham Steffaniak 3fd8ee7783 FIX: UI doesn't load for non-user on share (#51)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-14 08:43:25 -05:00
Graham SteffaniakandGraham Steffaniak 3c1a852435 V0.2.1 - update filebrowser.yaml (#50)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-11 10:09:12 -05:00
Graham SteffaniakandGraham Steffaniak 4894230482 V0.2.1 (update) (#49)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-11 09:37:32 -05:00
Graham SteffaniakandGraham Steffaniak d53426b580 V0.2.1 - update signup (#47)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-10 20:03:33 -05:00
Graham SteffaniakandGraham Steffaniak 1429963e76 updated test (#44)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-10 10:03:03 -05:00
Graham Steffaniak 26f31b91fb updated to v0.2.1 2023-10-10 09:57:15 -05:00
Graham Steffaniak aab3224d2c Merge branch 'v0.2.1' 2023-10-10 09:57:06 -05:00
Graham Steffaniak a8d2651a1d updated change log 2023-10-10 09:55:28 -05:00
Graham Steffaniak c476d4b9f5 updated readme 2023-10-10 09:50:37 -05:00
Graham Steffaniak c07e204b97 updated workflow 2023-10-10 09:34:54 -05:00
Graham Steffaniak 1794bd5241 updated workflow 2023-10-10 09:19:31 -05:00
Graham Steffaniak d3daafe9fe updated workflow 2023-10-10 08:53:26 -05:00
Graham Steffaniak 3f7caf97c5 updated workflow 2023-10-10 08:46:33 -05:00
Graham Steffaniak 6695a88f6f updated 2023-10-10 08:31:36 -05:00
Graham Steffaniak 1d1f7da975 updated 2023-10-10 08:25:14 -05:00
Graham Steffaniak 370b9b11c0 updated workflow to strip v 2023-10-10 08:09:59 -05:00
Graham Steffaniak 4f4cf13e76 updated 2023-10-09 17:27:34 -05:00
Graham SteffaniakandGraham Steffaniak a33eab2a8c update settings and views (#41)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-09 17:24:48 -05:00
Graham SteffaniakandGraham Steffaniak 05b911143a Merge auth changes back into main (#42)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-10-09 12:45:41 -05:00
Graham SteffaniakandGraham Steffaniak 5506135d32 Steffag/refactor slices (#40)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-09-30 13:17:03 -05:00
Graham Steffaniak 8261511881 Fix perms (#39) 2023-09-30 12:50:20 -05:00
Graham SteffaniakandGraham Steffaniak 65159848c2 Steffag/refactor slices (#38)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2023-09-30 09:18:21 -05:00
Graham Steffaniak d6e9ed6272 updated workflow (#37) 2023-09-24 20:03:09 -05:00
Graham Steffaniak 8cdb79c915 28 disable settings (#34) 2023-09-24 13:57:11 -05:00
Graham Steffaniak cb37a50abc updated with rounded corners (#35) 2023-09-24 13:50:59 -05:00
Graham Steffaniak 88e391abf5 Merge branch 'main' of github.com:gtsteffaniak/filebrowser 2023-09-16 13:17:54 -05:00
Graham Steffaniak cddab82215 Merge branch 'main' of github.com:gtsteffaniak/filebrowser 2023-09-15 18:11:01 -05:00
Graham Steffaniak 44fc212146 workflow 2023-09-15 16:03:37 -05:00
Graham Steffaniak 60fd41f11c updated workflow 2023-09-15 15:57:22 -05:00
275 changed files with 20383 additions and 21628 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
# These owners will be the default owners for everything in the repo.
# Unless a later match takes precedence, @o1egl will be requested for
# review when someone opens a pull request.
* @o1egl
+13 -26
View File
@@ -1,43 +1,27 @@
name: main
name: main release
on:
push:
branches:
- 'main'
- "main"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: 1.21.1
- run: cd backend && go test -race -v ./...
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: 1.21.1
- run: cd backend && go fmt ./...
push_latest_to_registry:
needs: [lint, test]
name: Push latest
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
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761
with:
driver-opts: |
image=moby/buildkit:v0.10.6
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
@@ -45,13 +29,16 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
build-args: |
VERSION=${{ env.LATEST_TAG }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./Dockerfile
push: true
-59
View File
@@ -1,59 +0,0 @@
name: pr-merge
on:
pull_request:
branches:
- 'main'
- 'v\d+.\d+.\d+'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: 1.21.1
- run: cd backend && go test -race -v ./...
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: 1.21.1
- run: cd backend && go fmt ./...
push_pr_to_registry:
needs: [lint, test]
name: Push PR
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
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761
with:
driver-opts: |
image=moby/buildkit:v0.10.6
- 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@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: gtstef/filebrowser
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+56
View File
@@ -0,0 +1,56 @@
name: pr-request
on:
pull_request:
branches:
- "main"
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
#test_frontend:
# name: Push release
# runs-on: ubuntu-latest
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# - name: Set up QEMU
# uses: docker/setup-qemu-action@v3.0.0
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v3.0.0
# - name: Build
# uses: docker/build-push-action@v6
# with:
# context: .
# file: ./Dockerfile.playwright
# push: false
push_pr_to_registry:
name: Push PR
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: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
REVISION=${{ steps.meta.outputs.revision }}
+51
View File
@@ -0,0 +1,51 @@
name: dev tests
on:
push:
branches:
- "*"
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go test -race -v ./...
lint-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23.3'
- uses: golangci/golangci-lint-action@v5
with:
version: v1.60
working-directory: backend
format-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go fmt ./...
lint-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
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
+42 -27
View File
@@ -1,31 +1,43 @@
name: release
name: version release
on:
push:
branches:
- 'v\d+.\d+.\d+'
- "v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test:
create_release:
name: Create Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
- name: Checkout code
uses: actions/checkout@v4
with:
go-version: 1.21.1
- run: cd backend && go test -race -v ./...
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
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.21.1
- run: cd backend && go fmt ./...
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:
needs: [lint, test]
name: Push release
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -33,29 +45,32 @@ jobs:
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761
with:
driver-opts: |
image=moby/buildkit:v0.10.6
- name: Login to Docker Hub
# Only push to Docker Hub when making a release
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@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
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@v5
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.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+50
View File
@@ -0,0 +1,50 @@
name: dev release
on:
push:
branches:
- "dev_v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
push_release_to_registry:
name: Push release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 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
file: ./Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
-24
View File
@@ -1,24 +0,0 @@
name: 'Close stale issues and PRs'
permissions:
issues: write
pull-requests: write
on:
schedule:
- cron: '30 1 * * *'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v5
with:
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
close-issue-message: 'This issue was closed because it has been stalled for 30 days with no activity.'
days-before-stale: 30
days-before-close: 30
exempt-issue-labels: 'feature ☘,enhancement ⚙,bug 🐞'
exempt-pr-labels: 'need-help,wip'
operations-per-run: 100
+31
View File
@@ -0,0 +1,31 @@
name: tag
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
update_tag:
name: Update Release tag
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
- name: Install dependencies and build frontend
run: npm i && npm run build
working-directory: frontend
- name: Install UPX
run: sudo apt-get install -y upx
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
args: release --clean
workdir: backend
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6
View File
@@ -6,8 +6,14 @@ rice-box.go
/filebrowser
/filebrowser.exe
/frontend/dist
/frontend/pkg
/frontend/test-results
/frontend/package-lock.json
/backend/vendor
/backend/*.cov
/backend/test_config.yaml
/backend/srv
/backend/http/dist
.DS_Store
node_modules
+248 -40
View File
@@ -1,59 +1,267 @@
# Changelog
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
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.3.4
# v0.2.0
**Bugfixes**:
- Safari right-click actions.
- Some small image viewer behavior
- Progressive webapp "install to homescreen" fix.
- improved UI
- more unified coehisive look
- Adjusted header bar look and icon behavior
- The shell is dead.
- If you need to use custom commands, exec into the docker container.
- The json config file is dead.
- All configuration is done via advanced `filebrowser.yaml`
- The only flag that is allowed is flag to specify config file.
- Removed old code to migrate database versions
- Removed all unused cmd code
## v0.3.3
# v0.1.4
- various UI fixes
- Added download button back to toolbar
- Added upload button to side menu
- breadcrumb spacing fix
- Added "compact" view option
- fixed slash issue with css rtl logic
- various backend fixes
- search has a sessionId attached so searches don't collide
- search no longer searches by word with spaces, includes space in searches
- prepared for full json configuration
- made size search work for smaller and larger
- made search types not show up in search bar when used
**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.
**New Features**:
- listing view items are middle-clickable on selected listing or when in single-click mode.
- listing view items can be navigated via arrow keys.
- listing view can jump to items using letters and number keys to cycle through files that start with that character.
- You can use the enter key and backspace key to navigate backwards and forwards on selected items.
- ctr-space will open/close the search (leaving ctr-f to browser default find prompt)
- Added right-click context menu to replace the file selection prompt.
**Bugfixes**:
- Fixed drag to upload not working.
- Fixed shared video link issues.
- Fixed user edit bug related to other user.
- Fixed password reset bug.
- Fixed loading state getting stuck.
## v0.2.8
- **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
- **Fix**: move/replace file prompt issue
- **Fix**: opening files from search
- **Fix**: Display count issue when hideDotFile is enabled.
## v0.2.7
- **Change**: New sidebar style and behavior
- **Change**: make search view and button behavior more consistent.
- **Fix**: [upload file bug](https://github.com/gtsteffaniak/filebrowser/issues/153)
- **Fix**: user lock out bug introduced in 0.2.6
- **Fix**: many minor state related issues.
## v0.2.6
This change focuses on minimizing and simplifying build process.
- **Change**: Migrated to Vite / Vue 3
- **Change**: removed npm modules
- replaced vuex with custom state management via src/store
- replaced noty with simple card popup notifications
- replaced moment with simple date formatter where needed
- replaced vue-simple-progress with vue component
- **Feature**: improved error logging
- backend errors show the root function that called them during the error
- frontend errors print errors to console that fail try/catch
- all frontend errors via popup notification & print to console as well
- **Fix**: Allow editing blank text based files in editor
- tweaked listing styles
- Feature: Allow disabling the index via configuration yaml
## v0.2.5
- Fix: delete user prompt works using native hovers.
## v0.2.4
- Feature: [create-folder-feature](https://github.com/gtsteffaniak/filebrowser/pull/105)
- Feature: [playable shared video](https://github.com/filebrowser/filebrowser/issues/2537)
- Feature: photos, videos, and audio get embedded preview on share instead of icon
- Fix: sharable link bug, now uses special publicUser
- Bump go version to 1.22
- In prep for vue3 migration, npm modules removed:
- js-base64
- pretty-bytes
- whatwg-fetch
- lodash.throttle
- lodash.clonedeep
## v0.2.3
- Feature: token expiration time now configurable
- FIX: Hidden files are still directly accessible. (https://github.com/filebrowser/filebrowser/issues/2698)
- FIX: search/user context bug
## v0.2.2
- CHG: **Speed:** (0m57s) - Decreased by 78% compared to the previous release.
- CHG: **Memory Usage:** (41MB) - Reduced by 45% compared to the previous release.
- Feature: Now utilizes the index for file browser listings!
- FIX: Editor issues fixed on save and themes.
## v0.2.1
- Addressed issue #29 - Rules can now be configured and read from the configuration YAML.
- Addressed issue #28 - Allows disabling settings per user.
- Addressed issue #27 - Shortened download link for password-protected files.
- Addressed issue #26 - Enables dark mode per user and improves switching performance.
- Improved styling with more rounded corners and enhanced listing design.
- Enhanced search performance.
- Fixed authentication issues.
- Added compact view mode.
- Improved view mode configuration and behavior.
- Updated the configuration file to accept new settings.
## v0.2.0
- **Improved UI:**
- Enhanced the cohesive and unified look.
- Adjusted the header bar appearance and icon behavior.
- 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 `config.yaml`.
- The only allowed flag is specifying the config file.
- Removed old code for migrating database versions.
- Eliminated all unused `cmd` code.
## v0.1.4
- **Various UI fixes:**
- Reintroduced the download button to the toolbar.
- Added the upload button to the side menu.
- Adjusted breadcrumb spacing.
- Introduced a "compact" view option.
- Fixed a slash issue with CSS right-to-left (RTL) logic.
- **Various backend improvements:**
- Added session IDs to searches to prevent collisions.
- Modified search behavior to include spaces in searches.
- Prepared for full JSON configuration support.
- Made size-based searches work for both smaller and larger files.
- Modified search types not to appear in the search bar when used.
## v0.1.3
- improved styling, colors, transparency, blur
- Made sidebar hidden on desktop as well
- simplified navbar to be three buttons
- open menu
- search
- toggle view
- Changed desktop search style and included additional search options.
- Enhanced styling with improved colors, transparency, and blur effects.
- Hid the sidebar on desktop views.
- Simplified the navbar to include three buttons:
- Open menu
- Search
- Toggle view
- Revised desktop search style and included additional search options.
## v0.1.2
- Updated UI to use search features better
- More filter options
- Better icons with colors
- GUI styling
- Improved search performance
- Updated the UI to better utilize search features:
- Added more filter options.
- Enhanced icons with colors.
- Improved GUI styling.
- Improved search performance.
- **Index Changes:**
- **Speed:** (0m32s) - Increased by 6% compared to the previous release.
- **Memory Usage:** (93MB) - Increased by 3% compared to the previous release.
## v0.1.1
- Improved search with indexing
- Improved search functionality with indexing.
- **Index Changes (Baseline Results):**
- **Speed:** (0m30s)
- **Memory Usage:** (90MB)
## v0.1.0
- nothing changed from origin.
- No changes from the original.
Forked from https://github.com/filebrowser/filebrowser
Forked from [filebrowser/filebrowser](https://github.com/filebrowser/filebrowser).
+26 -19
View File
@@ -1,24 +1,31 @@
FROM node:slim as nbuild
FROM golang:1.23-alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./frontend/package*.json ./
RUN npm i
COPY ./frontend/ ./
RUN npm run build
COPY ./backend ./
#RUN swag init --output swagger/docs
RUN ln -s swagger /usr/local/go/src/
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM golang:1.21-alpine as base
FROM node:slim AS nbuild
WORKDIR /app
COPY ./backend ./
RUN go get -u golang.org/x/net
RUN go build -ldflags="-w -s" -o filebrowser .
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
RUN apk --no-cache add \
ca-certificates \
mailcap
VOLUME /srv
EXPOSE 8080
WORKDIR /
COPY --from=base /app/filebrowser.yaml /filebrowser.yaml
COPY --from=base /app/filebrowser /filebrowser
COPY --from=nbuild /app/dist/ /frontend/dist/
ENTRYPOINT [ "./filebrowser" ]
ENV FILEBROWSER_NO_EMBEDED="true"
RUN apk --no-cache add ca-certificates mailcap
WORKDIR /home/filebrowser
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
USER root
# exposing default port for auto discovery.
EXPOSE 80
ENTRYPOINT [ "./filebrowser" ]
+19
View File
@@ -0,0 +1,19 @@
FROM golang:1.23-alpine AS base
WORKDIR /app
COPY ./backend ./
RUN go build -ldflags="-w -s" -o filebrowser .
FROM node:slim
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
RUN npx playwright install --with-deps firefox
COPY [ "backend/config.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
+182 -68
View File
@@ -2,69 +2,91 @@
<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">
<img src="frontend/public/img/icons/favicon-256x256.png" width="100" title="Login With Custom URL">
</p>
<h3 align="center">Filebrowser - A modern file manager for the web</h3>
<h3 align="center">FileBrowser Quantum - A modern web-based file manager</h3>
<p align="center">
<img width="500" src="https://github.com/gtsteffaniak/filebrowser/assets/42989099/459937ef-3f14-408d-aef5-899cde4cf3a1" title="Main Screenshot">
<img width="800" src="https://github.com/user-attachments/assets/e4a47229-66f8-4838-9575-dd2413596688" title="Main Screenshot">
</p>
> **NOTE**
Intended for docker use only
> [!Note]
> Starting with v0.3.3, configuration file mapping is different to support non-root user. Now, the default config file name is `config.yaml` and in docker the path is `/home/filebrowser/config.yaml` and `/home/filebrowser/<database_file>`. Please read the usage below to properly update your config to point the new config location. (open an issue for any help needed)
> **Warning**
Starting with v0.2.0, *ALL* configuration is done via `filebrowser.yaml` configuration file. `.filebrowser.json` and any flags other than `-c` and `-config` during execution WILL NO LONGER WORK. This is by design, in order to use the v0.2.0 You can mount your directory and initialize a new DB with a new default `filebrowser.yaml` which you can tweak and use in the future. Or you can copy and paste the default startup `filebrowser.yaml` below.
> [!WARNING]
> - There is no stable version yet. Always check release notes for bug fixes on functionality that may have been changed. If you notice any unexpected behavior -- please open an issue to have it fixed soon.
This fork makes the following significant changes to filebrowser for origin:
FileBrowser Quantum is a fork of the file browser opensource project with the following changes:
1. [x] Improves search to use index instead of filesystem.
- Lightning fast, realtime results as you type
- Works with more type filters
1. [x] Improved and simplified GUI navbar and sidebar menu.
1. [x] Updated version and dependencies.
1. [x] **IMPORTANT** Moved all configurations to `filebrowser.yaml`.
1. [x] Indexes files efficiently. (See [indexing readme](./docs/indexing.md) 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. [x] Revamped and simplified GUI navbar and sidebar menu.
- Additional compact view mode as well as refreshed view mode
styles.
- Many graphical and user experience improvements.
- right-click context menu
3. [x] Revamped and simplified configuration via `config.yaml` config file.
4. [x] Better listing browsing
- Switching view modes is instant
- Folder sizes are shown as well
- Changing Sort order is instant
- The entire directory is loaded in 1/3 the time
5. [x] Developer API support
- Ability to create long-live API Tokens.
- Helpful Swagger page available at `/swagger` endpoint.
Notable features that this fork *does not* have (removed):
- jobs/runners are not supported yet (planned).
- shell commands are completely removed and will not be returned.
- Themes and branding are not fully supported yet (planned).
- see feature matrix below for more.
- pagination for directory items, so large directories with more than 100,000 items may be slow to load or not load at all.
## About
Filebrowser provides a file managing interface within a specified directory
and it 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 own
directory. It can be used as a standalone app.
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, a collection of changes that make this program
work better in terms of asthetics and performance. Improved search,
simplified ui (without removing features) and more secure and up-to-date
build are just a few examples.
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 from the original.
There are hundreds of thousands of lines changed and they are generally
no longer compatible with each other. This has been intentional -- the
focus of this fork is on a few key principles:
- Simplicity and improved user experience
- Improving performance and faster feedback when making changes.
- Minimize external dependencies and standard library usage.
- Of course -- adding much-needed features.
For more questions, see the [Q&A Readme](./docs/questions.md)
## Look
The UI has a simple three-component navigation system :
1. (Left) The slide-out action panel button
2. (Middle) The powerful search bar.
3. (Right) The view change toggle.
All other functions are moved either into the action menu or popup menus.
If the action does not depend on context, it will exist in the slide-out
action panel. If the action is available based on context, it will show up as
a popup menu.
<p align="center">
<img width="500" src="https://github.com/gtsteffaniak/filebrowser/assets/42989099/35cdeb3b-ab79-4b04-8001-8f51f6ea06bb" title="Dark mode">
<img width="500" src="https://github.com/gtsteffaniak/filebrowser/assets/42989099/8d426356-26cf-407b-b078-bf58f198e799" title="Dark mode2">
<img width="300" src="https://github.com/gtsteffaniak/filebrowser/assets/42989099/37e8f03b-4f5a-4689-aa6c-5cd858a858e9" title="Dark mode">
<img width="300" src="https://github.com/gtsteffaniak/filebrowser/assets/42989099/b04d3c1f-154b-45ba-927c-2112926ad3a9" title="Dark mode">
<img width="800" src="https://github.com/user-attachments/assets/2be7a6c5-0f95-4d9f-bc05-484ee71246d8" title="Search GIF">
<img width="800" src="https://github.com/user-attachments/assets/f55a6f1f-b930-4399-98b5-94da6e90527a" title="Navigation GIF">
<img width="800" src="https://github.com/user-attachments/assets/75226dc4-9802-46f0-9e3c-e4403d3275da" title="Main Screenshot">
</p>
## Search Performance
100x faster search. However, this will be at expense of RAM. if you have < 1 million
files and folders in the given scope, the RAM usage should be less than 200MB total. RAM requirements
should scale based on the number of directories.
Also , the approx. time to fully index will vary widely based on performance. A sufficiently performant
system should fully index within the first 5 minutes, potentially within the first few seconds.
For example, a low end 11th gen i5 with SSD indexes 128K files within 1 second:
```
2023/09/09 21:38:50 Initializing with config file: filebrowser.yaml
2023/09/09 21:38:50 Indexing files...
2023/09/09 21:38:50 Listening on [::]:8080
2023/09/09 21:38:51 Successfully indexed files.
2023/09/09 21:38:51 Files found : 123452
2023/09/09 21:38:51 Directories found : 1768
2023/09/09 21:38:51 Indexing scheduler will run every 5 minutes
```
## Install
Using docker:
@@ -72,39 +94,47 @@ Using docker:
1. docker run (no persistent db):
```
docker run -it -v /path/to/folder:/srv -p 80:8080 gtstef/filebrowser
docker run -it -v /path/to/folder:/srv -v $(pwd)/config.yaml:/home/filebrowser/config.yaml -p 80:80 gtstef/filebrowser
```
1. docker-compose:
or optionally, as non-root filebrowser user:
```
docker run -u filebrowser -it -v $(pwd)/config.yaml:/home/filebrowser/config.yaml -v /path/to/folder:/srv -p 80:80 gtstef/filebrowser
```
1. docker compose:
- 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
# optional if you want db to persist - configure a path under "database" dir in config file.
- './database:/home/filebrowser/database'
- './config.yaml:/home/filebrowser/config.yaml'
ports:
- '80:8080'
- '80:80'
image: gtstef/filebrowser
# optionally run as non-root filebrowser user
#user: 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
# optional if you want db to persist - configure a path under "database" dir in config file.
- './database:/home/filebrowser/database'
- './config.yaml:/home/filebrowser/config.yaml'
ports:
- '80:8080'
- '80:80'
image: gtstef/filebrowser
restart: always
volumes:
@@ -112,27 +142,111 @@ volumes:
driver_opts:
type: cifs
o: "username=admin,password=password,rw" # enter valid info here
device: "//192.168.1.100/share/" # enter valid hinfo here
device: "//192.168.1.100/share/" # enter valid info here
```
Not using docker (not recommended), download your binary from releases and run with your custom config file:
```
./filebrowser -c <config.yaml or other /path/to/config.yaml>
```
## Command Line Usage
There are very few commands available. There are 3 actions done via the command line:
1. Running the program, as shown in the install step. The only argument used is the config file if you choose to override the default "config.yaml"
2. Checking the version info via `./filebrowser version`
3. Updating the DB, which currently only supports adding users via `./filebrowser set -u username,password [-a] [-s "example/scope"]`
## API Usage
API tokens can be created to perform actions, access file information, and update user settings just like what can be done from the UI. You can create API tokens from the settings page via "API Management" section. This section will only show up if the user has "API" permissions, which can be granted by editing the user in user management.
Regardless of whether a user has API permissions, anyone can visit the swagger page which is found at `/swagger`. This swagger page uses a short-live token (2-hour exp) that the UI uses, but allows for quick access to all the API's and their described usage and requirements:
![image](https://github.com/user-attachments/assets/12abd1f6-21d3-4437-98ed-9b0da6cf2c73)
When using the API outside of swagger, you will need to set the API token as a bearer token authentication type. This means the authorization header will look like `Authorization: Bearer <token>`. For example in Postman:
Successful Request:
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4f18fa8a-8d87-4f40-9dc7-3d4407769b59"></p>
Failed Request
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4da0deae-f93d-4d94-83b1-68806afb343a"></p>
## Configuration
All configuration is now done via a single configuration file: `filebrowser.yaml`, here is an example minimal [configuration file](./backend/filebrowser.yaml).
All configuration is now done via a single configuration file:
`config.yaml`, here is an example of minimal [configuration
file](./backend/config.yaml).
View the [Configuration Help Page](./configuration.md) for available configuration options and other help.
View the [Configuration Help Page](./docs/configuration.md) for available
configuration options and other help.
### background & help
The original project filebrowser/filebrowser used multiple different ways to configure the server.
This was confusing and difficult to work with from a user and from a developer's perspective.
So I completely redesigned the program to use one single human-readable config file.
## Migration from the original filebrowser
I understand many coming from the original fork may notice differences which make using this improved version more difficult. If you notice issues that you believe should be fixed, please open an issue here and it will very likely be addressed with a PR within a few weeks.
I would recommend that you start fresh without reusing the database. However,
If you want to migrate your existing database to FileBrowser Quantum,
visit the [migration
readme](./docs/migration.md)
This version of filebrowser is going through a configuration overhaul as mentioned above. Certain features related to rules and commands may not work as they do on the original filebrowser. The purpose of this is to create a more consistent experience where configuration is done via files rather than running commands, so that it's very clear what the current state of the configuration is. When running commands its not clear what the configuration is.
## 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
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
ftp support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 31 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 128 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
price | free | free | free | free tier | free tier | $99+ |
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
Custom job support | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
2FA sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
sharable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
office file support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
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)
see [Roadmap Page](./docs/roadmap.md)
+232
View File
@@ -0,0 +1,232 @@
# This code is licensed under the terms of the MIT license https://opensource.org/license/mit
# Copyright (c) 2021 Marat Reymers
## Golden config for golangci-lint v1.59.1
#
# This is the best config for golangci-lint based on my experience and opinion.
# It is very strict, but not extremely strict.
# Feel free to adapt and change it for your needs.
run:
# Timeout for analysis, e.g. 30s, 5m.
# Default: 1m
timeout: 3m
# This file contains only configs which differ from defaults.
# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml
linters-settings:
cyclop:
# The maximal code complexity to report.
# Default: 10
max-complexity: 30
# The maximal average package complexity.
# If it's higher than 0.0 (float) the check is enabled
# Default: 0.0
package-average: 10.0
errcheck:
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
# Such cases aren't reported by default.
# Default: false
check-type-assertions: true
exhaustive:
# Program elements to check for exhaustiveness.
# Default: [ switch ]
check:
- switch
- map
exhaustruct:
# List of regular expressions to exclude struct packages and their names from checks.
# Regular expressions must match complete canonical struct package/name/structname.
# Default: []
exclude:
# std libs
- "^net/http.Client$"
- "^net/http.Cookie$"
- "^net/http.Request$"
- "^net/http.Response$"
- "^net/http.Server$"
- "^net/http.Transport$"
- "^net/url.URL$"
- "^os/exec.Cmd$"
- "^reflect.StructField$"
# public libs
- "^github.com/Shopify/sarama.Config$"
- "^github.com/Shopify/sarama.ProducerMessage$"
- "^github.com/mitchellh/mapstructure.DecoderConfig$"
- "^github.com/prometheus/client_golang/.+Opts$"
- "^github.com/spf13/cobra.Command$"
- "^github.com/spf13/cobra.CompletionOptions$"
- "^github.com/stretchr/testify/mock.Mock$"
- "^github.com/testcontainers/testcontainers-go.+Request$"
- "^github.com/testcontainers/testcontainers-go.FromDockerfile$"
- "^golang.org/x/tools/go/analysis.Analyzer$"
- "^google.golang.org/protobuf/.+Options$"
- "^gopkg.in/yaml.v3.Node$"
funlen:
# Checks the number of lines in a function.
# If lower than 0, disable the check.
# Default: 60
lines: 100
# Checks the number of statements in a function.
# If lower than 0, disable the check.
# Default: 40
statements: 50
# Ignore comments when counting lines.
# Default false
ignore-comments: true
gocognit:
# Minimal code complexity to report.
# Default: 30 (but we recommend 10-20)
min-complexity: 20
gocritic:
# Settings passed to gocritic.
# The settings key is the name of a supported gocritic checker.
# The list of supported checkers can be find in https://go-critic.github.io/overview.
settings:
captLocal:
# Whether to restrict checker to params only.
# Default: true
paramsOnly: false
underef:
# Whether to skip (*x).method() calls where x is a pointer receiver.
# Default: true
skipRecvDeref: false
gomodguard:
blocked:
# List of blocked modules.
# Default: []
modules:
- github.com/golang/protobuf:
recommendations:
- google.golang.org/protobuf
reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules"
- github.com/satori/go.uuid:
recommendations:
- github.com/google/uuid
reason: "satori's package is not maintained"
- github.com/gofrs/uuid:
recommendations:
- github.com/gofrs/uuid/v5
reason: "gofrs' package was not go module before v5"
govet:
# Enable all analyzers.
# Default: false
enable-all: true
# Disable analyzers by name.
# Run `go tool vet help` to see all analyzers.
# Default: []
disable:
- fieldalignment # too strict
# Settings per analyzer.
settings:
shadow:
# Whether to be strict about shadowing; can be noisy.
# Default: false
strict: false
inamedparam:
# Skips check for interface methods with only a single parameter.
# Default: false
skip-single-param: true
mnd:
# List of function patterns to exclude from analysis.
# Values always ignored: `time.Date`,
# `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`,
# `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`.
# Default: []
ignored-functions:
- args.Error
- flag.Arg
- flag.Duration.*
- flag.Float.*
- flag.Int.*
- flag.Uint.*
- os.Chmod
- os.Mkdir.*
- os.OpenFile
- os.WriteFile
- prometheus.ExponentialBuckets.*
- prometheus.LinearBuckets
nakedret:
# Make an issue if func has more lines of code than this setting, and it has naked returns.
# Default: 30
max-func-lines: 0
nolintlint:
# Exclude following linters from requiring an explanation.
# Default: []
allow-no-explanation: [ funlen, gocognit, lll ]
# Enable to require an explanation of nonzero length after each nolint directive.
# Default: false
require-explanation: true
# Enable to require nolint directives to mention the specific linter being suppressed.
# Default: false
require-specific: true
perfsprint:
# Optimizes into strings concatenation.
# Default: true
strconcat: false
rowserrcheck:
# database/sql is always checked
# Default: []
packages:
- github.com/jmoiron/sqlx
sloglint:
# Enforce not using global loggers.
# Values:
# - "": disabled
# - "all": report all global loggers
# - "default": report only the default slog logger
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global
# Default: ""
no-global: "all"
# Enforce using methods that accept a context.
# Values:
# - "": disabled
# - "all": report all contextless calls
# - "scope": report only if a context exists in the scope of the outermost function
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only
# Default: ""
context: "scope"
tenv:
# The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures.
# Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked.
# Default: false
all: true
issues:
# Maximum count of issues with the same text.
# Set to 0 to disable.
# Default: 3
max-same-issues: 50
exclude-rules:
- source: "(noinspection|TODO)"
linters: [ godot ]
- source: "//noinspection"
linters: [ gocritic ]
- path: "_test\\.go"
linters:
- bodyclose
- dupl
- funlen
- goconst
- gosec
- noctx
- wrapcheck
+43
View File
@@ -0,0 +1,43 @@
# .goreleaser.yaml
project_name: filebrowser
version: 2
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 }}
main: main.go
binary: filebrowser
goos:
- darwin
- linux
goarch:
- amd64
- arm
- arm64
goarm:
- 6
- 7
hooks:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm
# - id: windows
# ldflags: *ldflags
# main: main.go
# binary: filebrowser
# goos:
# - windows
# goarch:
# - amd64
# - arm64
archives:
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
format: binary
checksum:
disable: true
+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
}
+23 -39
View File
@@ -9,10 +9,9 @@ import (
"os/exec"
"strings"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/files"
"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/users"
)
type hookCred struct {
@@ -31,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 {
@@ -44,15 +43,14 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
}
a.Users = usr
a.Settings = &settings.GlobalConfiguration
a.Server = &settings.GlobalConfiguration.Server
a.Settings = &settings.Config
a.Server = &settings.Config.Server
a.Cred = cred
action, err := a.RunCommand()
if err != nil {
return nil, err
}
switch action {
case "auth":
u, err := a.SaveUser()
@@ -146,14 +144,10 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
}
if u == nil {
pass, err := users.HashPwd(a.Cred.Password)
if err != nil {
return nil, err
}
// create user with the provided credentials
d := &users.User{
Username: a.Cred.Username,
Password: pass,
Password: a.Cred.Password,
Scope: a.Settings.UserDefaults.Scope,
Locale: a.Settings.UserDefaults.Locale,
ViewMode: a.Settings.UserDefaults.ViewMode,
@@ -178,16 +172,6 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
}
} else if p := !users.CheckPwd(a.Cred.Password, u.Password); len(a.Fields.Values) > 1 || p {
u = a.GetUser(u)
// update the password when it doesn't match the current
if p {
pass, err := users.HashPwd(a.Cred.Password)
if err != nil {
return nil, err
}
u.Password = pass
}
// update user with provided fields
err := a.Users.Update(u)
if err != nil {
@@ -201,31 +185,31 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
// GetUser returns a User filled with hook values or provided defaults
func (a *HookAuth) GetUser(d *users.User) *users.User {
// adds all permissions when user is admin
isAdmin := a.Fields.GetBoolean("user.perm.admin", d.Perm.Admin)
isAdmin := d.Perm.Admin
perms := users.Permissions{
Admin: isAdmin,
Execute: isAdmin || a.Fields.GetBoolean("user.perm.execute", d.Perm.Execute),
Create: isAdmin || a.Fields.GetBoolean("user.perm.create", d.Perm.Create),
Rename: isAdmin || a.Fields.GetBoolean("user.perm.rename", d.Perm.Rename),
Modify: isAdmin || a.Fields.GetBoolean("user.perm.modify", d.Perm.Modify),
Delete: isAdmin || a.Fields.GetBoolean("user.perm.delete", d.Perm.Delete),
Share: isAdmin || a.Fields.GetBoolean("user.perm.share", d.Perm.Share),
Download: isAdmin || a.Fields.GetBoolean("user.perm.download", d.Perm.Download),
Execute: isAdmin || d.Perm.Execute,
Create: isAdmin || d.Perm.Create,
Rename: isAdmin || d.Perm.Rename,
Modify: isAdmin || d.Perm.Modify,
Delete: isAdmin || d.Perm.Delete,
Share: isAdmin || d.Perm.Share,
Download: isAdmin || d.Perm.Download,
}
user := users.User{
ID: d.ID,
Username: d.Username,
Password: d.Password,
Scope: a.Fields.GetString("user.scope", d.Scope),
Locale: a.Fields.GetString("user.locale", d.Locale),
Scope: d.Scope,
Locale: d.Locale,
ViewMode: d.ViewMode,
SingleClick: a.Fields.GetBoolean("user.singleClick", d.SingleClick),
Sorting: files.Sorting{
Asc: a.Fields.GetBoolean("user.sorting.asc", d.Sorting.Asc),
By: a.Fields.GetString("user.sorting.by", d.Sorting.By),
SingleClick: d.SingleClick,
Sorting: users.Sorting{
Asc: d.Sorting.Asc,
By: d.Sorting.By,
},
Commands: a.Fields.GetArray("user.commands", d.Commands),
HideDotfiles: a.Fields.GetBoolean("user.hideDotfiles", d.HideDotfiles),
Commands: d.Commands,
HideDotfiles: d.HideDotfiles,
Perm: perms,
LockPassword: true,
}
+5 -7
View File
@@ -7,8 +7,8 @@ import (
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type jsonCred struct {
@@ -23,14 +23,13 @@ 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.GlobalConfiguration
func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
config := &settings.Config
var cred jsonCred
if r.Body == nil {
return nil, os.ErrPermission
}
err := json.NewDecoder(r.Body).Decode(&cred)
if err != nil {
return nil, os.ErrPermission
@@ -48,8 +47,7 @@ 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)
u, err := userStore.Get(config.Server.Root, cred.Username)
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
return nil, os.ErrPermission
}
+4 -4
View File
@@ -3,8 +3,8 @@ package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// MethodNoAuth is used to identify no auth.
@@ -14,8 +14,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.GlobalConfiguration.Server.Root, uint(1))
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return usr.Get(settings.Config.Server.Root, uint(1))
}
// LoginPage tells that no auth doesn't require a login page.
+5 -5
View File
@@ -4,10 +4,10 @@ import (
"net/http"
"os"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/backend/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 +19,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.GlobalConfiguration.Server.Root, username)
user, err := usr.Get(settings.Config.Server.Root, username)
if err == errors.ErrNotExist {
return nil, os.ErrPermission
}
+1 -1
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.
+21 -20
View File
@@ -5,38 +5,39 @@
? github.com/gtsteffaniak/filebrowser/auth [no test files]
? github.com/gtsteffaniak/filebrowser/cmd [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/diskcache 0.003s
ok github.com/gtsteffaniak/filebrowser/diskcache 0.004s
? github.com/gtsteffaniak/filebrowser/errors [no test files]
? github.com/gtsteffaniak/filebrowser/files [no test files]
2024/10/07 12:46:34 could not update unknown type: unknown
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/files
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 10 3847878 ns/op 758424 B/op 5567 allocs/op
BenchmarkSearchAllIndexes-8 10 780431 ns/op 173444 B/op 2014 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/files 0.073s
PASS
ok github.com/gtsteffaniak/filebrowser/fileutils 0.003s
2023/09/10 09:17:55 h: 401 <nil>
2023/09/10 09:17:55 h: 401 <nil>
2023/09/10 09:17:55 h: 401 <nil>
2023/09/10 09:17:56 h: 401 <nil>
2023/09/10 09:17:56 h: 401 <nil>
2023/09/10 09:17:56 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
PASS
ok github.com/gtsteffaniak/filebrowser/http 0.082s
ok github.com/gtsteffaniak/filebrowser/http 0.080s
PASS
ok github.com/gtsteffaniak/filebrowser/img 0.118s
ok github.com/gtsteffaniak/filebrowser/img 0.137s
PASS
ok github.com/gtsteffaniak/filebrowser/rules 0.002s
PASS
ok github.com/gtsteffaniak/filebrowser/runner 0.003s
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/search
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkSearchAllIndexes-8 10 5311144 ns/op 2934075 B/op 44569 allocs/op
BenchmarkFillIndex-8 10 3142607 ns/op 18473 B/op 452 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/search 0.110s
PASS
ok github.com/gtsteffaniak/filebrowser/settings 0.005s
ok github.com/gtsteffaniak/filebrowser/settings 0.004s
? github.com/gtsteffaniak/filebrowser/share [no test files]
? github.com/gtsteffaniak/filebrowser/storage [no test files]
? github.com/gtsteffaniak/filebrowser/storage/bolt [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/users 0.003s
ok github.com/gtsteffaniak/filebrowser/users 0.002s
? github.com/gtsteffaniak/filebrowser/utils [no test files]
? github.com/gtsteffaniak/filebrowser/version [no test files]
-25
View File
@@ -1,25 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
)
func init() {
rootCmd.AddCommand(hashCmd)
}
var hashCmd = &cobra.Command{
Use: "hash <password>",
Short: "Hashes a password",
Long: `Hashes a password using bcrypt algorithm.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
pwd, err := users.HashPwd(args[0])
checkErr(err)
fmt.Println(pwd)
},
}
+138 -156
View File
@@ -1,179 +1,161 @@
package cmd
import (
"crypto/tls"
"flag"
"io"
"io/fs"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"strings"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/img"
"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/afero"
"github.com/spf13/cobra"
lumberjack "gopkg.in/natefinch/lumberjack.v2"
"github.com/gtsteffaniak/filebrowser/auth"
"github.com/gtsteffaniak/filebrowser/diskcache"
fbhttp "github.com/gtsteffaniak/filebrowser/http"
"github.com/gtsteffaniak/filebrowser/img"
"github.com/gtsteffaniak/filebrowser/search"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
var (
configFile string
)
type dirFS struct {
http.Dir
func getStore(config string) (*storage.Storage, bool) {
// Use the config file (global flag)
settings.Initialize(config)
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
log.Fatal("could not load db info: ", err)
}
return store, 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.Println("Initializing with config file:", *configFlag)
settings.Initialize(*configFlag)
}
var rootCmd = &cobra.Command{
Use: "filebrowser",
Run: python(func(cmd *cobra.Command, args []string, d pythonData) {
serverConfig := settings.GlobalConfiguration.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)
var fileCache diskcache.Interface = diskcache.NewNoOp()
cacheDir := "/tmp"
if cacheDir != "" {
if err := os.MkdirAll(cacheDir, 0700); err != nil { //nolint:govet,gomnd
log.Fatalf("can't make directory %s: %s", cacheDir, err)
}
fileCache = diskcache.New(afero.NewOsFs(), cacheDir)
}
// initialize indexing and schedule indexing ever n minutes (default 5)
go search.InitializeIndex(serverConfig.IndexingInterval)
_, err := os.Stat(serverConfig.Root)
checkErr(err)
var listener net.Listener
address := serverConfig.Address + ":" + strconv.Itoa(serverConfig.Port)
switch {
case serverConfig.Socket != "":
listener, err = net.Listen("unix", serverConfig.Socket)
checkErr(err)
socketPerm, err := cmd.Flags().GetUint32("socket-perm") //nolint:govet
checkErr(err)
err = os.Chmod(serverConfig.Socket, os.FileMode(socketPerm))
checkErr(err)
case serverConfig.TLSKey != "" && serverConfig.TLSCert != "":
cer, err := tls.LoadX509KeyPair(serverConfig.TLSCert, serverConfig.TLSKey) //nolint:govet
checkErr(err)
listener, err = tls.Listen("tcp", address, &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cer}},
)
checkErr(err)
default:
listener, err = net.Listen("tcp", address)
checkErr(err)
}
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
go cleanupHandler(listener, sigc)
assetsFs := dirFS{Dir: http.Dir("frontend/dist")}
handler, err := fbhttp.NewHandler(imgSvc, fileCache, d.store, &serverConfig, assetsFs)
checkErr(err)
defer listener.Close()
log.Println("Listening on", listener.Addr().String())
//nolint: gosec
if err := http.Serve(listener, handler); err != nil {
log.Fatal(err)
}
}, pythonConfig{allowNoDB: true}),
func generalUsage() {
fmt.Printf(`usage: ./html-web-crawler <command> [options] --urls <urls>
commands:
collect Collect data from URLs
crawl Crawl URLs and collect data
install Install chrome browser for javascript enabled scraping.
Note: Consider instead to install via native package manager,
then set "CHROME_EXECUTABLE" in the environment
` + "\n")
}
func StartFilebrowser() {
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
// Global flags
var configPath string
var help bool
// Override the default usage output to use generalUsage()
flag.Usage = generalUsage
flag.StringVar(&configPath, "c", "config.yaml", "Path to the config file, default: config.yaml")
flag.BoolVar(&help, "h", false, "Get help about commands")
// Parse global flags (before subcommands)
flag.Parse() // print generalUsage on error
// Show help if requested
if help {
generalUsage()
return
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, scope, dbConfig string
var asAdmin bool
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
setCmd.BoolVar(&asAdmin, "a", false, "Create user as admin user, used in combination with -u")
setCmd.StringVar(&scope, "s", "", "Specify a user scope, otherwise default user config scope is used")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "set":
err := setCmd.Parse(os.Args)
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
userInfo := strings.Split(user, ",")
if len(userInfo) < 2 {
fmt.Println("not enough info to create user: \"set -u username,password\"")
setCmd.PrintDefaults()
os.Exit(1)
}
username := userInfo[0]
password := userInfo[1]
getStore(dbConfig)
// Create the user logic
if asAdmin {
log.Printf("Creating user as admin: %s\n", username)
} else {
log.Printf("Creating user: %s\n", username)
}
newUser := users.User{
Username: username,
Password: password,
}
if scope != "" {
newUser.Scope = scope
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
log.Fatal("Could not create user: ", err)
}
return
case "version":
fmt.Println("FileBrowser Quantum - A modern web-based file manager")
fmt.Printf("Version : %v\n", version.Version)
fmt.Printf("Commit : %v\n", version.CommitSHA)
fmt.Printf("Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v\n", version.Version)
return
}
}
store, dbExists := getStore(configPath)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
log.Printf("Initializing FileBrowser Quantum (%v)\n", version.Version)
log.Printf("Using Config file : %v", configPath)
log.Println("Embeded frontend :", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true")
log.Println(database)
log.Println("Sources :", settings.Config.Server.Root)
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL
swag.Register(docs.SwaggerInfo.InstanceName(), swagInfo)
// initialize indexing and schedule indexing ever n minutes (default 5)
go files.InitializeIndex(serverConfig.Indexing)
if err := rootCMD(store, &serverConfig); err != nil {
log.Fatal("Error starting filebrowser:", err)
}
}
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)
}
//nolint:gocyclo
func getRunParams(st *storage.Storage) *settings.Server {
server, err := st.Settings.GetServer()
checkErr(err)
return server
}
func setupLog(logMethod string) {
switch logMethod {
case "stdout":
log.SetOutput(os.Stdout)
case "stderr":
log.SetOutput(os.Stderr)
case "":
log.SetOutput(io.Discard)
default:
log.SetOutput(&lumberjack.Logger{
Filename: logMethod,
MaxSize: 100,
MaxAge: 14,
MaxBackups: 10,
})
func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
if serverConfig.NumImageProcessors < 1 {
log.Fatal("Image resize workers count could not be < 1")
}
}
imgSvc := img.New(serverConfig.NumImageProcessors)
func quickSetup(d pythonData) {
settings.GlobalConfiguration.Key = generateKey()
var err error
if settings.GlobalConfiguration.Auth.Method == "noauth" {
err = d.store.Auth.Save(&auth.NoAuth{})
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 {
settings.GlobalConfiguration.Auth.Method = "password"
err = d.store.Auth.Save(&auth.JSONAuth{})
// No-op cache if no cacheDir is specified
fileCache = diskcache.NewNoOp()
}
err = d.store.Settings.Save(&settings.GlobalConfiguration)
checkErr(err)
err = d.store.Settings.SaveServer(&settings.GlobalConfiguration.Server)
checkErr(err)
username := settings.GlobalConfiguration.AdminUsername
password := settings.GlobalConfiguration.AdminPassword
if username == "" || password == "" {
log.Fatal("username and password cannot be empty during quick setup")
}
user := &users.User{
Username: username,
Password: password,
LockPassword: false,
}
settings.GlobalConfiguration.UserDefaults.Apply(user)
user.Perm.Admin = true
err = d.store.Users.Save(user)
checkErr(err)
fbhttp.StartHttp(imgSvc, store, fileCache)
return nil
}
+13 -11
View File
@@ -5,8 +5,10 @@ import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
@@ -40,27 +42,27 @@ including 'index_end'.`,
return nil
},
Run: python(func(cmd *cobra.Command, args []string, d pythonData) {
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
i, err := strconv.Atoi(args[0])
checkErr(err)
utils.CheckErr("strconv.Atoi", err)
f := i
if len(args) == 2 { //nolint:gomnd
f, err = strconv.Atoi(args[1])
checkErr(err)
utils.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(err)
err := store.Users.Save(u)
utils.CheckErr("store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
err := d.store.Settings.Save(s)
checkErr(err)
err := store.Settings.Save(s)
utils.CheckErr("store.Settings.Save", err)
}
runRules(d.store, cmd, user, global)
}, pythonConfig{}),
runRules(store, cmd, user, global)
}),
}
+7 -13
View File
@@ -6,14 +6,13 @@ import (
"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"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
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")
}
@@ -33,7 +32,7 @@ func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User)
id := getUserIdentifier(cmd.Flags())
if id != nil {
user, err := st.Users.Get("", id)
checkErr(err)
utils.CheckErr("st.Users.Get", err)
if usersFn != nil {
usersFn(user)
@@ -44,7 +43,7 @@ func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User)
}
s, err := st.Settings.Get()
checkErr(err)
utils.CheckErr("st.Settings.Get", err)
if globalFn != nil {
globalFn(s)
@@ -66,12 +65,7 @@ func getUserIdentifier(flags *pflag.FlagSet) interface{} {
return nil
}
func printRules(rulez []rules.Rule, id interface{}) {
if id == nil {
fmt.Printf("Global Rules:\n\n")
} else {
fmt.Printf("Rules for user %v:\n\n", id)
}
func printRules(rulez []users.Rule, id interface{}) {
for id, rule := range rulez {
fmt.Printf("(%d) ", id)
+13 -12
View File
@@ -5,9 +5,10 @@ import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/rules"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
@@ -21,7 +22,7 @@ var rulesAddCmd = &cobra.Command{
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) {
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
allow := mustGetBool(cmd.Flags(), "allow")
regex := mustGetBool(cmd.Flags(), "regex")
exp := args[0]
@@ -30,29 +31,29 @@ var rulesAddCmd = &cobra.Command{
regexp.MustCompile(exp)
}
rule := rules.Rule{
rule := users.Rule{
Allow: allow,
Regex: regex,
}
if regex {
rule.Regexp = &rules.Regexp{Raw: exp}
rule.Regexp = &users.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(err)
err := store.Users.Save(u)
utils.CheckErr("store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules, rule)
err := d.store.Settings.Save(s)
checkErr(err)
err := store.Settings.Save(s)
utils.CheckErr("store.Settings.Save", err)
}
runRules(d.store, cmd, user, global)
}, pythonConfig{}),
runRules(store, cmd, user, global)
}),
}
+4 -3
View File
@@ -1,6 +1,7 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/spf13/cobra"
)
@@ -13,7 +14,7 @@ var rulesLsCommand = &cobra.Command{
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{}),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
runRules(store, cmd, nil, nil)
}),
}
+1 -81
View File
@@ -1,23 +1,16 @@
package cmd
import (
"errors"
"fmt"
"os"
"strconv"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
func init() {
rootCmd.AddCommand(usersCmd)
}
var usersCmd = &cobra.Command{
Use: "users",
Short: "Users management utility",
@@ -59,76 +52,3 @@ func parseUsernameOrID(arg string) (username string, id uint) {
}
return "", uint(id64)
}
func addUserFlags(flags *pflag.FlagSet) {
flags.Bool("perm.admin", false, "admin perm for users")
flags.Bool("perm.execute", true, "execute perm for users")
flags.Bool("perm.create", true, "create perm for users")
flags.Bool("perm.rename", true, "rename perm for users")
flags.Bool("perm.modify", true, "modify perm for users")
flags.Bool("perm.delete", true, "delete perm for users")
flags.Bool("perm.share", true, "share perm for users")
flags.Bool("perm.download", true, "download perm for users")
flags.String("sorting.by", "name", "sorting mode (name, size or modified)")
flags.Bool("sorting.asc", false, "sorting by ascending order")
flags.Bool("lockPassword", false, "lock password")
flags.StringSlice("commands", nil, "a list of the commands a user can execute")
flags.String("scope", ".", "scope for users")
flags.String("locale", "en", "locale for users")
flags.String("viewMode", string(users.ListViewMode), "view mode for users")
flags.Bool("singleClick", false, "use single clicks only")
}
func getViewMode(flags *pflag.FlagSet) string {
viewMode := settings.GlobalConfiguration.UserDefaults.ViewMode
if viewMode != users.ListViewMode && viewMode != users.MosaicViewMode {
checkErr(errors.New("view mode must be \"" + string(users.ListViewMode) + "\" or \"" + string(users.MosaicViewMode) + "\""))
}
return viewMode
}
//nolint:gocyclo
func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all bool) {
visit := func(flag *pflag.Flag) {
switch flag.Name {
case "scope":
defaults.Scope = mustGetString(flags, flag.Name)
case "locale":
defaults.Locale = mustGetString(flags, flag.Name)
case "viewMode":
defaults.ViewMode = getViewMode(flags)
case "singleClick":
defaults.SingleClick = mustGetBool(flags, flag.Name)
case "perm.admin":
defaults.Perm.Admin = mustGetBool(flags, flag.Name)
case "perm.execute":
defaults.Perm.Execute = mustGetBool(flags, flag.Name)
case "perm.create":
defaults.Perm.Create = mustGetBool(flags, flag.Name)
case "perm.rename":
defaults.Perm.Rename = mustGetBool(flags, flag.Name)
case "perm.modify":
defaults.Perm.Modify = mustGetBool(flags, flag.Name)
case "perm.delete":
defaults.Perm.Delete = mustGetBool(flags, flag.Name)
case "perm.share":
defaults.Perm.Share = mustGetBool(flags, flag.Name)
case "perm.download":
defaults.Perm.Download = mustGetBool(flags, flag.Name)
case "commands":
commands, err := flags.GetStringSlice(flag.Name)
checkErr(err)
defaults.Commands = commands
case "sorting.by":
defaults.Sorting.By = mustGetString(flags, flag.Name)
case "sorting.asc":
defaults.Sorting.Asc = mustGetBool(flags, flag.Name)
}
}
if all {
flags.VisitAll(visit)
} else {
flags.Visit(visit)
}
}
+13 -21
View File
@@ -3,12 +3,13 @@ package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
usersCmd.AddCommand(usersAddCmd)
addUserFlags(usersAddCmd.Flags())
}
var usersAddCmd = &cobra.Command{
@@ -16,35 +17,26 @@ var usersAddCmd = &cobra.Command{
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) {
s, err := d.store.Settings.Get()
checkErr(err)
password, err := users.HashPwd(args[1])
checkErr(err)
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
user := &users.User{
Username: args[0],
Password: password,
Password: args[1],
LockPassword: mustGetBool(cmd.Flags(), "lockPassword"),
}
s.UserDefaults.Apply(user)
servSettings, err := d.store.Settings.GetServer()
checkErr(err)
servSettings, err := store.Settings.GetServer()
utils.CheckErr("store.Settings.GetServer()", err)
// since getUserDefaults() polluted s.Defaults.Scope
// which makes the Scope not the one saved in the db
// we need the right s.Defaults.Scope here
s2, err := d.store.Settings.Get()
checkErr(err)
s2, err := store.Settings.Get()
utils.CheckErr("store.Settings.Get()", err)
userHome, err := s2.MakeUserDir(user.Username, user.Scope, servSettings.Root)
checkErr(err)
utils.CheckErr("s2.MakeUserDir", err)
user.Scope = userHome
err = d.store.Users.Save(user)
checkErr(err)
err = store.Users.Save(user)
utils.CheckErr("store.Users.Save", err)
printUsers([]*users.User{user})
}, pythonConfig{}),
}),
}
+7 -5
View File
@@ -1,6 +1,8 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/spf13/cobra"
)
@@ -14,11 +16,11 @@ var usersExportCmd = &cobra.Command{
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(err)
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
list, err := store.Users.Gets("")
utils.CheckErr("store.Users.Gets", err)
err = marshal(args[0], list)
checkErr(err)
}, pythonConfig{}),
utils.CheckErr("marshal", err)
}),
}
+9 -7
View File
@@ -3,7 +3,9 @@ package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
@@ -26,7 +28,7 @@ var usersLsCmd = &cobra.Command{
Run: findUsers,
}
var findUsers = python(func(cmd *cobra.Command, args []string, d pythonData) {
var findUsers = cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
var (
list []*users.User
user *users.User
@@ -36,16 +38,16 @@ var findUsers = python(func(cmd *cobra.Command, args []string, d pythonData) {
if len(args) == 1 {
username, id := parseUsernameOrID(args[0])
if username != "" {
user, err = d.store.Users.Get("", username)
user, err = store.Users.Get("", username)
} else {
user, err = d.store.Users.Get("", id)
user, err = store.Users.Get("", id)
}
list = []*users.User{user}
} else {
list, err = d.store.Users.Gets("")
list, err = store.Users.Gets("")
}
checkErr(err)
utils.CheckErr("findUsers", err)
printUsers(list)
}, pythonConfig{})
})
+20 -21
View File
@@ -8,7 +8,9 @@ import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
@@ -25,50 +27,47 @@ 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) {
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
fd, err := os.Open(args[0])
checkErr(err)
utils.CheckErr("os.Open", err)
defer fd.Close()
list := []*users.User{}
err = unmarshal(args[0], &list)
checkErr(err)
for _, user := range list {
err = user.Clean("")
checkErr(err)
}
utils.CheckErr("unmarshal", err)
if mustGetBool(cmd.Flags(), "replace") {
oldUsers, err := d.store.Users.Gets("")
checkErr(err)
oldUsers, err := store.Users.Gets("")
utils.CheckErr("store.Users.Gets", err)
err = marshal("users.backup.json", list)
checkErr(err)
utils.CheckErr("marshal users.backup.json", err)
for _, user := range oldUsers {
err = d.store.Users.Delete(user.ID)
checkErr(err)
err = store.Users.Delete(user.ID)
utils.CheckErr("store.Users.Delete", err)
}
}
overwrite := mustGetBool(cmd.Flags(), "overwrite")
for _, user := range list {
onDB, err := d.store.Users.Get("", user.ID)
onDB, err := store.Users.Get("", user.ID)
// User exists in DB.
if err == nil {
if !overwrite {
checkErr(errors.New("user " + strconv.Itoa(int(user.ID)) + " is already registred"))
newErr := errors.New("user " + strconv.Itoa(int(user.ID)) + " is already registered")
utils.CheckErr("", newErr)
}
// If the usernames mismatch, check if there is another one in the DB
// with the new username. If there is, print an error and cancel the
// operation
if user.Username != onDB.Username {
if conflictuous, err := d.store.Users.Get("", user.Username); err == nil { //nolint:govet
checkErr(usernameConflictError(user.Username, conflictuous.ID, user.ID))
if conflictuous, err := store.Users.Get("", user.Username); err == nil { //nolint:govet
newErr := usernameConflictError(user.Username, conflictuous.ID, user.ID)
utils.CheckErr("usernameConflictError", newErr)
}
}
} else {
@@ -77,10 +76,10 @@ list or set it to 0.`,
user.ID = 0
}
err = d.store.Users.Save(user)
checkErr(err)
err = store.Users.Save(user)
utils.CheckErr("store.Users.Save", err)
}
}, pythonConfig{}),
}),
}
func usernameConflictError(username string, originalID, newID uint) error {
+9 -7
View File
@@ -1,8 +1,10 @@
package cmd
import (
"fmt"
"log"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/spf13/cobra"
)
@@ -15,17 +17,17 @@ var usersRmCmd = &cobra.Command{
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) {
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
username, id := parseUsernameOrID(args[0])
var err error
if username != "" {
err = d.store.Users.Delete(username)
err = store.Users.Delete(username)
} else {
err = d.store.Users.Delete(id)
err = store.Users.Delete(id)
}
checkErr(err)
fmt.Println("user deleted successfully")
}, pythonConfig{}),
utils.CheckErr("usersRmCmd", err)
log.Println("user deleted successfully")
}),
}
+10 -32
View File
@@ -3,15 +3,13 @@ package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
usersCmd.AddCommand(usersUpdateCmd)
usersUpdateCmd.Flags().StringP("password", "p", "", "new password")
usersUpdateCmd.Flags().StringP("username", "u", "", "new username")
addUserFlags(usersUpdateCmd.Flags())
}
var usersUpdateCmd = &cobra.Command{
@@ -20,11 +18,8 @@ var usersUpdateCmd = &cobra.Command{
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) {
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
username, id := parseUsernameOrID(args[0])
flags := cmd.Flags()
password := mustGetString(flags, "password")
newUsername := mustGetString(flags, "username")
var (
err error
@@ -32,31 +27,14 @@ options you want to change.`,
)
if id != 0 {
user, err = d.store.Users.Get("", id)
user, err = store.Users.Get("", id)
} else {
user, err = d.store.Users.Get("", username)
user, err = store.Users.Get("", username)
}
checkErr(err)
user.Scope = user.Scope
user.Locale = user.Locale
user.ViewMode = user.ViewMode
user.SingleClick = user.SingleClick
user.Perm = user.Perm
user.Commands = user.Commands
user.Sorting = user.Sorting
user.LockPassword = user.LockPassword
utils.CheckErr("store.Users.Get", err)
if newUsername != "" {
user.Username = newUsername
}
if password != "" {
user.Password, err = users.HashPwd(password)
checkErr(err)
}
err = d.store.Users.Update(user)
checkErr(err)
err = store.Users.Update(user)
utils.CheckErr("store.Users.Update", err)
printUsers([]*users.User{user})
}, pythonConfig{}),
}),
}
+11 -116
View File
@@ -3,113 +3,42 @@ package cmd
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"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"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func checkErr(err error) {
if err != nil {
log.Fatal(err)
}
}
func mustGetString(flags *pflag.FlagSet, flag string) string {
s, err := flags.GetString(flag)
checkErr(err)
utils.CheckErr("mustGetString", err)
return s
}
func mustGetBool(flags *pflag.FlagSet, flag string) bool {
b, err := flags.GetBool(flag)
checkErr(err)
utils.CheckErr("mustGetBool", err)
return b
}
func mustGetUint(flags *pflag.FlagSet, flag string) uint {
b, err := flags.GetUint(flag)
checkErr(err)
utils.CheckErr("mustGetUint", err)
return b
}
func generateKey() []byte {
k, err := settings.GenerateKey()
checkErr(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.GlobalConfiguration.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(err)
defer db.Close()
data.store, err = bolt.NewStorage(db)
checkErr(err)
fn(cmd, args, data)
}
}
type pythonFunc func(cmd *cobra.Command, args []string, store *storage.Storage)
func marshal(filename string, data interface{}) error {
fd, err := os.Create(filename)
checkErr(err)
utils.CheckErr("os.Create", err)
defer fd.Close()
switch ext := filepath.Ext(filename); ext {
@@ -127,7 +56,7 @@ func marshal(filename string, data interface{}) error {
func unmarshal(filename string, data interface{}) error {
fd, err := os.Open(filename)
checkErr(err)
utils.CheckErr("os.Open", err)
defer fd.Close()
switch ext := filepath.Ext(filename); ext {
@@ -153,41 +82,7 @@ func jsonYamlArg(cmd *cobra.Command, args []string) error {
}
}
func cleanUpInterfaceMap(in map[interface{}]interface{}) map[string]interface{} {
result := make(map[string]interface{})
for k, v := range in {
result[fmt.Sprintf("%v", k)] = cleanUpMapValue(v)
}
return result
}
func cleanUpInterfaceArray(in []interface{}) []interface{} {
result := make([]interface{}, len(in))
for i, v := range in {
result[i] = cleanUpMapValue(v)
}
return result
}
func cleanUpMapValue(v interface{}) interface{} {
switch v := v.(type) {
case []interface{}:
return cleanUpInterfaceArray(v)
case map[interface{}]interface{}:
return cleanUpInterfaceMap(v)
default:
return v
func cobraCmd(fn pythonFunc) cobraFunc {
return func(cmd *cobra.Command, args []string) {
}
}
// convertCmdStrToCmdArray checks if cmd string is blank (whitespace included)
// then returns empty string array, else returns the splitted word array of cmd.
// This is to ensure the result will never be []string{""}
func convertCmdStrToCmdArray(cmd string) []string {
var cmdArray []string
trimmedCmdStr := strings.TrimSpace(cmd)
if trimmedCmdStr != "" {
cmdArray = strings.Split(trimmedCmdStr, " ")
}
return cmdArray
}
-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 v" + version.Version + "/" + version.CommitSHA)
},
}
+21
View File
@@ -0,0 +1,21 @@
server:
port: 80
baseURL: "/"
root: "/srv"
auth:
method: password
signup: false
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
+19 -13
View File
@@ -10,13 +10,17 @@ import (
"os"
"path/filepath"
"sync"
"github.com/spf13/afero"
)
type FileCache struct {
fs afero.Fs
// Cache interface for caching operations
type Cache interface {
Get(key string) ([]byte, error)
Set(key string, value []byte) error
}
// FileCache struct for file-based caching
type FileCache struct {
dir string
// granular locks
scopedLocks struct {
sync.Mutex
@@ -25,10 +29,12 @@ type FileCache struct {
}
}
func New(fs afero.Fs, root string) *FileCache {
return &FileCache{
fs: afero.NewBasePathFs(fs, root),
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("can't make directory %s: %v", dir, err)
}
return &FileCache{dir: dir}, nil
}
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
@@ -37,11 +43,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
defer mu.Unlock()
fileName := f.getFileName(key)
if err := f.fs.MkdirAll(filepath.Dir(fileName), 0700); err != nil { //nolint:gomnd
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
return err
}
if err := afero.WriteFile(f.fs, fileName, value, 0700); err != nil { //nolint:gomnd
if err := os.WriteFile(fileName, value, 0600); err != nil {
return err
}
@@ -68,15 +74,15 @@ func (f *FileCache) Delete(ctx context.Context, key string) error {
defer mu.Unlock()
fileName := f.getFileName(key)
if err := f.fs.Remove(fileName); err != nil && !errors.Is(err, os.ErrNotExist) {
if err := os.Remove(fileName); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
func (f *FileCache) open(key string) (afero.File, bool, error) {
func (f *FileCache) open(key string) (*os.File, bool, error) {
fileName := f.getFileName(key)
file, err := f.fs.Open(fileName)
file, err := os.Open(fileName)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
@@ -106,5 +112,5 @@ func (f *FileCache) getFileName(key string) string {
hasher := sha1.New() //nolint:gosec
_, _ = hasher.Write([]byte(key))
hash := hex.EncodeToString(hasher.Sum(nil))
return fmt.Sprintf("%s/%s/%s", hash[:1], hash[1:3], hash)
return filepath.Join(f.dir, fmt.Sprintf("%s/%s/%s", hash[:1], hash[1:3], hash))
}
+23 -11
View File
@@ -2,10 +2,10 @@ package diskcache
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
)
@@ -15,35 +15,39 @@ func TestFileCache(t *testing.T) {
key = "key"
value = "some text"
newValue = "new text"
cacheRoot = "/cache"
cacheRoot = "cache"
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
)
fs := afero.NewMemMapFs()
cache := New(fs, "/cache")
// Create temporary directory for the cache
cacheDir, err := os.MkdirTemp("", cacheRoot)
require.NoError(t, err)
defer os.RemoveAll(cacheDir) // Clean up
cache, err := NewFileCache(cacheDir)
require.NoError(t, err)
// store new key
err := cache.Store(ctx, key, []byte(value))
err = cache.Store(ctx, key, []byte(value))
require.NoError(t, err)
checkValue(t, ctx, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, value)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
// update existing key
err = cache.Store(ctx, key, []byte(newValue))
require.NoError(t, err)
checkValue(t, ctx, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, newValue)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
// delete key
err = cache.Delete(ctx, key)
require.NoError(t, err)
exists, err := afero.Exists(fs, filepath.Join(cacheRoot, cachedFilePath))
require.NoError(t, err)
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
require.False(t, exists)
}
func checkValue(t *testing.T, ctx context.Context, fs afero.Fs, fileFullPath string, cache *FileCache, key, wantValue string) { //nolint:golint
func checkValue(t *testing.T, ctx context.Context, cache *FileCache, fileFullPath string, key, wantValue string) {
t.Helper()
// check actual file content
b, err := afero.ReadFile(fs, fileFullPath)
b, err := os.ReadFile(fileFullPath)
require.NoError(t, err)
require.Equal(t, wantValue, string(b))
@@ -53,3 +57,11 @@ func checkValue(t *testing.T, ctx context.Context, fs afero.Fs, fileFullPath str
require.True(t, ok)
require.Equal(t, wantValue, string(b))
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}
-20
View File
@@ -1,20 +0,0 @@
server:
port: 8080
baseURL: "/"
auth:
method: noauth
signup: true
userDefaults:
scope: "."
hideDotfiles: true
singleClick: false
permissions:
admin: false
create: true
rename: true
modify: true
delete: true
share: true
download: true
frontend:
theme: dark
+258
View File
@@ -0,0 +1,258 @@
package files
import (
"mime"
"regexp"
"strconv"
"strings"
)
var typeRegexp = regexp.MustCompile(`type:(\S+)`)
var AllFiletypeOptions = []string{
"image",
"audio",
"archive",
"video",
"doc",
"text",
}
// Document file extensions
var documentTypes = []string{
// 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
}
// 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",
".zip",
".tar",
".gz",
".xz",
".bz2",
".tgz", // tar.gz
".tbz2", // tar.bz2
".lzma",
".lz4",
".zstd",
}
type SearchOptions struct {
Conditions map[string]bool
LargerThan int
SmallerThan int
Terms []string
}
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"),
},
Terms: []string{},
}
// removes the options from the value
value = strings.Replace(value, "case:exact", "", -1)
value = strings.TrimSpace(value)
types := typeRegexp.FindAllStringSubmatch(value, -1)
for _, filterType := range types {
if len(filterType) == 1 {
continue
}
filter := filterType[1]
switch filter {
case "image":
opts.Conditions["image"] = true
case "audio", "music":
opts.Conditions["audio"] = true
case "video":
opts.Conditions["video"] = true
case "doc":
opts.Conditions["doc"] = true
case "archive":
opts.Conditions["archive"] = true
case "folder":
opts.Conditions["dir"] = true
case "file":
opts.Conditions["dir"] = false
}
if len(filter) < 8 {
continue
}
if strings.HasPrefix(filter, "largerThan=") {
opts.Conditions["larger"] = true
size := strings.TrimPrefix(filter, "largerThan=")
opts.LargerThan = updateSize(size)
}
if strings.HasPrefix(filter, "smallerThan=") {
opts.Conditions["smaller"] = true
size := strings.TrimPrefix(filter, "smallerThan=")
opts.SmallerThan = updateSize(size)
}
}
if len(types) > 0 {
// Remove the fields from the search value
value = typeRegexp.ReplaceAllString(value, "")
}
if value == "" {
return opts
}
// if the value starts with " and finishes what that character, we will
// only search for that term
if value[0] == '"' && value[len(value)-1] == '"' {
unique := strings.TrimPrefix(value, "\"")
unique = strings.TrimSuffix(unique, "\"")
opts.Terms = []string{unique}
return opts
}
value = strings.TrimSpace(value)
opts.Terms = strings.Split(value, "|")
return opts
}
func toInt(str string) int {
val, err := strconv.Atoi(str)
if err != nil {
return 0
}
return val
}
func updateSize(given string) int {
size := toInt(given)
if size == 0 {
return 100
} else {
return size
}
}
func IsMatchingType(extension string, matchType string) bool {
mimetype := mime.TypeByExtension(extension)
if strings.HasPrefix(mimetype, matchType) {
return true
}
switch matchType {
case "doc":
return isDoc(extension)
case "text":
return isText(extension)
case "archive":
return isArchive(extension)
}
return false
}
func isText(extension string) bool {
for _, typefile := range textTypes {
if extension == typefile {
return true
}
}
return false
}
func isDoc(extension string) bool {
for _, typefile := range documentTypes {
if extension == typefile {
return true
}
}
return false
}
func isArchive(extension string) bool {
for _, typefile := range compressedFile {
if extension == 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))
}
}
+378 -286
View File
@@ -1,371 +1,463 @@
package files
import (
"crypto/md5" //nolint:gosec
"crypto/sha1" //nolint:gosec
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
"log"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/spf13/afero"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/rules"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
var (
pathMutexes = make(map[string]*sync.Mutex)
pathMutexesMu sync.Mutex // Mutex to protect the pathMutexes map
)
type ItemInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime time.Time `json:"modified"`
Type string `json:"type"`
}
// FileInfo describes a file.
// reduced item is non-recursive reduced "Items", used to pass flat items array
type FileInfo struct {
*Listing
Fs afero.Fs `json:"-"`
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
Extension string `json:"extension"`
ModTime time.Time `json:"modified"`
Mode os.FileMode `json:"mode"`
IsDir bool `json:"isDir"`
IsSymlink bool `json:"isSymlink"`
Type string `json:"type"`
Subtitles []string `json:"subtitles,omitempty"`
ItemInfo
Files []ItemInfo `json:"files"`
Folders []ItemInfo `json:"folders"`
Path string `json:"path"`
}
// 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"`
Subtitles []string `json:"subtitles,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
Token string `json:"token,omitempty"`
}
// FileOptions are the options when getting a file info.
type FileOptions struct {
Fs afero.Fs
Path string
Path string // realpath
IsDir bool
Modify bool
Expand bool
ReadHeader bool
Token string
Checker rules.Checker
Checker users.Checker
Content bool
}
// 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
}
file, err := stat(opts)
if err != nil {
return nil, err
}
if opts.Expand {
if file.IsDir {
if err := file.readListing(opts.Checker, opts.ReadHeader); err != nil { //nolint:govet
return nil, err
}
return file, nil
}
err = file.detectType(opts.Modify, opts.Content, true)
if err != nil {
return nil, err
}
}
return file, err
func (f FileOptions) Components() (string, string) {
return filepath.Dir(f.Path), filepath.Base(f.Path)
}
func stat(opts FileOptions) (*FileInfo, error) {
var file *FileInfo
if lstaterFs, ok := opts.Fs.(afero.Lstater); ok {
info, _, err := lstaterFs.LstatIfPossible(opts.Path)
if err != nil {
return nil, err
}
file = &FileInfo{
Fs: opts.Fs,
Path: opts.Path,
Name: info.Name(),
ModTime: info.ModTime(),
Mode: info.Mode(),
IsDir: info.IsDir(),
IsSymlink: IsSymlink(info.Mode()),
Size: info.Size(),
Extension: filepath.Ext(info.Name()),
Token: opts.Token,
}
func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
index := GetIndex(rootPath)
opts.Path = index.makeIndexPath(opts.Path)
response := ExtendedFileInfo{}
// Lock access for the specific path
pathMutex := getMutex(opts.Path)
pathMutex.Lock()
defer pathMutex.Unlock()
if !opts.Checker.Check(opts.Path) {
return response, os.ErrPermission
}
// regular file
if file != nil && !file.IsSymlink {
return file, nil
}
// fs doesn't support afero.Lstater interface or the file is a symlink
info, err := opts.Fs.Stat(opts.Path)
_, isDir, err := GetRealPath(opts.Path)
if err != nil {
// can't follow symlink
if file != nil && file.IsSymlink {
return file, nil
return response, err
}
opts.IsDir = isDir
// TODO : whats the best way to save trips to disk here?
// disabled using cache because its not clear if this is helping or hurting
// check if the file exists in the index
//info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
//if exists {
// err := RefreshFileInfo(opts)
// if err != nil {
// return info, err
// }
// if opts.Content {
// content := ""
// content, err = getContent(opts.Path)
// if err != nil {
// return info, err
// }
// info.Content = content
// }
// return info, nil
//}
err = index.RefreshFileInfo(opts)
if err != nil {
return response, err
}
info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, err
}
if opts.Content {
content, err := getContent(opts.Path)
if err != nil {
return response, err
}
return nil, err
response.Content = content
}
// set correct file size in case of symlink
if file != nil && file.IsSymlink {
file.Size = info.Size()
file.IsDir = info.IsDir()
return file, nil
}
file = &FileInfo{
Fs: opts.Fs,
Path: opts.Path,
Name: info.Name(),
ModTime: info.ModTime(),
Mode: info.Mode(),
IsDir: info.IsDir(),
Size: info.Size(),
Extension: filepath.Ext(info.Name()),
Token: opts.Token,
}
return file, nil
response.FileInfo = info
return response, nil
}
// 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 := i.Fs.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()
var h hash.Hash
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
//nolint:gosec
switch algo {
case "md5":
h = md5.New()
case "sha1":
h = sha1.New()
case "sha256":
h = sha256.New()
case "sha512":
h = sha512.New()
default:
return errors.ErrInvalidOption
h, ok := hashFuncs[algo]
if !ok {
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 {
if realPathFs, ok := i.Fs.(interface {
RealPath(name string) (fPath string, err error)
}); ok {
realPath, err := realPathFs.RealPath(i.Path)
if err == nil {
return realPath
}
realPath, _, _ := GetRealPath(rootPath, i.Path)
realPath, err := filepath.EvalSymlinks(realPath)
if err == nil {
return realPath
}
return i.Path
}
// TODO: use constants
//
//nolint:goconst
func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error {
if IsNamedPipe(i.Mode) {
i.Type = "blob"
return nil
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))
}
// failing to detect the type should not return error.
// imagine the situation where a file in a dir with thousands
// of files couldn't be opened: we'd have immediately
// a 500 even though it doesn't matter. So we just log it.
joinedPath := filepath.Join(combined...)
mimetype := mime.TypeByExtension(i.Extension)
var buffer []byte
if readHeader {
buffer = i.readFirstBytes()
if mimetype == "" {
mimetype = http.DetectContentType(buffer)
}
isDir, _ := utils.RealPathCache.Get(joinedPath + ":isdir").(bool)
cached, ok := utils.RealPathCache.Get(joinedPath).(string)
if ok && cached != "" {
return cached, isDir, nil
}
switch {
case strings.HasPrefix(mimetype, "video"):
i.Type = "video"
i.detectSubtitles()
return nil
case strings.HasPrefix(mimetype, "audio"):
i.Type = "audio"
return nil
case strings.HasPrefix(mimetype, "image"):
i.Type = "image"
return nil
case strings.HasSuffix(mimetype, "pdf"):
i.Type = "pdf"
return nil
case (strings.HasPrefix(mimetype, "text") || !isBinary(buffer)) && i.Size <= 10*1024*1024: // 10 MB
i.Type = "text"
if !modify {
i.Type = "textImmutable"
}
if saveContent {
afs := &afero.Afero{Fs: i.Fs}
content, err := afs.ReadFile(i.Path)
if err != nil {
return err
}
i.Content = string(content)
}
return nil
default:
i.Type = "blob"
// 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", combined, err)
}
// Resolve symlinks and get the real path
realPath, isDir, err := resolveSymlinks(absolutePath)
if err == nil {
utils.RealPathCache.Set(joinedPath, realPath)
utils.RealPathCache.Set(joinedPath+":isdir", isDir)
}
return realPath, isDir, err
}
func DeleteFiles(absPath string, opts FileOptions) error {
err := os.RemoveAll(absPath)
if err != nil {
return err
}
index := GetIndex(rootPath)
refreshConfig := FileOptions{Path: filepath.Dir(opts.Path), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return err
}
return nil
}
func (i *FileInfo) readFirstBytes() []byte {
reader, err := i.Fs.Open(i.Path)
func MoveResource(realsrc, realdst string, isSrcDir bool) error {
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
log.Print(err)
i.Type = "blob"
return nil
return err
}
defer reader.Close()
buffer := make([]byte, 512) //nolint:gomnd
n, err := reader.Read(buffer)
if err != nil && err != io.EOF {
log.Print(err)
i.Type = "blob"
return nil
index := GetIndex(rootPath)
// refresh info for source and dest
err = index.RefreshFileInfo(FileOptions{
Path: realsrc,
IsDir: isSrcDir,
})
if err != nil {
return errors.ErrEmptyKey
}
return buffer[:n]
refreshConfig := FileOptions{Path: realdst, IsDir: true}
if !isSrcDir {
refreshConfig.Path = filepath.Dir(realdst)
}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func (i *FileInfo) detectSubtitles() {
if i.Type != "video" {
return
func CopyResource(realsrc, realdst string, isSrcDir bool) error {
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
i.Subtitles = []string{}
ext := filepath.Ext(i.Path)
// detect multiple languages. Base*.vtt
// TODO: give subtitles descriptive names (lang) and track attributes
parentDir := strings.TrimRight(i.Path, i.Name)
dir, err := afero.ReadDir(i.Fs, parentDir)
if err == nil {
base := strings.TrimSuffix(i.Name, ext)
for _, f := range dir {
if !f.IsDir() && strings.HasPrefix(f.Name(), base) && strings.HasSuffix(f.Name(), ".vtt") {
i.Subtitles = append(i.Subtitles, path.Join(parentDir, f.Name()))
}
}
index := GetIndex(rootPath)
refreshConfig := FileOptions{Path: realdst, IsDir: true}
if !isSrcDir {
refreshConfig.Path = filepath.Dir(realdst)
}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error {
afs := &afero.Afero{Fs: i.Fs}
dir, err := afs.ReadDir(i.Path)
func WriteDirectory(opts FileOptions) error {
realPath, _, _ := GetRealPath(rootPath, opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(realPath, 0775)
if err != nil {
return err
}
index := GetIndex(rootPath)
err = index.RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func WriteFile(opts FileOptions, in io.Reader) error {
dst, _, _ := GetRealPath(rootPath, opts.Path)
parentDir := filepath.Dir(dst)
// Create the directory and all necessary parents
err := os.MkdirAll(parentDir, 0775)
if err != nil {
return err
}
listing := &Listing{
Items: []*FileInfo{},
NumDirs: 0,
NumFiles: 0,
// Open the file for writing (create if it doesn't exist, truncate if it does)
file, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
if err != nil {
return err
}
defer file.Close()
for _, f := range dir {
name := f.Name()
fPath := path.Join(i.Path, name)
if !checker.Check(fPath) {
continue
}
isSymlink, isInvalidLink := false, false
if IsSymlink(f.Mode()) {
isSymlink = true
// It's a symbolic link. We try to follow it. If it doesn't work,
// we stay with the link information instead of the target's.
info, err := i.Fs.Stat(fPath)
if err == nil {
f = info
} else {
isInvalidLink = true
}
}
file := &FileInfo{
Fs: i.Fs,
Name: name,
Size: f.Size(),
ModTime: f.ModTime(),
Mode: f.Mode(),
IsDir: f.IsDir(),
IsSymlink: isSymlink,
Extension: filepath.Ext(name),
Path: fPath,
}
if file.IsDir {
listing.NumDirs++
} else {
listing.NumFiles++
if isInvalidLink {
file.Type = "invalid_link"
} else {
err := file.detectType(true, false, readHeader)
if err != nil {
return err
}
}
}
listing.Items = append(listing.Items, file)
// Copy the contents from the reader to the file
_, err = io.Copy(file, in)
if err != nil {
return err
}
i.Listing = listing
return nil
opts.Path = parentDir
opts.IsDir = true
index := GetIndex(rootPath)
return index.RefreshFileInfo(opts)
}
// resolveSymlinks resolves symlinks in the given path
func resolveSymlinks(path string) (string, bool, error) {
for {
// Get the file info using os.Lstat to handle symlinks
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
// Check if the path is a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the symlink's target relative to its directory
// This ensures the resolved path is absolute and correctly calculated
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, so return the resolved path and whether it's a directory
isDir := info.IsDir()
return path, isDir, nil
}
}
}
// addContent reads and sets content based on the file type.
func getContent(path string) (string, error) {
realPath, _, err := GetRealPath(rootPath, path)
if err != nil {
return "", err
}
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
stringContent := string(content)
if !utf8.ValidString(stringContent) {
return "", nil
}
if stringContent == "" {
return "empty-file-x6OlSil", nil
}
return stringContent, nil
}
// DetectType detects the MIME type of a file and updates the ItemInfo struct.
func (i *ItemInfo) DetectType(path string, saveContent bool) {
name := i.Name
ext := filepath.Ext(name)
// Attempt MIME detection by file extension
i.Type = strings.Split(mime.TypeByExtension(ext), ";")[0]
if i.Type == "" {
i.Type = extendedMimeTypeCheck(ext)
}
if i.Type == "blob" {
realpath, _, _ := GetRealPath(path)
// Read only the first 512 bytes for efficient MIME detection
file, err := os.Open(realpath)
if err != nil {
} else {
defer file.Close()
buffer := make([]byte, 512)
n, _ := file.Read(buffer) // Ignore errors from Read
i.Type = strings.Split(http.DetectContentType(buffer[:n]), ";")[0]
}
}
}
// TODO add subtitles back
// detectSubtitles detects subtitles for video files.
//func (i *FileInfo) detectSubtitles(path string) {
// if i.Type != "video" {
// return
// }
// parentDir := filepath.Dir(path)
// fileName := filepath.Base(path)
// i.Subtitles = []string{}
// ext := filepath.Ext(fileName)
// 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(fileName, 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
// }
// }
// }
//}
func IsNamedPipe(mode os.FileMode) bool {
return mode&os.ModeNamedPipe != 0
}
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 {
return true
}
if os.IsNotExist(err) {
return false
}
return false
}
func (info *FileInfo) SortItems() {
sort.Slice(info.Folders, func(i, j int) bool {
// Convert strings to integers for numeric sorting if both are numeric
numI, errI := strconv.Atoi(info.Folders[i].Name)
numJ, errJ := strconv.Atoi(info.Folders[j].Name)
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 {
// Convert strings to integers for numeric sorting if both are numeric
numI, errI := strconv.Atoi(info.Files[i].Name)
numJ, errJ := strconv.Atoi(info.Files[j].Name)
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)
})
}
+169
View File
@@ -0,0 +1,169 @@
package files
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func Test_GetRealPath(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
return
}
trimPrefix := filepath.Dir(filepath.Dir(cwd)) + "/"
tests := []struct {
name string
paths []string
want struct {
path string
isDir bool
}
}{
{
name: "current directory",
paths: []string{
"./",
},
want: struct {
path string
isDir bool
}{
path: "backend/files",
isDir: true,
},
},
{
name: "current directory",
paths: []string{
"./file.go",
},
want: struct {
path string
isDir bool
}{
path: "backend/files/file.go",
isDir: false,
},
},
{
name: "other test case",
paths: []string{
"/mnt/doesnt/exist",
},
want: struct {
path string
isDir bool
}{
path: "/mnt/doesnt/exist",
isDir: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
realPath, isDir, _ := GetRealPath(tt.paths...)
adjustedRealPath := strings.TrimPrefix(realPath, trimPrefix)
if tt.want.path != adjustedRealPath || tt.want.isDir != isDir {
t.Errorf("expected %v:%v but got: %v:%v", tt.want.path, tt.want.isDir, adjustedRealPath, isDir)
}
})
}
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
input FileInfo
expected FileInfo
}{
{
name: "Numeric and Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
{Name: "10"},
{Name: "2"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
{Name: "File2"},
{Name: "File10"},
{Name: "File1"},
{Name: "banana"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
{Name: "2"},
{Name: "10"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
{Name: "banana"},
{Name: "File1"},
{Name: "File10"},
{Name: "File2"},
},
},
},
{
name: "Only Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
{Name: "dog"},
{Name: "Cat"},
{Name: "apple"},
},
Files: []ItemInfo{
{Name: "Zebra"},
{Name: "apple"},
{Name: "cat"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
{Name: "apple"},
{Name: "Cat"},
{Name: "dog"},
},
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)
}
})
}
}
+229
View File
@@ -0,0 +1,229 @@
package files
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
type Index struct {
Root string
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 (
rootPath string = "/srv"
indexes []*Index
indexesMutex sync.RWMutex
)
func InitializeIndex(enabled bool) {
if enabled {
time.Sleep(time.Second)
if settings.Config.Server.Root != "" {
rootPath = settings.Config.Server.Root
}
si := GetIndex(rootPath)
log.Println("Initializing index and assessing file system complexity")
si.RunIndexing("/", false)
go si.setupIndexingScanners()
}
}
// Define a function to recursively index files and directories
func (si *Index) indexDirectory(adjustedPath string, quick, recursive bool) error {
realPath := strings.TrimRight(si.Root, "/") + adjustedPath
// Open the directory
dir, err := os.Open(realPath)
if err != nil {
si.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
si.mu.RLock()
cacheDirItems := []ItemInfo{}
modChange := true // default to true
cachedDir, exists := si.Directories[adjustedPath]
if exists && quick {
modChange = dirInfo.ModTime() != cachedDir.ModTime
cacheDirItems = cachedDir.Folders
}
si.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 = si.indexDirectory(combinedPath+item.Name, quick, true)
if err != nil {
fmt.Printf("error indexing directory %v : %v", combinedPath+item.Name, err)
}
}
return nil
}
if quick {
si.mu.Lock()
si.FilesChangedDuringIndexing = true
si.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 {
itemInfo := &ItemInfo{
Name: file.Name(),
ModTime: file.ModTime(),
}
if file.IsDir() {
dirPath := combinedPath + file.Name()
if recursive {
// Recursively index the subdirectory
err = si.indexDirectory(dirPath, quick, recursive)
if err != nil {
log.Printf("Failed to index directory %s: %v", dirPath, err)
continue
}
}
realDirInfo, exists := si.GetMetadataInfo(dirPath, true)
if exists {
itemInfo.Size = realDirInfo.Size
}
totalSize += itemInfo.Size
itemInfo.Type = "directory"
dirInfos = append(dirInfos, *itemInfo)
si.NumDirs++
} else {
itemInfo.DetectType(combinedPath+file.Name(), false)
itemInfo.Size = file.Size()
fileInfos = append(fileInfos, *itemInfo)
totalSize += itemInfo.Size
si.NumFiles++
}
}
// 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
si.UpdateMetadata(dirFileInfo)
return nil
}
func (si *Index) makeIndexPath(subPath string) string {
if strings.HasPrefix(subPath, "./") {
subPath = strings.TrimPrefix(subPath, ".")
}
if si.Root == subPath || subPath == "." {
return "/"
}
// clean path
subPath = strings.TrimSuffix(subPath, "/")
// remove index prefix
adjustedPath := strings.TrimPrefix(subPath, si.Root)
// remove trailing slash
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
if !strings.HasPrefix(adjustedPath, "/") {
adjustedPath = "/" + adjustedPath
}
return adjustedPath
}
func (si *Index) recursiveUpdateDirSizes(childInfo *FileInfo, previousSize int64) {
parentDir := utils.GetParentDirectoryPath(childInfo.Path)
parentInfo, exists := si.GetMetadataInfo(parentDir, true)
if !exists || parentDir == "" {
return
}
newSize := parentInfo.Size - previousSize + childInfo.Size
parentInfo.Size += newSize
si.UpdateMetadata(parentInfo)
si.recursiveUpdateDirSizes(parentInfo, newSize)
}
func (si *Index) RefreshFileInfo(opts FileOptions) error {
refreshOptions := FileOptions{
Path: opts.Path,
IsDir: opts.IsDir,
}
if !refreshOptions.IsDir {
refreshOptions.Path = si.makeIndexPath(filepath.Dir(refreshOptions.Path))
refreshOptions.IsDir = true
} else {
refreshOptions.Path = si.makeIndexPath(refreshOptions.Path)
}
err := si.indexDirectory(refreshOptions.Path, false, false)
if err != nil {
return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path)
}
file, exists := si.GetMetadataInfo(refreshOptions.Path, true)
if !exists {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
}
current, firstExisted := si.GetMetadataInfo(refreshOptions.Path, true)
refreshParentInfo := firstExisted && current.Size != file.Size
//utils.PrintStructFields(*file)
result := si.UpdateMetadata(file)
if !result {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
}
if !exists {
return nil
}
if refreshParentInfo {
si.recursiveUpdateDirSizes(file, current.Size)
}
return nil
}
+117
View File
@@ -0,0 +1,117 @@
package files
import (
"log"
"time"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// 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, // [3] element is 20 minutes, reset anchor for full scan
40 * time.Minute,
1 * time.Hour,
2 * time.Hour,
3 * time.Hour,
4 * time.Hour, // 4 hours for quick scan & 20 hours for a full scan
}
func (si *Index) newScanner(origin string) {
fullScanAnchor := 3
fullScanCounter := 0 // every 5th scan is a full scan
for {
// Determine sleep time with modifiers
fullScanCounter++
sleepTime := scanSchedule[si.currentSchedule] + si.SmartModifier
if si.assessment == "simple" {
sleepTime = scanSchedule[si.currentSchedule] - si.SmartModifier
}
if settings.Config.Server.IndexingInterval > 0 {
sleepTime = time.Duration(settings.Config.Server.IndexingInterval) * time.Minute
}
// Log and sleep before indexing
log.Printf("Next scan in %v\n", sleepTime)
time.Sleep(sleepTime)
si.scannerMu.Lock()
if fullScanCounter == 5 {
si.RunIndexing(origin, false) // Full scan
fullScanCounter = 0
} else {
si.RunIndexing(origin, true) // Quick scan
}
si.scannerMu.Unlock()
// Adjust schedule based on file changes
if si.FilesChangedDuringIndexing {
// Move to at least the full-scan anchor or reduce interval
if si.currentSchedule > fullScanAnchor {
si.currentSchedule = fullScanAnchor
} else if si.currentSchedule > 0 {
si.currentSchedule--
}
} else {
// Increment toward the longest interval if no changes
if si.currentSchedule < len(scanSchedule)-1 {
si.currentSchedule++
}
}
if si.assessment == "simple" && si.currentSchedule > 3 {
si.currentSchedule = 3
}
// Ensure `currentSchedule` stays within bounds
if si.currentSchedule < 0 {
si.currentSchedule = 0
} else if si.currentSchedule >= len(scanSchedule) {
si.currentSchedule = len(scanSchedule) - 1
}
}
}
func (si *Index) RunIndexing(origin string, quick bool) {
prevNumDirs := si.NumDirs
prevNumFiles := si.NumFiles
if quick {
log.Println("Starting quick scan")
} else {
log.Println("Starting full scan")
si.NumDirs = 0
si.NumFiles = 0
}
startTime := time.Now()
si.FilesChangedDuringIndexing = false
// Perform the indexing operation
err := si.indexDirectory("/", quick, true)
if err != nil {
log.Printf("Error during indexing: %v", err)
}
// Update the LastIndexed time
si.LastIndexed = time.Now()
si.indexingTime = int(time.Since(startTime).Seconds())
if !quick {
// update smart indexing
if si.indexingTime < 3 || si.NumDirs < 10000 {
si.assessment = "simple"
si.SmartModifier = 4 * time.Minute
} else if si.indexingTime > 120 || si.NumDirs > 500000 {
si.assessment = "complex"
modifier := si.indexingTime / 10 // seconds
si.SmartModifier = time.Duration(modifier) * time.Minute
} else {
si.assessment = "normal"
}
log.Printf("Index assessment : complexity=%v directories=%v files=%v \n", si.assessment, si.NumDirs, si.NumFiles)
if si.NumDirs != prevNumDirs || si.NumFiles != prevNumFiles {
si.FilesChangedDuringIndexing = true
}
}
log.Printf("Time Spent Indexing : %v seconds\n", si.indexingTime)
}
func (si *Index) setupIndexingScanners() {
go si.newScanner("/")
}
+101
View File
@@ -0,0 +1,101 @@
package files
import (
"encoding/json"
"math/rand"
"reflect"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
func BenchmarkFillIndex(b *testing.B) {
InitializeIndex(false)
si := GetIndex(settings.Config.Server.Root)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
si.createMockData(50, 3) // 1000 dirs, 3 files per dir
}
}
func (si *Index) createMockData(numDirs, numFilesPerDir int) {
for i := 0; i < numDirs; i++ {
dirPath := generateRandomPath(rand.Intn(3) + 1)
files := []ItemInfo{} // Slice of FileInfo
// 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",
}
files = append(files, newFile)
}
dirInfo := &FileInfo{
Path: dirPath,
Files: files,
}
si.UpdateMetadata(dirInfo)
}
}
// JSONBytesEqual compares the JSON in two byte slices.
func JSONBytesEqual(a, b []byte) (bool, error) {
var j, j2 interface{}
if err := json.Unmarshal(a, &j); err != nil {
return false, err
}
if err := json.Unmarshal(b, &j2); err != nil {
return false, err
}
return reflect.DeepEqual(j2, j), nil
}
func TestGetIndex(t *testing.T) {
tests := []struct {
name string
want *map[string][]string
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetIndex("root"); !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetIndex() = %v, want %v", got, tt.want)
}
})
}
}
func TestMakeIndexPath(t *testing.T) {
tests := []struct {
name string
subPath string
expected string
}{
{"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"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
si := &Index{Root: "/"}
result := si.makeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("makeIndexPath(%q) = %q; want %q", tt.name, result, tt.expected)
}
})
}
}
-111
View File
@@ -1,111 +0,0 @@
package files
import (
"sort"
"strings"
"github.com/maruel/natural"
)
// Listing is a collection of files.
type Listing struct {
Items []*FileInfo `json:"items"`
NumDirs int `json:"numDirs"`
NumFiles int `json:"numFiles"`
Sorting Sorting `json:"sorting"`
}
// ApplySort applies the sort order using .Order and .Sort
//
//nolint:goconst
func (l Listing) ApplySort() {
// Check '.Order' to know how to sort
// TODO: use enum
if !l.Sorting.Asc {
switch l.Sorting.By {
case "name":
sort.Sort(sort.Reverse(byName(l)))
case "size":
sort.Sort(sort.Reverse(bySize(l)))
case "modified":
sort.Sort(sort.Reverse(byModified(l)))
default:
// If not one of the above, do nothing
return
}
} else { // If we had more Orderings we could add them here
switch l.Sorting.By {
case "name":
sort.Sort(byName(l))
case "size":
sort.Sort(bySize(l))
case "modified":
sort.Sort(byModified(l))
default:
sort.Sort(byName(l))
return
}
}
}
// Implement sorting for Listing
type byName Listing
type bySize Listing
type byModified Listing
// By Name
func (l byName) Len() int {
return len(l.Items)
}
func (l byName) Swap(i, j int) {
l.Items[i], l.Items[j] = l.Items[j], l.Items[i]
}
// Treat upper and lower case equally
func (l byName) Less(i, j int) bool {
if l.Items[i].IsDir && !l.Items[j].IsDir {
return l.Sorting.Asc
}
if !l.Items[i].IsDir && l.Items[j].IsDir {
return !l.Sorting.Asc
}
return natural.Less(strings.ToLower(l.Items[j].Name), strings.ToLower(l.Items[i].Name))
}
// By Size
func (l bySize) Len() int {
return len(l.Items)
}
func (l bySize) Swap(i, j int) {
l.Items[i], l.Items[j] = l.Items[j], l.Items[i]
}
const directoryOffset = -1 << 31 // = math.MinInt32
func (l bySize) Less(i, j int) bool {
iSize, jSize := l.Items[i].Size, l.Items[j].Size
if l.Items[i].IsDir {
iSize = directoryOffset + iSize
}
if l.Items[j].IsDir {
jSize = directoryOffset + jSize
}
return iSize < jSize
}
// By Modified
func (l byModified) Len() int {
return len(l.Items)
}
func (l byModified) Swap(i, j int) {
l.Items[i], l.Items[j] = l.Items[j], l.Items[i]
}
func (l byModified) Less(i, j int) bool {
iModified, jModified := l.Items[i].ModTime, l.Items[j].ModTime
return iModified.Sub(jModified) < 0
}
+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
}
+182
View File
@@ -0,0 +1,182 @@
package files
import (
"path/filepath"
"sort"
"strings"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
var (
sessionInProgress sync.Map
maxSearchResults = 100
)
type SearchResult struct {
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
}
func (si *Index) Search(search string, scope string, sourceSession string) []SearchResult {
// Remove slashes
scope = si.makeIndexPath(scope)
runningHash := utils.GenerateRandomHash(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := ParseSearch(search)
results := make(map[string]SearchResult, 0)
count := 0
var directories []string
cachedDirs, ok := utils.SearchResultsCache.Get(si.Root + scope).([]string)
if ok {
directories = cachedDirs
} else {
directories = si.getDirsInScope(scope)
utils.SearchResultsCache.Set(si.Root+scope, directories)
}
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" {
continue
}
if count > maxSearchResults {
break
}
si.mu.Lock()
for _, dirName := range directories {
scopedPath := strings.TrimPrefix(strings.TrimPrefix(dirName, scope), "/") + "/"
si.mu.Unlock()
dir, found := si.GetReducedMetadata(dirName, true)
si.mu.Lock()
if !found {
continue
}
if count > maxSearchResults {
break
}
reducedDir := ItemInfo{
Name: filepath.Base(dirName),
Type: "directory",
Size: dir.Size,
}
matches := reducedDir.containsSearchTerm(searchTerm, searchOptions)
if matches {
results[scopedPath] = SearchResult{Path: scopedPath, Type: "directory", Size: dir.Size}
count++
}
// search files first
for _, item := range dir.Files {
fullPath := dirName + "/" + item.Name
scopedPath := strings.TrimPrefix(strings.TrimPrefix(fullPath, scope), "/")
if item.Type == "directory" {
scopedPath += "/"
}
value, found := sessionInProgress.Load(sourceSession)
if !found || value != runningHash {
si.mu.Unlock()
return []SearchResult{}
}
if count > maxSearchResults {
break
}
matches := item.containsSearchTerm(searchTerm, searchOptions)
if matches {
results[scopedPath] = SearchResult{Path: scopedPath, Type: item.Type, Size: item.Size}
count++
}
}
}
si.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(sortedKeys, func(i, j int) bool {
parts1 := strings.Split(sortedKeys[i].Path, "/")
parts2 := strings.Split(sortedKeys[j].Path, "/")
return len(parts1) < len(parts2)
})
return sortedKeys
}
// 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 {
fileTypes := map[string]bool{}
largerThan := int64(options.LargerThan) * 1024 * 1024
smallerThan := int64(options.SmallerThan) * 1024 * 1024
conditions := options.Conditions
lowerFileName := strings.ToLower(fi.Name)
// Convert to lowercase if not exact match
if !conditions["exact"] {
searchTerm = strings.ToLower(searchTerm)
}
// 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
}
}
}
return true
}
func (si *Index) getDirsInScope(scope string) []string {
newList := []string{}
si.mu.Lock()
defer si.mu.Unlock()
for k := range si.Directories {
if strings.HasPrefix(k, scope) || scope == "" {
newList = append(newList, k)
}
}
return newList
}
+224
View File
@@ -0,0 +1,224 @@
package files
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
func BenchmarkSearchAllIndexes(b *testing.B) {
InitializeIndex(false)
si := GetIndex(rootPath)
si.createMockData(50, 3) // 50 dirs, 3 files per dir
// Generate 100 random search terms
searchTerms := generateRandomSearchTerms(100)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Execute the SearchAllIndexes function
for _, term := range searchTerms {
si.Search(term, "/", "test")
}
}
}
func TestParseSearch(t *testing.T) {
tests := []struct {
input string
want SearchOptions
}{
{
input: "my test search",
want: SearchOptions{
Conditions: map[string]bool{"exact": false},
Terms: []string{"my test search"},
},
},
{
input: "case:exact my|test|search",
want: SearchOptions{
Conditions: map[string]bool{"exact": true},
Terms: []string{"my", "test", "search"},
},
},
{
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,
},
},
{
input: "type:audio thisfile",
want: SearchOptions{
Conditions: map[string]bool{"exact": false, "audio": true},
Terms: []string{"thisfile"},
},
},
}
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(false)
si := GetIndex(rootPath)
searchTerms := generateRandomSearchTerms(10)
for i := 0; i < 5; i++ {
go si.createMockData(100, 100) // Creating mock data concurrently
for _, term := range searchTerms {
go si.Search(term, "/", "test") // Search concurrently
}
}
}
func TestSearchIndexes(t *testing.T) {
index := Index{
Directories: map[string]*FileInfo{
"/test": {Files: []ItemInfo{{Name: "audio1.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"},
}},
"/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},
},
},
"/firstDir/thisIsDir": {
Files: []ItemInfo{
{Name: "hi.txt", Type: "text"},
},
ItemInfo: ItemInfo{
Size: 2 * 1024 * 1024,
},
},
},
}
tests := []struct {
search string
scope string
expectedResult []SearchResult
}{
{
search: "audio",
scope: "/new/",
expectedResult: []SearchResult{
{
Path: "test/audio.wav",
Type: "audio",
Size: 0,
},
},
},
{
search: "test",
scope: "/",
expectedResult: []SearchResult{
{
Path: "test/",
Type: "directory",
Size: 0,
},
{
Path: "new/test/",
Type: "directory",
Size: 0,
},
},
},
{
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: "archive.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: []SearchResult{
{
Path: "new/test/video.MP4",
Type: "video",
Size: 0,
},
{
Path: "new/test/video.mp4",
Type: "video",
Size: 0,
},
},
},
}
for _, tt := range tests {
t.Run(tt.search, func(t *testing.T) {
result := index.Search(tt.search, tt.scope, "")
assert.ElementsMatch(t, tt.expectedResult, result)
})
}
}
-7
View File
@@ -1,7 +0,0 @@
package files
// Sorting contains a sorting order.
type Sorting struct {
By string `json:"by"`
Asc bool `json:"asc"`
}
+89
View File
@@ -0,0 +1,89 @@
package files
import (
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
func (si *Index) UpdateMetadata(info *FileInfo) bool {
si.mu.Lock()
defer si.mu.Unlock()
si.Directories[info.Path] = info
return true
}
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
func (si *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
si.mu.Lock()
defer si.mu.Unlock()
checkDir := si.makeIndexPath(target)
if !isDir {
checkDir = si.makeIndexPath(filepath.Dir(target))
}
dir, exists := si.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 nil, false
}
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
func (si *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
si.mu.RLock()
defer si.mu.RUnlock()
checkDir := si.makeIndexPath(target)
if !isDir {
checkDir = si.makeIndexPath(filepath.Dir(target))
}
dir, exists := si.Directories[checkDir]
return dir, exists
}
func (si *Index) RemoveDirectory(path string) {
si.mu.Lock()
defer si.mu.Unlock()
si.NumDeleted++
delete(si.Directories, path)
}
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: map[string]*FileInfo{},
NumDirs: 0,
NumFiles: 0,
}
newIndex.Directories["/"] = &FileInfo{}
indexesMutex.Lock()
indexes = append(indexes, newIndex)
indexesMutex.Unlock()
return newIndex
}
+240
View File
@@ -0,0 +1,240 @@
package files
import (
"testing"
"github.com/stretchr/testify/assert"
)
var testIndex Index
// 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) {
info := &FileInfo{
Path: "/testpath",
Files: []ItemInfo{
{Name: "testfile.txt"},
{Name: "anotherfile.txt"},
},
}
index := &Index{
Directories: map[string]*FileInfo{
"/testpath": info,
},
}
success := index.UpdateMetadata(info)
if !success {
t.Fatalf("expected UpdateFileMetadata to succeed")
}
fileInfo, exists := index.GetReducedMetadata("/testpath/testfile.txt", false)
if !exists || fileInfo.Name != "testfile.txt" {
t.Fatalf("expected testfile.txt to be updated in the directory metadata:%v %v", exists, info.Name)
}
}
// Test for GetDirMetadata
func TestGetDirMetadata(t *testing.T) {
t.Parallel()
_, exists := testIndex.GetReducedMetadata("/testpath", true)
if !exists {
t.Fatalf("expected GetDirMetadata to return initialized metadata map")
}
_, exists = testIndex.GetReducedMetadata("/nonexistent", true)
if exists {
t.Fatalf("expected GetDirMetadata to return false for nonexistent directory")
}
}
// Test for SetDirectoryInfo
func TestSetDirectoryInfo(t *testing.T) {
index := &Index{
Directories: map[string]*FileInfo{
"/testpath": {
Path: "/testpath",
ItemInfo: ItemInfo{
Name: "testpath",
Type: "directory",
},
Files: []ItemInfo{
{Name: "testfile.txt"},
{Name: "anotherfile.txt"},
},
},
},
}
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.Files[0].Name != "testfile.txt" {
t.Fatalf("expected SetDirectoryInfo to store directory info correctly")
}
}
// Test for RemoveDirectory
func TestRemoveDirectory(t *testing.T) {
index := &Index{
Directories: map[string]*FileInfo{
"/testpath": {},
},
}
index.RemoveDirectory("/testpath")
_, exists := index.Directories["/testpath"]
if exists {
t.Fatalf("expected directory to be removed")
}
}
func init() {
testIndex = Index{
Root: "/",
NumFiles: 10,
NumDirs: 5,
Directories: map[string]*FileInfo{
"/testpath": {
Path: "/testpath",
ItemInfo: ItemInfo{
Name: "testpath",
Type: "directory",
},
Files: []ItemInfo{
{Name: "testfile.txt", Size: 100},
{Name: "anotherfile.txt", Size: 100},
},
},
"/anotherpath": {
Path: "/anotherpath",
ItemInfo: ItemInfo{
Name: "anotherpath",
Type: "directory",
},
Files: []ItemInfo{
{Name: "afile.txt", Size: 100},
},
Folders: []ItemInfo{
{Name: "directory", Type: "directory", Size: 100},
},
},
},
}
}
-59
View File
@@ -1,59 +0,0 @@
package files
import (
"os"
"unicode/utf8"
)
func isBinary(content []byte) bool {
maybeStr := string(content)
runeCnt := utf8.RuneCount(content)
runeIndex := 0
gotRuneErrCnt := 0
firstRuneErrIndex := -1
const (
// 8 and below are control chars (e.g. backspace, null, eof, etc)
maxControlCharsCode = 8
// 0xFFFD(65533) is the "error" Rune or "Unicode replacement character"
// see https://golang.org/pkg/unicode/utf8/#pkg-constants
unicodeReplacementChar = 0xFFFD
)
for _, b := range maybeStr {
if b <= maxControlCharsCode {
return true
}
if b == unicodeReplacementChar {
// if it is not the last (utf8.UTFMax - x) rune
if runeCnt > utf8.UTFMax && runeIndex < runeCnt-utf8.UTFMax {
return true
}
// else it is the last (utf8.UTFMax - x) rune
// there maybe Vxxx, VVxx, VVVx, thus, we may got max 3 0xFFFD rune (assume V is the byte we got)
// for Chinese, it can only be Vxx, VVx, we may got max 2 0xFFFD rune
gotRuneErrCnt++
// mark the first time
if firstRuneErrIndex == -1 {
firstRuneErrIndex = runeIndex
}
}
runeIndex++
}
// if last (utf8.UTFMax - x ) rune has the "error" Rune, but not all
if firstRuneErrIndex != -1 && gotRuneErrCnt != runeCnt-firstRuneErrIndex {
return true
}
return false
}
func IsNamedPipe(mode os.FileMode) bool {
return mode&os.ModeNamedPipe != 0
}
func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0
}
+10 -10
View File
@@ -2,23 +2,23 @@ package fileutils
import (
"os"
"path"
"github.com/spf13/afero"
"path/filepath"
)
// Copy copies a file or folder from one place to another.
func Copy(fs afero.Fs, src, dst string) error {
if src = path.Clean("/" + src); src == "" {
func CopyHelper(src, dst string) error {
src = filepath.Clean(src)
if src == "" {
return os.ErrNotExist
}
if dst = path.Clean("/" + dst); dst == "" {
dst = filepath.Clean(dst)
if dst == "" {
return os.ErrNotExist
}
if src == "/" || dst == "/" {
// Prohibit copying from or to the virtual root directory.
// Prohibit copying from or to the root directory.
return os.ErrInvalid
}
@@ -26,14 +26,14 @@ func Copy(fs afero.Fs, src, dst string) error {
return os.ErrInvalid
}
info, err := fs.Stat(src)
info, err := os.Stat(src)
if err != nil {
return err
}
if info.IsDir() {
return CopyDir(fs, src, dst)
return CopyDir(src, dst)
}
return CopyFile(fs, src, dst)
return CopyFile(src, dst)
}
+15 -10
View File
@@ -2,27 +2,32 @@ package fileutils
import (
"errors"
"github.com/spf13/afero"
"os"
"path/filepath"
)
// CopyDir copies a directory from source to dest and all
// of its sub-directories. It doesn't stop if it finds an error
// during the copy. Returns an error if any.
func CopyDir(fs afero.Fs, source, dest string) error {
func CopyDir(source, dest string) error {
// Get properties of source.
srcinfo, err := fs.Stat(source)
srcinfo, err := os.Stat(source)
if err != nil {
return err
}
// Create the destination directory.
err = fs.MkdirAll(dest, srcinfo.Mode())
err = os.MkdirAll(dest, srcinfo.Mode())
if err != nil {
return err
}
dir, _ := fs.Open(source)
dir, err := os.Open(source)
if err != nil {
return err
}
defer dir.Close()
obs, err := dir.Readdir(-1)
if err != nil {
return err
@@ -31,18 +36,18 @@ func CopyDir(fs afero.Fs, source, dest string) error {
var errs []error
for _, obj := range obs {
fsource := source + "/" + obj.Name()
fdest := dest + "/" + obj.Name()
fsource := filepath.Join(source, obj.Name())
fdest := filepath.Join(dest, obj.Name())
if obj.IsDir() {
// Create sub-directories, recursively.
err = CopyDir(fs, fsource, fdest)
err = CopyDir(fsource, fdest)
if err != nil {
errs = append(errs, err)
}
} else {
// Perform the file copy.
err = CopyFile(fs, fsource, fdest)
err = CopyFile(fsource, fdest)
if err != nil {
errs = append(errs, err)
}
+24 -38
View File
@@ -5,24 +5,22 @@ import (
"os"
"path"
"path/filepath"
"github.com/spf13/afero"
)
// MoveFile moves file from src to dst.
// By default the rename filesystem system call is used. If src and dst point to different volumes
// the file copy is used as a fallback
func MoveFile(fs afero.Fs, src, dst string) error {
if fs.Rename(src, dst) == nil {
// 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 {
return nil
}
// fallback
err := CopyFile(fs, src, dst)
err := CopyFile(src, dst)
if err != nil {
_ = fs.Remove(dst)
_ = os.Remove(dst)
return err
}
if err := fs.Remove(src); err != nil {
if err := os.Remove(src); err != nil {
return err
}
return nil
@@ -30,23 +28,22 @@ func MoveFile(fs afero.Fs, src, dst string) error {
// CopyFile copies a file from source to dest and returns
// an error if any.
func CopyFile(fs afero.Fs, source, dest string) error {
func CopyFile(source, dest string) error {
// Open the source file.
src, err := fs.Open(source)
src, err := os.Open(source)
if err != nil {
return err
}
defer src.Close()
// Makes the directory needed to create the dst
// file.
err = fs.MkdirAll(filepath.Dir(dest), 0666) //nolint:gomnd
// Makes the directory needed to create the dst file.
err = os.MkdirAll(filepath.Dir(dest), 0775) //nolint:gomnd
if err != nil {
return err
}
// Create the destination file.
dst, err := fs.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775) //nolint:gomnd
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) //nolint:gomnd
if err != nil {
return err
}
@@ -58,12 +55,12 @@ func CopyFile(fs afero.Fs, source, dest string) error {
return err
}
// Copy the mode
info, err := fs.Stat(source)
// Copy the mode.
info, err := os.Stat(source)
if err != nil {
return err
}
err = fs.Chmod(dest, info.Mode())
err = os.Chmod(dest, info.Mode())
if err != nil {
return err
}
@@ -71,7 +68,7 @@ func CopyFile(fs afero.Fs, source, dest string) error {
return nil
}
// CommonPrefix returns common directory path of provided files
// CommonPrefix returns the common directory path of provided files.
func CommonPrefix(sep byte, paths ...string) string {
// Handle special cases.
switch len(paths) {
@@ -81,30 +78,19 @@ func CommonPrefix(sep byte, paths ...string) string {
return path.Clean(paths[0])
}
// Note, we treat string as []byte, not []rune as is often
// done in Go. (And sep as byte, not rune). This is because
// most/all supported OS' treat paths as string of non-zero
// bytes. A filename may be displayed as a sequence of Unicode
// runes (typically encoded as UTF-8) but paths are
// not required to be valid UTF-8 or in any normalized form
// (e.g. "é" (U+00C9) and "é" (U+0065,U+0301) are different
// file names.
// Treat string as []byte, not []rune as is often done in Go.
c := []byte(path.Clean(paths[0]))
// We add a trailing sep to handle the case where the
// common prefix directory is included in the path list
// (e.g. /home/user1, /home/user1/foo, /home/user1/bar).
// path.Clean will have cleaned off trailing / separators with
// the exception of the root directory, "/" (in which case we
// make it "//", but this will get fixed up to "/" bellow).
// Add a trailing sep to handle the case where the common prefix directory
// is included in the path list.
c = append(c, sep)
// Ignore the first path since it's already in c
// Ignore the first path since it's already in c.
for _, v := range paths[1:] {
// Clean up each path before testing it
// Clean up each path before testing it.
v = path.Clean(v) + string(sep)
// Find the first non-common byte and truncate c
// Find the first non-common byte and truncate c.
if len(v) < len(c) {
c = c[:len(v)]
}
@@ -116,7 +102,7 @@ func CommonPrefix(sep byte, paths ...string) string {
}
}
// Remove trailing non-separator characters and the final separator
// Remove trailing non-separator characters and the final separator.
for i := len(c) - 1; i >= 0; i-- {
if c[i] == sep {
c = c[:i]
-1
View File
@@ -1 +0,0 @@
../../frontend/dist
+33 -39
View File
@@ -1,58 +1,52 @@
module github.com/gtsteffaniak/filebrowser
module github.com/gtsteffaniak/filebrowser/backend
go 1.21
go 1.22.5
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.11.0
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/google/go-cmp v0.5.9
github.com/gorilla/mux v1.8.0
github.com/maruel/natural v1.1.0
github.com/marusama/semaphore/v2 v2.5.0
github.com/mholt/archiver/v3 v3.5.1
github.com/shirou/gopsutil/v3 v3.23.8
github.com/spf13/afero v1.9.5
github.com/spf13/cobra v1.7.0
github.com/gabriel-vasile/mimetype v1.4.7
github.com/goccy/go-yaml v1.15.7
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/google/go-cmp v0.6.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.8.4
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
golang.org/x/crypto v0.12.0
golang.org/x/image v0.12.0
golang.org/x/text v0.13.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
github.com/stretchr/testify v1.9.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.30.0
golang.org/x/image v0.23.0
golang.org/x/text v0.21.0
)
require (
github.com/andybalholm/brotli v1.0.1 // 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.10.0 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/golang/snappy v0.0.2 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // 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.0 // indirect
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.11.4 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
github.com/nwaples/rardecode v1.1.0 // indirect
github.com/pierrec/lz4/v4 v4.1.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/ulikunitz/xz v0.5.9 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
go.etcd.io/bbolt v1.3.4 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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.32.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/tools v0.28.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+81 -508
View File
@@ -1,68 +1,16 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
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 h1:KqhlKozYbRtJvsPrrEeXcO+N2l6NYT5A2QAFmSULpEc=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
@@ -80,538 +28,163 @@ 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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
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.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
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=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
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.11.0 h1:n7Z+zx8S9f9KgzG6KtQKf+kwqXZlLNR2F6018Dgau54=
github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng=
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-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-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
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.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/goccy/go-yaml v1.15.7 h1:L7XuKpd/A66X4w/dlk08lVfiIADdy79a1AzRoIefC98=
github.com/goccy/go-yaml v1.15.7/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/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 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
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/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.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
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 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4 h1:kz40R/YWls3iqT9zX9AHN3WoVsrAWVyui5sxuLqiXqU=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/maruel/natural v1.1.0 h1:2z1NgP/Vae+gYrtC0VuvrTJ6U35OuyUqDdfluLqMWuQ=
github.com/maruel/natural v1.1.0/go.mod h1:eFVhYCcUOfZFxXoDZam8Ktya72wa79fNC3lc/leA0DQ=
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.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
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 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7MQ=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/pierrec/lz4/v4 v4.1.2 h1:qvY3YFXRQE/XB8MlLzJH7mSzBs74eA2gg52YTk6jUPM=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
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/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v3 v3.23.8 h1:xnATPiybo6GgdRoC4YoGnxXZFRc3dqQTGi73oLvvBrE=
github.com/shirou/gopsutil/v3 v3.23.8/go.mod h1:7hmCaBn+2ZwaZOr6jmPBZDfawwMGuo1id3C6aM8EDqQ=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
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 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
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/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.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg=
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.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
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-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/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-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/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-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/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-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/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.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/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/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
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-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+121
View File
@@ -0,0 +1,121 @@
package http
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
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 name == "" {
return http.StatusInternalServerError, fmt.Errorf("api name must be valid")
}
if durationStr == "" {
return http.StatusInternalServerError, fmt.Errorf("api duration must be valid")
}
if permissionsStr == "" {
return http.StatusInternalServerError, 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,
Execute: strings.Contains(permissionsStr, "execute") && d.user.Perm.Execute,
Create: strings.Contains(permissionsStr, "create") && d.user.Perm.Create,
Rename: strings.Contains(permissionsStr, "rename") && d.user.Perm.Rename,
Modify: strings.Contains(permissionsStr, "modify") && d.user.Perm.Modify,
Delete: strings.Contains(permissionsStr, "delete") && d.user.Perm.Delete,
Share: strings.Contains(permissionsStr, "share") && d.user.Perm.Share,
Download: strings.Contains(permissionsStr, "download") && d.user.Perm.Download,
}
// 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)
}
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
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"`
}
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
key := r.URL.Query().Get("key")
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)
}
+166 -137
View File
@@ -2,118 +2,95 @@ package http
import (
"encoding/json"
libError "errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"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/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"
)
const (
TokenExpirationTime = time.Hour * 2
var (
revokedApiKeyList map[string]bool
revokeMu sync.Mutex
)
type userInfo struct {
ID uint `json:"id"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Perm users.Permissions `json:"perm"`
Commands []string `json:"commands"`
LockPassword bool `json:"lockPassword"`
HideDotfiles bool `json:"hideDotfiles"`
DateFormat bool `json:"dateFormat"`
}
// 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
}
}
type authToken struct {
User userInfo `json:"user"`
jwt.RegisteredClaims
}
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
// 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
}
}
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
}
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.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)
}
}
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)
func loginHandler(w http.ResponseWriter, r *http.Request) {
// Get the authentication method from the settings
auther, err := store.Auth.Get(config.Auth.Method)
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)
// Authenticate the user based on the request
user, err := auther.Auth(r, store.Users)
if err == os.ErrPermission {
return http.StatusForbidden, nil
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
} else if err != nil {
return http.StatusInternalServerError, err
} else {
return printToken(w, r, d, user)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
http.Error(w, http.StatusText(status), status)
}
}
@@ -122,89 +99,141 @@ type signupBody struct {
Password string `json:"password"`
}
var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.settings.Signup {
return http.StatusMethodNotAllowed, nil
func signupHandler(w http.ResponseWriter, r *http.Request) {
if !settings.Config.Auth.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.User{
Username: info.Username,
}
user := settings.ApplyUserDefaults(users.User{})
user.Username = info.Username
user.Password = info.Password
d.settings.UserDefaults.Apply(user)
pwd, err := users.HashPwd(info.Password)
if err != nil {
return http.StatusInternalServerError, err
}
user.Password = pwd
userHome, err := d.settings.MakeUserDir(user.Username, user.Scope, d.server.Root)
userHome, err := config.MakeUserDir(user.Username, user.Scope, config.Server.Root)
if err != nil {
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
return http.StatusInternalServerError, err
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
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)
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)
})
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) {
claims := &authToken{
User: userInfo{
ID: user.ID,
Locale: user.Locale,
ViewMode: user.ViewMode,
SingleClick: user.SingleClick,
Perm: user.Perm,
LockPassword: user.LockPassword,
Commands: user.Commands,
HideDotfiles: user.HideDotfiles,
DateFormat: user.DateFormat,
},
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(TokenExpirationTime)),
Issuer: "File Browser",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(d.settings.Key)
func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int, error) {
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.GenerateRandomHash(4), time.Hour*2, 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(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
}
-80
View File
@@ -1,80 +0,0 @@
package http
import (
"log"
"net/http"
"strconv"
"github.com/tomasen/realip"
"github.com/gtsteffaniak/filebrowser/rules"
"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 {
if d.user.HideDotfiles && rules.MatchHidden(path) {
return false
}
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 @@
*
-75
View File
@@ -1,75 +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.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
}
+329
View File
@@ -0,0 +1,329 @@
package http
import (
"compress/gzip"
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/runner"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type requestContext struct {
user *users.User
*runner.Runner
raw interface{}
}
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")
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")
}
// 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")
}
}
// Retrieve the user (using the public user by default)
user := &users.PublicUser
// Get file information with options
file, err := FileInfoFasterFunc(files.FileOptions{
Path: filepath.Join(user.Scope, link.Path+"/"+path),
Modify: user.Perm.Modify,
Expand: true,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: user, // Call your checker function here
})
file.Token = link.Token
if err != nil {
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
}
// Proceed to the actual handler if the user is admin
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 settings.Config.Auth.Method == "noauth" {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(config.Server.Root, "admin")
if err != nil {
return http.StatusInternalServerError, err
}
return fn(w, r, data)
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
return config.Auth.Key, nil
}
tokenString, err := extractToken(r)
if err != nil {
return http.StatusUnauthorized, err
}
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(config.Server.Root, tk.BelongsTo)
if err != nil {
return http.StatusInternalServerError, err
}
// 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{
Runner: &runner.Runner{Enabled: config.Server.EnableExec, Settings: config},
}
// 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 {
log.Printf("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 {
log.Printf("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
}
// 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)
}
// 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)
// Determine the color based on the status code.
color := "\033[32m" // Default green color
if wrappedWriter.StatusCode >= 300 && wrappedWriter.StatusCode < 500 {
color = "\033[33m" // Yellow for client errors (4xx)
} else if wrappedWriter.StatusCode >= 500 {
color = "\033[31m" // Red for server errors (5xx)
}
// Capture the full URL path including the query parameters.
fullURL := r.URL.Path
if r.URL.RawQuery != "" {
fullURL += "?" + r.URL.RawQuery
}
// Log the request, status code, and response size.
log.Printf("%s%-7s | %3d | %-15s | %-12s | \"%s\"%s",
color,
r.Method,
wrappedWriter.StatusCode, // Captured status code
r.RemoteAddr,
time.Since(start).String(),
fullURL,
"\033[0m", // Reset color
)
})
}
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"
"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); err != nil {
t.Fatal("failed to save admin user:", err)
}
if err := store.Users.Save(nonAdminUser); 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.GenerateRandomHash(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: []byte("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
}
+82 -70
View File
@@ -1,4 +1,3 @@
//go:generate go-enum --sql --marshal --names --file $GOFILE
package http
import (
@@ -7,21 +6,14 @@ import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"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"
)
/*
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
@@ -33,85 +25,89 @@ 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) {
// 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")
previewSize := r.URL.Query().Get("size")
if previewSize != "small" {
previewSize = "large"
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
response, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Expand: true,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
})
fileInfo := response.FileInfo
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) {
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.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
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)
return rawFileHandler(w, r, fileInfo)
}
format, err := imgSvc.FormatFromExtension(file.Extension)
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)
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
return rawFileHandler(w, r, fileInfo)
}
if err != nil {
return errToStatus(err), err
}
cacheKey := previewCacheKey(file, previewSize)
cacheKey := previewCacheKey(fileInfo, previewSize)
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 := file.Fs.Open(file.Path)
func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo, previewSize string) ([]byte, error) {
fd, err := os.Open(file.RealPath())
if err != nil {
return nil, err
}
@@ -124,11 +120,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))
@@ -151,6 +147,22 @@ func createPreview(imgSvc ImgService, fileCache FileCache,
return buf.Bytes(), nil
}
func previewCacheKey(f *files.FileInfo, previewSize PreviewSize) string {
// Generates a cache key for the preview image
func previewCacheKey(f *files.FileInfo, previewSize string) string {
return fmt.Sprintf("%x%x%x", f.RealPath(), f.ModTime.Unix(), previewSize)
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
realPath, _, _ := files.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
}
+49 -130
View File
@@ -1,148 +1,67 @@
package http
import (
"errors"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"path/filepath"
"strings"
"github.com/spf13/afero"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/share"
_ "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, ifPath := ifPathWithName(r)
link, err := d.store.Share.GetByHash(id)
if err != nil {
return errToStatus(err), err
}
status, err := authenticateShareRequest(r, link)
if status != 0 || err != nil {
return status, err
}
user, err := d.store.Users.Get(d.server.Root, link.UserID)
if err != nil {
return errToStatus(err), err
}
d.user = user
file, err := files.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
Path: link.Path,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
Token: link.Token,
})
if err != nil {
return errToStatus(err), err
}
// share base path
basePath := link.Path
// file relative path
filePath := ""
if file.IsDir {
basePath = filepath.Dir(basePath)
filePath = ifPath
}
// set fs root to the shared file/folder
d.user.Fs = afero.NewBasePathFs(d.user.Fs, basePath)
file, err = files.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
Path: filePath,
Modify: d.user.Perm.Modify,
Expand: true,
Checker: d,
Token: link.Token,
})
if err != nil {
return errToStatus(err), err
}
d.raw = file
return fn(w, r, d)
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")
}
}
// ref to https://github.com/filebrowser/filebrowser/pull/727
// `/api/public/dl/MEEuZK-v/file-name.txt` for old browsers to save file with correct name
func ifPathWithName(r *http.Request) (id, filePath string) {
pathElements := strings.Split(r.URL.Path, "/")
// prevent maliciously constructed parameters like `/api/public/dl/XZzCDnK2_not_exists_hash_name`
// len(pathElements) will be 1, and golang will panic `runtime error: index out of range`
switch len(pathElements) {
case 1:
return r.URL.Path, "/"
default:
return pathElements[0], path.Join("/", path.Join(pathElements[1:]...))
}
}
var publicShareHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
file := d.raw.(*files.FileInfo)
if file.IsDir {
file.Listing.Sorting = files.Sorting{By: "name", Asc: false}
file.Listing.ApplySort()
return renderJSON(w, r, file)
}
file.Path = strings.TrimPrefix(file.Path, settings.Config.Server.Root)
return renderJSON(w, r, file)
})
}
var publicDlHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
file := d.raw.(*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)
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.
status, err := renderJSON(w, r, users.PublicUser)
if err != nil {
return 0, err
}
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
http.Error(w, http.StatusText(status), status)
}
return 0, nil
}
func healthHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"OK"}`))
func publicDlHandler(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")
}
if d.user == nil {
return http.StatusUnauthorized, fmt.Errorf("failed to get user")
}
if file.Type == "directory" {
return rawFilesHandler(w, r, d, []string{file.Path})
}
return rawFileHandler(w, r, file.FileInfo)
}
// 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)
}
}
-136
View File
@@ -1,136 +0,0 @@
package http
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/asdine/storm/v3"
"github.com/spf13/afero"
"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", UserID: 1},
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.Users.Save(&users.User{Username: "username", Password: "pw"}); err != nil {
t.Fatalf("failed to save user: %v", err)
}
if err := storage.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil {
t.Fatalf("failed to save settings: %v", err)
}
storage.Users = &customFSUser{
Store: storage.Users,
fs: &afero.MemMapFs{},
}
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
fs afero.Fs
}
func (cu *customFSUser) Get(baseScope string, id interface{}) (*users.User, error) {
user, err := cu.Store.Get(baseScope, id)
if err != nil {
return nil, err
}
user.Fs = cu.fs
return user, nil
}
+174 -156
View File
@@ -1,214 +1,232 @@
package http
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
gopath "path"
"os"
"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"
)
func slashClean(name string) string {
if name == "" || name[0] != '/' {
name = "/" + name
}
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"), ",")
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))
}
}
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")
}
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.FileInfo) {
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
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))
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName))
}
}
var rawHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
// 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 "Comma-separated list of specific files within the directory (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 "Download 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) {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
file, err := files.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
Path: r.URL.Path,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
})
encodedFiles := r.URL.Query().Get("files")
// Decode the URL-encoded path
files, err := url.QueryUnescape(encodedFiles)
if err != nil {
return errToStatus(err), err
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
return rawFilesHandler(w, r, d, strings.Split(files, ","))
}
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) {
func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer) error {
realPath, _, _ := files.GetRealPath(d.user.Scope, path)
if !d.user.Check(realPath) {
return nil
}
info, err := d.user.Fs.Stat(path)
info, err := os.Stat(realPath)
if err != nil {
return err
}
if !info.IsDir() && !info.Mode().IsRegular() {
return nil
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
}
relPath, err := filepath.Rel(filepath.Dir(realPath), filePath) // Relative path including base dir
if err != nil {
return err
}
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 := d.user.Fs.Open(path)
// Add a single file if it's not a directory
return addSingleFile(realPath, filepath.Base(realPath), 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 := os.Stat(realPath)
if err != nil {
return err
}
if info.IsDir() {
names, err := file.Readdirnames(0)
if tarWriter != nil {
header, err := tar.FileInfoHeader(info, realPath)
if err != nil {
return err
}
header.Name = 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) {
filePath := fileList[0]
fileName := filepath.Base(filePath)
realPath, isDir, err := files.GetRealPath(d.user.Scope, filePath)
if err != nil {
return http.StatusInternalServerError, err
}
if len(fileList) == 1 && !isDir {
fd, err2 := os.Open(realPath)
if err2 != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
// Get file information
fileInfo, err3 := fd.Stat()
if err3 != nil {
return http.StatusInternalServerError, err
}
// Set headers and serve the file
setContentDisposition(w, r, fileName)
w.Header().Set("Cache-Control", "private")
// Serve the content
http.ServeContent(w, r, fileName, fileInfo.ModTime(), fd)
return 0, nil
}
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(realPath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
downloadFileName := url.PathEscape(baseDirName + "." + algo)
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+downloadFileName)
// Create the archive and stream it directly to the response
if extension == ".zip" {
err = createZip(w, d, fileList...)
} else {
err = createTarGz(w, d, fileList...)
}
if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
func createZip(w io.Writer, d *requestContext, filenames ...string) error {
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
for _, fname := range filenames {
err := addFile(fname, d, nil, zipWriter)
if err != nil {
log.Printf("Failed to add %s to ZIP: %v", fname, 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)
if err != nil {
return http.StatusInternalServerError, err
}
func createTarGz(w io.Writer, d *requestContext, filenames ...string) error {
gzWriter := gzip.NewWriter(w)
defer gzWriter.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))
tarWriter := tar.NewWriter(gzWriter)
defer tarWriter.Close()
for _, fname := range filenames {
err = addFile(ar, d, fname, commonDir)
err := addFile(fname, d, tarWriter, nil)
if err != nil {
log.Printf("Failed to archive %s: %v", fname, err)
log.Printf("Failed to add %s to TAR.GZ: %v", fname, err)
}
}
return 0, nil
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
fd, err := file.Fs.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
}
+315 -258
View File
@@ -3,330 +3,355 @@ package http
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/shirou/gopsutil/v3/disk"
"github.com/spf13/afero"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
file, err := files.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
Path: r.URL.Path,
// 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 "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) {
// TODO source := r.URL.Query().Get("source")
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)
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Expand: true,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
Content: true,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
Content: r.URL.Query().Get("content") == "true",
})
if err != nil {
return errToStatus(err), err
}
if file.IsDir {
file.Listing.Sorting = d.user.Sorting
file.Listing.ApplySort()
return renderJSON(w, r, file)
if fileInfo.Type == "directory" {
return renderJSON(w, r, fileInfo)
}
if checksum := r.URL.Query().Get("checksum"); checksum != "" {
err := file.Checksum(checksum)
if algo := r.URL.Query().Get("checksum"); algo != "" {
checksums, err := files.GetChecksum(fileInfo.Path, 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)
// do not waste bandwidth if we just want the checksum
file.Content = ""
}
// 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 "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")
// 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 == "/" || !d.user.Perm.Delete {
return http.StatusForbidden, nil
}
realPath, isDir, err := files.GetRealPath(d.user.Scope, path)
if err != nil {
return http.StatusNotFound, err
}
fileOpts := files.FileOptions{
Path: realPath,
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
}
fileInfo, err := files.FileInfoFaster(fileOpts)
if err != nil {
return errToStatus(err), err
}
return renderJSON(w, r, file)
})
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
}
file, err := files.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
Path: r.URL.Path,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
})
if err != nil {
return errToStatus(err), err
}
// delete thumbnails
err = delThumbs(r.Context(), fileCache, file)
if err != nil {
return errToStatus(err), err
}
err = d.RunHook(func() error {
return d.user.Fs.RemoveAll(r.URL.Path)
}, "delete", r.URL.Path, "", d.user)
if err != nil {
return errToStatus(err), err
}
return http.StatusOK, 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) {
return http.StatusForbidden, nil
}
// Directories creation on POST.
if strings.HasSuffix(r.URL.Path, "/") {
err := d.user.Fs.MkdirAll(r.URL.Path, 0775) //nolint:gomnd
return errToStatus(err), err
}
file, err := files.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
Path: r.URL.Path,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
})
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 = d.RunHook(func() error {
info, writeErr := writeFile(d.user.Fs, r.URL.Path, r.Body)
if writeErr != nil {
return writeErr
}
etag := fmt.Sprintf(`"%x%x"`, info.ModTime().UnixNano(), info.Size())
w.Header().Set("ETag", etag)
return nil
}, "upload", r.URL.Path, "", d.user)
if err != nil {
_ = d.user.Fs.RemoveAll(r.URL.Path)
}
// delete thumbnails
err = delThumbs(r.Context(), fileCache, fileInfo.FileInfo)
if err != nil {
return errToStatus(err), err
})
}
err = files.DeleteFiles(realPath, fileOpts)
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) {
// 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 "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) {
// TODO source := r.URL.Query().Get("source")
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)
}
if !d.user.Perm.Create || !d.user.Check(path) {
return http.StatusForbidden, nil
}
fileOpts := files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Expand: false,
Checker: d.user,
}
// 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" {
return http.StatusConflict, nil
}
// Permission for overwriting the file
if !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
err = delThumbs(r.Context(), fileCache, fileInfo.FileInfo)
if err != nil {
return errToStatus(err), err
}
}
err = files.WriteFile(fileOpts, r.Body)
if err != nil {
return errToStatus(err), err
}
return http.StatusOK, 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 "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) {
// TODO source := r.URL.Query().Get("source")
// TODO source := r.URL.Query().Get("source")
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)
}
if !d.user.Perm.Modify || !d.user.Check(path) {
return http.StatusForbidden, nil
}
// Only allow PUT for files.
if strings.HasSuffix(r.URL.Path, "/") {
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
}
exists, err := afero.Exists(d.user.Fs, r.URL.Path)
realPath, isDir, err := files.GetRealPath(d.user.Scope, path)
if err != nil {
return http.StatusInternalServerError, err
return http.StatusNotFound, err
}
if !exists {
return http.StatusNotFound, nil
fileOpts := files.FileOptions{
Path: realPath,
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
}
err = files.WriteFile(fileOpts, r.Body)
return errToStatus(err), err
}
// 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 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")
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
}
if !d.user.Check(src) || !d.user.Check(dst) {
return http.StatusForbidden, nil
}
if dst == "/" || src == "/" {
return http.StatusForbidden, nil
}
// check target dir exists
parentDir, _, err := files.GetRealPath(d.user.Scope, filepath.Dir(dst))
if err != nil {
return http.StatusNotFound, err
}
realDest := parentDir + "/" + filepath.Base(dst)
realSrc, isSrcDir, err := files.GetRealPath(d.user.Scope, 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, nil
}
err = d.RunHook(func() error {
info, writeErr := writeFile(d.user.Fs, r.URL.Path, r.Body)
if writeErr != nil {
return writeErr
}
etag := fmt.Sprintf(`"%x%x"`, info.ModTime().UnixNano(), info.Size())
w.Header().Set("ETag", etag)
return nil
}, "save", r.URL.Path, "", d.user)
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir)
}, action, realSrc, realDest, d.user)
return errToStatus(err), err
})
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
}
err = checkParent(src, dst)
if err != nil {
return http.StatusBadRequest, err
}
override := r.URL.Query().Get("override") == "true"
rename := r.URL.Query().Get("rename") == "true"
if !override && !rename {
if _, err = d.user.Fs.Stat(dst); err == nil {
return http.StatusConflict, nil
}
}
if rename {
dst = addVersionSuffix(dst, d.user.Fs)
}
// 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)
return errToStatus(err), err
})
}
func checkParent(src, dst string) error {
rel, err := filepath.Rel(src, dst)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if !strings.HasPrefix(rel, "../") && rel != ".." && rel != "." {
return errors.ErrSourceIsParent
}
return nil
}
func addVersionSuffix(source string, fs afero.Fs) string {
func addVersionSuffix(source string) string {
counter := 1
dir, name := path.Split(source)
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for {
if _, err := fs.Stat(source); err != nil {
if _, err := os.Stat(source); err != nil {
break
}
renamed := fmt.Sprintf("%s(%d)%s", base, counter, ext)
source = path.Join(dir, renamed)
counter++
}
return source
}
func writeFile(fs afero.Fs, dst string, in io.Reader) (os.FileInfo, error) {
dir, _ := path.Split(dst)
err := fs.MkdirAll(dir, 0775) //nolint:gomnd
if err != nil {
return nil, err
}
file, err := fs.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775) //nolint:gomnd
if err != nil {
return nil, err
}
defer file.Close()
_, err = io.Copy(file, in)
if err != nil {
return nil, err
}
// Gets the info about the file.
info, err := file.Stat()
if err != nil {
return nil, err
}
return info, nil
}
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
}
if err := fileCache.Delete(ctx, previewCacheKey(file, "small")); err != nil {
return 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) error {
switch action {
// TODO: use enum
case "copy":
if !d.user.Perm.Create {
return errors.ErrPermissionDenied
}
err := files.CopyResource(src, dst, isSrcDir)
return err
case "rename", "move":
return fileutils.Copy(d.user.Fs, src, dst)
case "rename":
if !d.user.Perm.Rename {
return errors.ErrPermissionDenied
}
src = path.Clean("/" + src)
dst = path.Clean("/" + dst)
file, err := files.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: src,
IsDir: isSrcDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: false,
Checker: d,
Checker: d.user,
})
if err != nil {
return err
}
// delete thumbnails
err = delThumbs(ctx, fileCache, file)
err = delThumbs(ctx, fileCache, fileInfo.FileInfo)
if err != nil {
return err
}
return fileutils.MoveFile(d.user.Fs, src, dst)
return files.MoveResource(src, dst, isSrcDir)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
}
@@ -337,33 +362,65 @@ type DiskUsageResponse struct {
Used uint64 `json:"used"`
}
var diskUsage = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
file, err := files.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
Path: r.URL.Path,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: false,
Checker: d,
Content: false,
})
// 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 "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) {
source := r.URL.Query().Get("source")
if source == "" {
source = "/"
}
value, ok := utils.DiskUsageCache.Get(source).(DiskUsageResponse)
if ok {
return renderJSON(w, r, &value)
}
fPath, isDir, err := files.GetRealPath(d.user.Scope, source)
if err != nil {
return errToStatus(err), err
}
fPath := file.RealPath()
if !file.IsDir {
return renderJSON(w, r, &DiskUsageResponse{
Total: 0,
Used: 0,
})
if !isDir {
return http.StatusNotFound, fmt.Errorf("not a directory: %s", source)
}
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,
})
})
}
utils.DiskUsageCache.Set(source, latestUsage)
return renderJSON(w, r, &latestUsage)
}
func inspectIndex(w http.ResponseWriter, r *http.Request) {
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
isDir := r.URL.Query().Get("isDir") == "true"
index := files.GetIndex(config.Server.Root)
info, _ := index.GetReducedMetadata(path, isDir)
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
}
+187
View File
@@ -0,0 +1,187 @@
package http
import (
"crypto/tls"
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"text/template"
"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(Service ImgService, storage *storage.Storage, cache FileCache) {
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 {
log.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(shareGetsHandler))
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(publicDlHandler))
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
api.HandleFunc("PUT /settings", withAdmin(settingsPutHandler))
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 := ""
// 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 {
log.Fatalf("could not load certificate: %v", err)
}
// Create a custom TLS listener
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cer},
}
// Set HTTPS scheme and default port for TLS
scheme = "https"
// Listen on TCP and wrap with TLS
listener, err := tls.Listen("tcp", fmt.Sprintf(":%v", config.Server.Port), tlsConfig)
if err != nil {
log.Fatalf("could not start TLS server: %v", err)
}
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)
log.Printf("Running at : %s", fullURL)
err = http.Serve(listener, muxWithMiddleware(router))
if err != nil {
log.Fatalf("could not start server: %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)
log.Printf("Running at : %s", fullURL)
err := http.ListenAndServe(fmt.Sprintf(":%v", config.Server.Port), muxWithMiddleware(router))
if err != nil {
log.Fatalf("could not start server: %v", err)
}
}
}
+59 -21
View File
@@ -2,31 +2,69 @@ package http
import (
"net/http"
"strings"
"github.com/gtsteffaniak/filebrowser/search"
"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")
searchScope := strings.TrimPrefix(r.URL.Query().Get("scope"), ".")
searchScope = strings.TrimPrefix(searchScope, "/")
// Retrieve the User-Agent and X-Auth headers from the request
sessionId := r.Header.Get("SessionId")
indexInfo, fileTypes := search.SearchAllIndexes(query, r.URL.Path, sessionId)
for _, path := range indexInfo {
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(settings.Config.Server.Root)
userScope := strings.TrimPrefix(d.user.Scope, ".")
combinedScope := strings.TrimPrefix(userScope+"/"+searchScope, "/")
// Perform the search using the provided query and user scope
response := index.Search(query, combinedScope, sessionId)
// Set the Content-Type header to application/json
w.Header().Set("Content-Type", "application/json")
return renderJSON(w, r, response)
})
}
+39 -25
View File
@@ -4,8 +4,8 @@ import (
"encoding/json"
"net/http"
"github.com/gtsteffaniak/filebrowser/rules"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type settingsData struct {
@@ -13,42 +13,56 @@ type settingsData struct {
CreateUserDir bool `json:"createUserDir"`
UserHomeBasePath string `json:"userHomeBasePath"`
Defaults settings.UserDefaults `json:"defaults"`
Rules []rules.Rule `json:"rules"`
Rules []users.Rule `json:"rules"`
Frontend settings.Frontend `json:"frontend"`
Shell []string `json:"shell"`
Commands map[string][]string `json:"commands"`
}
var settingsGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
// settingsGetHandler retrieves the current system settings.
// @Summary Get system settings
// @Description Returns the current configuration settings for signup, user directories, rules, frontend, and commands.
// @Tags Settings
// @Accept json
// @Produce json
// @Success 200 {object} settingsData "System settings data"
// @Router /api/settings [get]
func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
data := &settingsData{
Signup: d.settings.Signup,
CreateUserDir: d.settings.CreateUserDir,
UserHomeBasePath: d.settings.UserHomeBasePath,
Defaults: d.settings.UserDefaults,
Rules: d.settings.Rules,
Frontend: d.settings.Frontend,
Shell: d.settings.Shell,
Commands: d.settings.Commands,
Signup: config.Auth.Signup,
CreateUserDir: config.Server.CreateUserDir,
UserHomeBasePath: config.Server.UserHomeBasePath,
Defaults: config.UserDefaults,
Rules: config.Rules,
Frontend: config.Frontend,
}
return renderJSON(w, r, data)
})
}
var settingsPutHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
// settingsPutHandler updates the system settings.
// @Summary Update system settings
// @Description Updates the system configuration settings for signup, user directories, rules, frontend, and commands.
// @Tags Settings
// @Accept json
// @Produce json
// @Param body body settingsData true "Settings data to update"
// @Success 200 "Settings updated successfully"
// @Failure 400 {object} map[string]string "Bad request - failed to decode body"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/settings [put]
func settingsPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
req := &settingsData{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
return http.StatusBadRequest, err
}
d.settings.Signup = req.Signup
d.settings.CreateUserDir = req.CreateUserDir
d.settings.UserHomeBasePath = req.UserHomeBasePath
d.settings.UserDefaults = req.Defaults
d.settings.Rules = req.Rules
d.settings.Frontend = req.Frontend
d.settings.Shell = req.Shell
d.settings.Commands = req.Commands
err = d.store.Settings.Save(d.settings)
config.Server.CreateUserDir = req.CreateUserDir
config.Server.UserHomeBasePath = req.UserHomeBasePath
config.UserDefaults = req.Defaults
config.Rules = req.Rules
config.Frontend = req.Frontend
config.Auth.Signup = req.Signup
err = store.Settings.Save(config)
return errToStatus(err), err
})
}
+88 -44
View File
@@ -8,34 +8,32 @@ 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/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{})
@@ -51,36 +49,69 @@ 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"
// @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 shareGetsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
s, err := store.Share.Gets(path, 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)
return errToStatus(err), err
})
err := store.Share.Delete(hash)
if err != nil {
return errToStatus(err), err
}
var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
return errToStatus(err), err
}
// 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"
// @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 {
@@ -90,14 +121,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 != "" {
@@ -126,31 +154,32 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
if err != nil {
return status, err
}
stringHash := ""
var token string
if len(hash) > 0 {
tokenBuffer := make([]byte, 96) //nolint:gomnd
tokenBuffer := make([]byte, 24) //nolint:gomnd
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")
s = &share.Link{
Path: r.URL.Path,
Hash: str,
Path: path,
Hash: secure_hash,
Expire: expire,
UserID: d.user.ID,
PasswordHash: string(hash),
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 == "" {
@@ -159,8 +188,23 @@ func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, e
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
}
+85 -92
View File
@@ -7,47 +7,65 @@ import (
"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/auth"
"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(config.Auth.Method)
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,
"Color": d.settings.Frontend.Color,
"BaseURL": d.server.BaseURL,
"Name": config.Frontend.Name,
"DisableExternal": config.Frontend.DisableExternal,
"DisableUsedPercentage": config.Frontend.DisableUsedPercentage,
"darkMode": settings.Config.UserDefaults.DarkMode,
"Color": config.Frontend.Color,
"BaseURL": config.Server.BaseURL,
"Version": version.Version,
"StaticURL": path.Join(d.server.BaseURL, "/static"),
"Signup": d.settings.Signup,
"NoAuth": d.settings.Auth.Method == "noauth",
"AuthMethod": d.settings.Auth.Method,
"CommitSHA": version.CommitSHA,
"StaticURL": config.Server.BaseURL + "static",
"Signup": settings.Config.Auth.Signup,
"NoAuth": config.Auth.Method == "noauth",
"AuthMethod": config.Auth.Method,
"LoginPage": auther.LoginPage(),
"CSS": false,
"ReCaptcha": false,
"Theme": d.settings.Frontend.Theme,
"EnableThumbs": d.server.EnableThumbnails,
"ResizePreview": d.server.ResizePreview,
"EnableExec": d.server.EnableExec,
"EnableThumbs": config.Server.EnableThumbnails,
"ResizePreview": config.Server.ResizePreview,
"EnableExec": config.Server.EnableExec,
"ReCaptchaHost": config.Auth.Recaptcha.Host,
}
if d.settings.Frontend.Files != "" {
fPath := filepath.Join(d.settings.Frontend.Files, "custom.css")
if config.Frontend.Files != "" {
fPath := filepath.Join(config.Frontend.Files, "custom.css")
_, err := os.Stat(fPath) //nolint:govet
if err != nil && !os.IsNotExist(err) {
@@ -59,13 +77,18 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, fSys
}
}
if d.settings.Auth.Method == "password" {
raw, err := d.store.Auth.Get(d.settings.Auth.Method) //nolint:govet
if config.Auth.Method == "password" {
raw, err := store.Auth.Get(config.Auth.Method) //nolint:govet
if err != nil {
return http.StatusInternalServerError, err
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
auther := raw.(*auth.JSONAuth)
auther, ok := raw.(*auth.JSONAuth)
if !ok {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if auther.ReCaptcha != nil {
data["ReCaptcha"] = auther.ReCaptcha.Key != "" && auther.ReCaptcha.Secret != ""
@@ -76,77 +99,47 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, fSys
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, "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")
}
+192 -112
View File
@@ -2,120 +2,178 @@ package http
import (
"encoding/json"
"log"
"fmt"
"io"
"net/http"
"reflect"
"sort"
"strconv"
"github.com/gorilla/mux"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
var (
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
)
type modifyUserRequest struct {
modifyRequest
Data *users.User `json:"data"`
// SortingSettings represents the sorting settings.
type Sorting struct {
By string `json:"by"`
Asc bool `json:"asc"`
}
type UserRequest struct {
What string `json:"what"`
Which []string `json:"which"`
Data *users.User `json:"data"`
}
func getUserID(r *http.Request) (uint, error) {
vars := mux.Vars(r)
i, err := strconv.ParseUint(vars["id"], 10, 0)
if err != nil {
return 0, err
}
return uint(i), err
}
// userGetHandler retrieves a user by ID.
// @Summary Retrieve a user by ID
// @Description Returns a user's details based on their ID.
// @Tags Users
// @Accept json
// @Produce json
// @Param id path int true "User ID" or "self"
// @Success 200 {object} users.User "User details"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Not Found"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users/{id} [get]
func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
givenUserIdString := r.URL.Query().Get("id")
func getUser(_ http.ResponseWriter, r *http.Request) (*modifyUserRequest, error) {
if r.Body == nil {
return nil, errors.ErrEmptyRequest
}
// since api self is used to validate a logged in user
w.Header().Add("X-Renew-Token", "false")
req := &modifyUserRequest{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
return nil, err
}
if req.What != "user" {
return nil, errors.ErrInvalidDataType
}
return req, nil
}
func withSelfOrAdmin(fn handleFunc) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
id, err := getUserID(r)
var givenUserId uint
if givenUserIdString == "self" {
givenUserId = d.user.ID
} else if givenUserIdString == "" {
if !d.user.Perm.Admin {
return http.StatusForbidden, nil
}
users, err := store.Users.Gets(config.Server.Root)
if err != nil {
return http.StatusInternalServerError, err
}
if d.user.ID != id && !d.user.Perm.Admin {
return http.StatusForbidden, nil
for _, u := range users {
u.Password = ""
}
for _, u := range users {
u.ApiKeys = nil
}
d.raw = id
return fn(w, r, d)
})
}
sort.Slice(users, func(i, j int) bool {
return users[i].ID < users[j].ID
})
var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
users, err := d.store.Users.Gets(d.server.Root)
if err != nil {
return http.StatusInternalServerError, err
return renderJSON(w, r, users)
} else {
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
givenUserId = uint(num)
}
for _, u := range users {
u.Password = ""
if givenUserId != d.user.ID && !d.user.Perm.Admin {
return http.StatusForbidden, nil
}
sort.Slice(users, func(i, j int) bool {
return users[i].ID < users[j].ID
})
return renderJSON(w, r, users)
})
var userGetHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
u, err := d.store.Users.Get(d.server.Root, d.raw.(uint))
// Fetch the user details
u, err := store.Users.Get(config.Server.Root, givenUserId)
if err == errors.ErrNotExist {
return http.StatusNotFound, err
}
if err != nil {
return http.StatusInternalServerError, err
}
// Remove the password from the response if the user is not an admin
u.Password = ""
u.ApiKeys = nil
if !d.user.Perm.Admin {
u.Scope = ""
}
return renderJSON(w, r, u)
})
var userDeleteHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
err := d.store.Users.Delete(d.raw.(uint))
return renderJSON(w, r, u)
}
// userDeleteHandler deletes a user by ID.
// @Summary Delete a user by ID
// @Description Deletes a user identified by their ID.
// @Tags Users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Success 200 "User deleted successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users/{id} [delete]
func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
givenUserIdString := r.URL.Query().Get("id")
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
givenUserId := uint(num)
if givenUserId == d.user.ID {
return http.StatusForbidden, fmt.Errorf("cannot delete your own user")
}
if !d.user.Perm.Admin {
return http.StatusForbidden, fmt.Errorf("cannot delete users without admin permissions")
}
if givenUserId == 1 {
return http.StatusForbidden, fmt.Errorf("cannot delete the default admin user")
}
// Delete the user
err := store.Users.Delete(givenUserId)
if err != nil {
return errToStatus(err), err
}
return http.StatusOK, nil
})
}
var userPostHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
req, err := getUser(w, r)
// usersPostHandler creates a new user.
// @Summary Create a new user
// @Description Adds a new user to the system.
// @Tags Users
// @Accept json
// @Produce json
// @Param data body users.User true "User data to create a new user"
// @Success 201 {object} users.User "Created user"
// @Failure 400 {object} map[string]string "Bad Request"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users [post]
func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Perm.Admin {
return http.StatusForbidden, nil
}
// Validate the user's scope
_, _, err := files.GetRealPath(config.Server.Root, d.user.Scope)
if err != nil {
return http.StatusBadRequest, err
}
// Read the JSON body
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, err
}
defer r.Body.Close()
// Parse the JSON into the UserRequest struct
var req UserRequest
if err = json.Unmarshal(body, &req); err != nil {
return http.StatusBadRequest, err
}
if len(req.Which) != 0 {
return http.StatusBadRequest, nil
}
@@ -124,84 +182,106 @@ var userPostHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *
return http.StatusBadRequest, errors.ErrEmptyPassword
}
req.Data.Password, err = users.HashPwd(req.Data.Password)
if err != nil {
return http.StatusInternalServerError, err
}
userHome, err := d.settings.MakeUserDir(req.Data.Username, req.Data.Scope, d.server.Root)
if err != nil {
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
return http.StatusInternalServerError, err
}
req.Data.Scope = userHome
log.Printf("user: %s, home dir: [%s].", req.Data.Username, userHome)
err = d.store.Users.Save(req.Data)
err = storage.CreateUser(*req.Data, req.Data.Perm.Admin)
if err != nil {
return http.StatusInternalServerError, err
}
w.Header().Set("Location", "/settings/users/"+strconv.FormatUint(uint64(req.Data.ID), 10))
return http.StatusCreated, nil
})
}
var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
req, err := getUser(w, r)
// userPutHandler updates an existing user's details.
// @Summary Update a user's details
// @Description Updates the details of a user identified by ID.
// @Tags Users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Param data body users.User true "User data to update"
// @Success 200 {object} users.User "Updated user details"
// @Failure 400 {object} map[string]string "Bad Request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users/{id} [put]
func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
givenUserIdString := r.URL.Query().Get("id")
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
givenUserId := uint(num)
if givenUserId != d.user.ID && !d.user.Perm.Admin {
return http.StatusForbidden, nil
}
// Validate the user's scope
_, _, err := files.GetRealPath(config.Server.Root, d.user.Scope)
if err != nil {
return http.StatusBadRequest, err
}
if req.Data.ID != d.raw.(uint) {
return http.StatusBadRequest, nil
// Read the JSON body
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, err
}
defer r.Body.Close()
// Parse the JSON into the UserRequest struct
var req UserRequest
if err = json.Unmarshal(body, &req); err != nil {
return http.StatusBadRequest, err
}
if len(req.Which) == 0 || (len(req.Which) == 1 && req.Which[0] == "all") {
if !d.user.Perm.Admin {
return http.StatusForbidden, nil
}
if req.Data.Password != "" {
req.Data.Password, err = users.HashPwd(req.Data.Password)
} else {
var suser *users.User
suser, err = d.store.Users.Get(d.server.Root, d.raw.(uint))
req.Data.Password = suser.Password
}
if err != nil {
return http.StatusInternalServerError, err
}
// If `Which` is not specified, default to updating all fields
if len(req.Which) == 0 || req.Which[0] == "all" {
req.Which = []string{}
v := reflect.ValueOf(req.Data)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
// Dynamically populate fields to update
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.Name == "Password" && req.Data.Password != "" {
req.Which = append(req.Which, field.Name)
} else if field.Name != "Password" && field.Name != "Fs" {
req.Which = append(req.Which, field.Name)
}
}
}
for k, v := range req.Which {
v = cases.Title(language.English, cases.NoLower).String(v)
req.Which[k] = v
// Process the fields to update
for _, field := range req.Which {
// Title case field names
field = cases.Title(language.English, cases.NoLower).String(field)
if v == "Password" {
// Handle password update
if field == "Password" {
if !d.user.Perm.Admin && d.user.LockPassword {
return http.StatusForbidden, nil
}
req.Data.Password, err = users.HashPwd(req.Data.Password)
if err != nil {
return http.StatusInternalServerError, err
}
}
for _, f := range NonModifiableFieldsForNonAdmin {
if !d.user.Perm.Admin && v == f {
// Prevent non-admins from modifying certain fields
for _, restrictedField := range NonModifiableFieldsForNonAdmin {
if !d.user.Perm.Admin && field == restrictedField {
return http.StatusForbidden, nil
}
}
}
err = d.store.Users.Update(req.Data, req.Which...)
// Perform the user update
err = store.Users.Update(req.Data, req.Which...)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
})
// Return the updated user (with the password hidden) as JSON response
req.Data.Password = ""
return renderJSON(w, r, req.Data)
}
+1 -39
View File
@@ -1,31 +1,13 @@
package http
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"os"
"strings"
libErrors "github.com/gtsteffaniak/filebrowser/errors"
libErrors "github.com/gtsteffaniak/filebrowser/backend/errors"
)
func renderJSON(w http.ResponseWriter, _ *http.Request, data interface{}) (int, error) {
marsh, err := json.Marshal(data)
if err != nil {
return http.StatusInternalServerError, err
}
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 errToStatus(err error) int {
switch {
case err == nil:
@@ -46,23 +28,3 @@ func errToStatus(err error) int {
return http.StatusInternalServerError
}
}
// This is an addaptation if http.StripPrefix in which we don't
// return 404 if the page doesn't have the needed prefix.
func stripPrefix(prefix string, h http.Handler) http.Handler {
if prefix == "" || prefix == "/" {
return h
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, prefix)
rp := strings.TrimPrefix(r.URL.RawPath, prefix)
r2 := new(http.Request)
*r2 = *r
r2.URL = new(url.URL)
*r2.URL = *r.URL
r2.URL.Path = p
r2.URL.RawPath = rp
h.ServeHTTP(w, r2)
})
}
+43 -5
View File
@@ -11,7 +11,6 @@ import (
"github.com/disintegration/imaging"
"github.com/dsoprea/go-exif/v3"
"github.com/marusama/semaphore/v2"
exifcommon "github.com/dsoprea/go-exif/v3/common"
)
@@ -21,12 +20,32 @@ var ErrUnsupportedFormat = errors.New("unsupported image format")
// Service
type Service struct {
sem semaphore.Semaphore
sem chan struct{}
}
// New initializes the service with a specified number of workers (concurrency limit).
func New(workers int) *Service {
return &Service{
sem: semaphore.New(workers),
sem: make(chan struct{}, workers), // Buffered channel to limit concurrency.
}
}
// acquire blocks until a worker is available or the context is canceled.
func (s *Service) acquire(ctx context.Context) error {
select {
case s.sem <- struct{}{}: // Reserve a worker.
return nil
case <-ctx.Done(): // Context canceled or deadline exceeded.
return ctx.Err()
}
}
// release frees up a worker slot.
func (s *Service) release() {
select {
case <-s.sem: // Free a worker slot.
default:
// Shouldn't happen, but guard against releasing more than acquired.
}
}
@@ -136,10 +155,10 @@ func WithQuality(quality Quality) Option {
}
func (s *Service) Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...Option) error {
if err := s.sem.Acquire(ctx, 1); err != nil {
if err := s.acquire(ctx); err != nil {
return err
}
defer s.sem.Release(1)
defer s.release()
format, wrappedReader, err := s.detectFormat(in)
if err != nil {
@@ -242,3 +261,22 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
thm, err := ifd.Thumbnail()
return thm, wrappedReader, err
}
// CreateThumbnail takes raw image data and creates a thumbnail image.
func CreateThumbnail(rawData io.Reader, width, height int) (image.Image, error) {
// Decode the raw image to get an image.Image.
img, _, err := image.Decode(rawData)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
// Resize the image to create a thumbnail using the specified dimensions.
thumb := imaging.Fit(img, width, height, imaging.Lanczos)
// Optionally, convert the thumbnail to grayscale if needed.
// Uncomment the line below if you want the result to be grayscale.
// thumb = imaging.Grayscale(thumb)
// Return the resized thumbnail image.
return thumb, nil
}
+1 -1
View File
@@ -1,7 +1,7 @@
package main
import (
"github.com/gtsteffaniak/filebrowser/cmd"
"github.com/gtsteffaniak/filebrowser/backend/cmd"
)
func main() {
-23
View File
@@ -1,23 +0,0 @@
package rules
import "testing"
func TestMatchHidden(t *testing.T) {
cases := map[string]bool{
"/": false,
"/src": false,
"/src/": false,
"/.circleci": true,
"/a/b/c/.docker.json": true,
".docker.json": true,
"Dockerfile": false,
"/Dockerfile": false,
}
for path, want := range cases {
got := MatchHidden(path)
if got != want {
t.Errorf("MatchHidden(%s)=%v; want %v", path, got, want)
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ checkExit() {
if command -v go &> /dev/null
then
printf "\n == Running tests == \n"
go test -race -v ./...
go test -race -parallel -v ./...
checkExit
else
echo "ERROR: unable to perform tests"
+1 -1
View File
@@ -3,7 +3,7 @@ package runner
import (
"os/exec"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// ParseCommand parses the command taking in account if the current
+8 -7
View File
@@ -7,8 +7,9 @@ import (
"os/exec"
"strings"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// Runner is a commands runner.
@@ -19,8 +20,8 @@ type Runner struct {
// RunHook runs the hooks for the before and after event.
func (r *Runner) RunHook(fn func() error, evt, path, dst string, user *users.User) error {
path = user.FullPath(path)
dst = user.FullPath(dst)
path, _, _ = files.GetRealPath(user.Scope, path)
dst, _, _ = files.GetRealPath(user.Scope, dst)
if r.Enabled {
if val, ok := r.Commands["before_"+evt]; ok {
@@ -101,18 +102,18 @@ func (r *Runner) exec(raw, evt, path, dst string, user *users.User) error {
cmd.Stderr = os.Stderr
if !blocking {
log.Printf("[INFO] Nonblocking Command: \"%s\"", strings.Join(command, " "))
log.Printf("Nonblocking Command: \"%s\"", strings.Join(command, " "))
defer func() {
go func() {
err := cmd.Wait()
if err != nil {
log.Printf("[INFO] Nonblocking Command \"%s\" failed: %s", strings.Join(command, " "), err)
log.Printf("Nonblocking Command \"%s\" failed: %s", strings.Join(command, " "), err)
}
}()
}()
return cmd.Start()
}
log.Printf("[INFO] Blocking Command: \"%s\"", strings.Join(command, " "))
log.Printf("Blocking Command: \"%s\"", strings.Join(command, " "))
return cmd.Run()
}
-122
View File
@@ -1,122 +0,0 @@
package search
import (
"regexp"
"strconv"
"strings"
)
var typeRegexp = regexp.MustCompile(`type:(\S+)`)
var documentTypes = []string{
".word",
".pdf",
".txt",
".doc",
".docx",
}
var compressedFile = []string{
".7z",
".rar",
".zip",
".tar",
".tar.gz",
".tar.xz",
}
type SearchOptions struct {
Conditions map[string]bool
LargerThan int
SmallerThan int
Terms []string
}
func ParseSearch(value string) *SearchOptions {
opts := &SearchOptions{
Conditions: map[string]bool{
"exact": strings.Contains(value, "case:exact"),
},
Terms: []string{},
}
// removes the options from the value
value = strings.Replace(value, "case:exact", "", -1)
value = strings.TrimSpace(value)
types := typeRegexp.FindAllStringSubmatch(value, -1)
for _, filterType := range types {
if len(filterType) == 1 {
continue
}
filter := filterType[1]
switch filter {
case "image":
opts.Conditions["image"] = true
case "audio", "music":
opts.Conditions["audio"] = true
case "video":
opts.Conditions["video"] = true
case "doc":
opts.Conditions["doc"] = true
case "archive":
opts.Conditions["archive"] = true
case "folder":
opts.Conditions["dir"] = true
case "file":
opts.Conditions["dir"] = false
}
if len(filter) < 8 {
continue
}
if strings.HasPrefix(filter, "largerThan=") {
opts.Conditions["larger"] = true
size := strings.TrimPrefix(filter, "largerThan=")
opts.LargerThan = updateSize(size)
}
if strings.HasPrefix(filter, "smallerThan=") {
opts.Conditions["larger"] = true
size := strings.TrimPrefix(filter, "smallerThan=")
opts.SmallerThan = updateSize(size)
}
}
if len(types) > 0 {
// Remove the fields from the search value
value = typeRegexp.ReplaceAllString(value, "")
}
if value == "" {
return opts
}
// if the value starts with " and finishes what that character, we will
// only search for that term
if value[0] == '"' && value[len(value)-1] == '"' {
unique := strings.TrimPrefix(value, "\"")
unique = strings.TrimSuffix(unique, "\"")
opts.Terms = []string{unique}
return opts
}
value = strings.TrimSpace(value)
opts.Terms = strings.Split(value, "|")
return opts
}
func toInt(str string) int {
val, err := strconv.Atoi(str)
if err != nil {
return 0
}
return val
}
func updateSize(given string) int {
size := toInt(given)
if size == 0 {
return 100
} else {
return size
}
}
-279
View File
@@ -1,279 +0,0 @@
package search
import (
"log"
"math/rand"
"mime"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
var (
sessionInProgress sync.Map // Track session with requests in progress
rootPath string = "/srv"
indexes map[string][]string
mutex sync.RWMutex
lastIndexed time.Time
)
func InitializeIndex(intervalMinutes uint32) {
// Initialize the indexes map
indexes = make(map[string][]string)
indexes["dirs"] = []string{}
indexes["files"] = []string{}
var numFiles, numDirs int
log.Println("Indexing files...")
lastIndexedStart := time.Now()
// Call the function to index files and directories
totalNumFiles, totalNumDirs, err := indexFiles(rootPath, &numFiles, &numDirs)
if err != nil {
log.Fatal(err)
}
lastIndexed = lastIndexedStart
go indexingScheduler(intervalMinutes)
log.Println("Successfully indexed files.")
log.Println("Files found :", totalNumFiles)
log.Println("Directories found :", totalNumDirs)
}
func indexingScheduler(intervalMinutes uint32) {
log.Printf("Indexing scheduler will run every %v minutes", intervalMinutes)
for {
time.Sleep(time.Duration(intervalMinutes) * time.Minute)
var numFiles, numDirs int
lastIndexedStart := time.Now()
totalNumFiles, totalNumDirs, err := indexFiles(rootPath, &numFiles, &numDirs)
if err != nil {
log.Fatal(err)
}
lastIndexed = lastIndexedStart
if totalNumFiles+totalNumDirs > 0 {
log.Println("re-indexing found changes and updated the index.")
}
}
}
// Define a function to recursively index files and directories
func indexFiles(path string, numFiles *int, numDirs *int) (int, int, error) {
// Check if the current directory has been modified since last indexing
dir, err := os.Open(path)
if err != nil {
// directory must have been deleted, remove from index
delete(indexes, path)
}
defer dir.Close()
dirInfo, err := dir.Stat()
if err != nil {
return *numFiles, *numDirs, err
}
// Compare the last modified time of the directory with the last indexed time
if dirInfo.ModTime().Before(lastIndexed) {
return *numFiles, *numDirs, nil
}
// Read the directory contents
files, err := dir.Readdir(-1)
if err != nil {
return *numFiles, *numDirs, err
}
// Iterate over the files and directories
for _, file := range files {
if file.IsDir() {
*numDirs++
addToIndex(path, file.Name(), true)
indexFiles(path+"/"+file.Name(), numFiles, numDirs) // recursive
} else {
*numFiles++
addToIndex(path, file.Name(), false)
}
}
return *numFiles, *numDirs, nil
}
func addToIndex(path string, fileName string, isDir bool) {
mutex.Lock()
defer mutex.Unlock()
path = strings.TrimPrefix(path, rootPath+"/")
path = strings.TrimSuffix(path, "/")
adjustedPath := path + "/" + fileName
if path == rootPath {
adjustedPath = fileName
}
if isDir {
indexes["dirs"] = append(indexes["dirs"], adjustedPath)
} else {
indexes["files"] = append(indexes["files"], adjustedPath)
}
}
func SearchAllIndexes(search string, scope string, sourceSession string) ([]string, map[string]map[string]bool) {
runningHash := generateRandomHash(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := ParseSearch(search)
mutex.RLock()
defer mutex.RUnlock()
fileListTypes := make(map[string]map[string]bool)
var matching []string
maximum := 100
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" {
continue
}
// Iterate over the indexes
for _, i := range []string{"dirs", "files"} {
isdir := i == "dirs"
count := 0
for _, path := range indexes[i] {
value, found := sessionInProgress.Load(sourceSession)
if !found || value != runningHash {
return []string{}, map[string]map[string]bool{}
}
if count > maximum {
break
}
pathName := scopedPathNameFilter(path, scope)
if pathName == "" {
continue
}
matches, fileType := containsSearchTerm(path, searchTerm, *searchOptions, isdir)
if !matches {
continue
}
if isdir {
pathName = pathName + "/"
}
matching = append(matching, pathName)
fileListTypes[pathName] = fileType
count++
}
}
}
// 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], "/")
return len(parts1) < len(parts2)
})
return matching, fileListTypes
}
func scopedPathNameFilter(pathName string, scope string) string {
scope = strings.TrimPrefix(scope, "/")
if strings.HasPrefix(pathName, scope) {
pathName = strings.TrimPrefix(pathName, scope)
} else {
pathName = ""
}
return pathName
}
func containsSearchTerm(pathName string, searchTerm string, options SearchOptions, isDir bool) (bool, map[string]bool) {
conditions := options.Conditions
path := getLastPathComponent(pathName)
// Convert to lowercase once
lowerSearchTerm := searchTerm
if !conditions["exact"] {
path = strings.ToLower(path)
lowerSearchTerm = strings.ToLower(searchTerm)
}
if strings.Contains(path, lowerSearchTerm) {
// Reuse the fileTypes map and clear its values
fileTypes := map[string]bool{
"audio": false,
"image": false,
"video": false,
"doc": false,
"archive": false,
"dir": false,
}
// Calculate fileSize only if needed
var fileSize int64
if conditions["larger"] || conditions["smaller"] {
fileSize = getFileSize(pathName)
}
matchesAllConditions := true
extension := filepath.Ext(path)
mimetype := mime.TypeByExtension(extension)
fileTypes["audio"] = strings.HasPrefix(mimetype, "audio")
fileTypes["image"] = strings.HasPrefix(mimetype, "image")
fileTypes["video"] = strings.HasPrefix(mimetype, "video")
fileTypes["doc"] = isDoc(extension)
fileTypes["archive"] = isArchive(extension)
fileTypes["dir"] = isDir
for t, v := range conditions {
if t == "exact" {
continue
}
var matchesCondition bool
switch t {
case "larger":
matchesCondition = fileSize > int64(options.LargerThan)*1000000
case "smaller":
matchesCondition = fileSize < int64(options.SmallerThan)*1000000
default:
matchesCondition = v == fileTypes[t]
}
if !matchesCondition {
matchesAllConditions = false
}
}
return matchesAllConditions, fileTypes
}
// Clear variables and return
return false, map[string]bool{}
}
func isDoc(extension string) bool {
for _, typefile := range documentTypes {
if extension == typefile {
return true
}
}
return false
}
func getFileSize(filepath string) int64 {
fileInfo, err := os.Stat(rootPath + "/" + filepath)
if err != nil {
return 0
}
return fileInfo.Size()
}
func isArchive(extension string) bool {
for _, typefile := range compressedFile {
if extension == typefile {
return true
}
}
return false
}
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.Seed(rand.Int63()) // Automatically seeded based on current time
result := make([]byte, length)
for i := range result {
result[i] = charset[rand.Intn(len(charset))]
}
return string(result)
}
func stringExistsInArray(target string, strings []string) bool {
for _, s := range strings {
if s == target {
return true
}
}
return false
}
-170
View File
@@ -1,170 +0,0 @@
package search
import (
"encoding/json"
"fmt"
"math/rand"
"reflect"
"testing"
"time"
)
// 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,
},
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,
},
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,
},
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,
},
Terms: []string{"thisfile"},
}
if !reflect.DeepEqual(value, want) {
t.Fatalf("\n got: %+v\n want: %+v", value, want)
}
}
func BenchmarkSearchAllIndexes(b *testing.B) {
indexes = make(map[string][]string)
// Create mock data
createMockData(50, 3) // 1000 dirs, 3 files per dir
// Generate 100 random search terms
searchTerms := generateRandomSearchTerms(100)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Execute the SearchAllIndexes function
for _, term := range searchTerms {
SearchAllIndexes(term, "/", "test")
}
}
}
func BenchmarkFillIndex(b *testing.B) {
indexes = make(map[string][]string)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
createMockData(50, 3) // 1000 dirs, 3 files per dir
}
}
func createMockData(numDirs, numFilesPerDir int) {
for i := 0; i < numDirs; i++ {
dirName := generateRandomPath(rand.Intn(3) + 1)
addToIndex("/", dirName, true)
for j := 0; j < numFilesPerDir; j++ {
fileName := "file-" + getRandomTerm() + getRandomExtension()
addToIndex("/"+dirName, fileName, false)
}
}
}
func generateRandomPath(levels int) string {
rand.Seed(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.Seed(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.Seed(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.
func JSONBytesEqual(a, b []byte) (bool, error) {
var j, j2 interface{}
if err := json.Unmarshal(a, &j); err != nil {
return false, err
}
if err := json.Unmarshal(b, &j2); err != nil {
return false, err
}
return reflect.DeepEqual(j2, j), nil
}
func passedFunc(t *testing.T) {
t.Logf("%s passed!", t.Name())
}
func formatDuration(duration time.Duration) string {
if duration >= time.Second {
return fmt.Sprintf("%.2f seconds", duration.Seconds())
} else if duration >= time.Millisecond {
return fmt.Sprintf("%.2f ms", float64(duration.Milliseconds()))
}
return fmt.Sprintf("%.2f ns", float64(duration.Nanoseconds()))
}
func formatMemory(bytes int64) string {
sizes := []string{"B", "KB", "MB", "GB", "TB"}
i := 0
for bytes >= 1024 && i < len(sizes)-1 {
bytes /= 1024
i++
}
return fmt.Sprintf("%d %s", bytes, sizes[i])
}
+50 -25
View File
@@ -3,37 +3,53 @@ package settings
import (
"log"
"os"
"path/filepath"
"strings"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
var GlobalConfiguration Settings
var Config Settings
func Initialize(configFile string) {
yamlData := loadConfigFile(configFile)
GlobalConfiguration = setDefaults()
err := yaml.Unmarshal(yamlData, &GlobalConfiguration)
Config = setDefaults()
err := yaml.Unmarshal(yamlData, &Config)
if err != nil {
log.Fatalf("Error unmarshaling YAML data: %v", err)
}
GlobalConfiguration.UserDefaults.Perm = GlobalConfiguration.UserDefaults.Permissions
GlobalConfiguration.Server.Root = "/srv" // hardcoded for now. TODO allow changing
Config.UserDefaults.Perm = Config.UserDefaults.Permissions
// Convert relative path to absolute path
realRoot, err := filepath.Abs(Config.Server.Root)
if err != nil {
log.Fatalf("Error getting root path: %v", err)
}
_, err = os.Stat(realRoot)
if err != nil {
log.Fatalf("ERROR: Configured Root Path does not exist! %v", err)
}
Config.Server.Root = realRoot
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Server.BaseURL = "/"
} else {
Config.Server.BaseURL = "/" + baseurl + "/"
}
}
func loadConfigFile(configFile string) []byte {
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
log.Printf("ERROR: opening config file\n %v\n WARNING: Using default config only\n If this was a mistake, please make sure the file exists and is accessible by the filebrowser binary.\n\n", err)
setDefaults()
return []byte{}
log.Println(err)
os.Exit(1)
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
if err != nil {
log.Fatalf("Error getting file information: %s", err.Error())
log.Fatalf("error getting file information: %s", err.Error())
}
yamlData := make([]byte, stat.Size())
@@ -46,37 +62,46 @@ func loadConfigFile(configFile string) []byte {
func setDefaults() Settings {
return Settings{
Signup: true,
AdminUsername: "admin",
AdminPassword: "admin",
Server: Server{
EnableThumbnails: true,
ResizePreview: false,
EnableExec: false,
IndexingInterval: 5,
Port: 8080,
Port: 80,
NumImageProcessors: 4,
BaseURL: "",
Database: "database.db",
Log: "stdout",
Root: "/srv",
Indexing: true,
},
Auth: Auth{
Method: "password",
TokenExpirationTime: "2h",
AdminUsername: "admin",
AdminPassword: "admin",
Method: "password",
Signup: false,
Recaptcha: Recaptcha{
Host: "",
},
},
UserDefaults: UserDefaults{
Scope: ".",
LockPassword: false,
HideDotfiles: true,
StickySidebar: true,
Scope: ".",
LockPassword: false,
HideDotfiles: true,
DarkMode: false,
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
Permissions: users.Permissions{
Create: true,
Rename: true,
Modify: true,
Delete: true,
Share: true,
Download: true,
Create: false,
Rename: false,
Modify: false,
Delete: false,
Share: false,
Download: false,
Admin: false,
Api: false,
},
},
}
+59
View File
@@ -0,0 +1,59 @@
package settings
import (
"reflect"
"testing"
)
func TestInitialize(t *testing.T) {
type args struct {
configFile string
}
tests := []struct {
name string
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Initialize(tt.args.configFile)
})
}
}
func Test_loadConfigFile(t *testing.T) {
type args struct {
configFile string
}
tests := []struct {
name string
args args
want []byte
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := loadConfigFile(tt.args.configFile); !reflect.DeepEqual(got, tt.want) {
t.Errorf("loadConfigFile() = %v, want %v", got, tt.want)
}
})
}
}
func Test_setDefaults(t *testing.T) {
tests := []struct {
name string
want Settings
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setDefaults(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
}
})
}
}
+6 -8
View File
@@ -6,34 +6,32 @@ import (
"log"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/spf13/afero"
)
var (
invalidFilenameChars = regexp.MustCompile(`[^0-9A-Za-z@_\-.]`)
dashes = regexp.MustCompile(`[\-]+`)
dashes = regexp.MustCompile(`[\-]+`)
)
// MakeUserDir makes the user directory according to settings.
func (s *Settings) MakeUserDir(username, userScope, serverRoot string) (string, error) {
userScope = strings.TrimSpace(userScope)
if userScope == "" && s.CreateUserDir {
if userScope == "" && s.Server.CreateUserDir {
username = cleanUsername(username)
if username == "" || username == "-" || username == "." {
log.Printf("create user: invalid user for home dir creation: [%s]", username)
return "", errors.New("invalid user for home dir creation")
}
userScope = path.Join(s.UserHomeBasePath, username)
userScope = path.Join(s.Server.UserHomeBasePath, username)
}
userScope = path.Join("/", userScope)
fs := afero.NewBasePathFs(afero.NewOsFs(), serverRoot)
if err := fs.MkdirAll(userScope, os.ModePerm); err != nil {
fullPath := filepath.Join(serverRoot, userScope)
if err := os.MkdirAll(fullPath, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create user home dir: [%s]: %w", userScope, err)
}
return userScope, nil
+81
View File
@@ -0,0 +1,81 @@
package settings
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
func TestSettings_MakeUserDir(t *testing.T) {
type fields struct {
Signup bool
CreateUserDir bool
UserHomeBasePath string
Commands map[string][]string
Shell []string
AdminUsername string
AdminPassword string
Rules []users.Rule
Server Server
Auth Auth
Frontend Frontend
Users []UserDefaults
UserDefaults UserDefaults
}
type args struct {
username string
userScope string
serverRoot string
}
tests := []struct {
name string
fields fields
args args
want string
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &Settings{
Commands: tt.fields.Commands,
Shell: tt.fields.Shell,
Rules: tt.fields.Rules,
Server: tt.fields.Server,
Auth: tt.fields.Auth,
Frontend: tt.fields.Frontend,
Users: tt.fields.Users,
UserDefaults: tt.fields.UserDefaults,
}
got, err := s.MakeUserDir(tt.args.username, tt.args.userScope, tt.args.serverRoot)
if (err != nil) != tt.wantErr {
t.Errorf("Settings.MakeUserDir() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Settings.MakeUserDir() = %v, want %v", got, tt.want)
}
})
}
}
func Test_cleanUsername(t *testing.T) {
type args struct {
s 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 := cleanUsername(tt.args.s); got != tt.want {
t.Errorf("cleanUsername() = %v, want %v", got, tt.want)
}
})
}
}
+31 -14
View File
@@ -2,9 +2,8 @@ package settings
import (
"crypto/rand"
"strings"
"github.com/gtsteffaniak/filebrowser/rules"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
const DefaultUsersHomeBasePath = "/users"
@@ -12,18 +11,6 @@ const DefaultUsersHomeBasePath = "/users"
// AuthMethod describes an authentication method.
type AuthMethod string
// Settings contain the main settings of the application.
// GetRules implements rules.Provider.
func (s *Settings) GetRules() []rules.Rule {
return s.Rules
}
// Server specific settings
// Clean cleans any variables that might need cleaning.
func (s *Server) Clean() {
s.BaseURL = strings.TrimSuffix(s.BaseURL, "/")
}
// GenerateKey generates a key of 512 bits.
func GenerateKey() ([]byte, error) {
b := make([]byte, 64) //nolint:gomnd
@@ -39,3 +26,33 @@ func GenerateKey() ([]byte, error) {
func GetSettingsConfig(nameType string, Value string) string {
return nameType + Value
}
func AdminPerms() users.Permissions {
return users.Permissions{
Create: true,
Rename: true,
Modify: true,
Delete: true,
Share: true,
Download: true,
Admin: true,
Api: true,
}
}
// Apply applies the default options to a user.
func ApplyUserDefaults(u users.User) users.User {
u.StickySidebar = Config.UserDefaults.StickySidebar
u.DisableSettings = Config.UserDefaults.DisableSettings
u.DarkMode = Config.UserDefaults.DarkMode
u.Scope = Config.UserDefaults.Scope
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
u.Perm = Config.UserDefaults.Perm
u.Sorting = Config.UserDefaults.Sorting
u.Commands = Config.UserDefaults.Commands
u.HideDotfiles = Config.UserDefaults.HideDotfiles
u.DateFormat = Config.UserDefaults.DateFormat
return u
}
+18 -8
View File
@@ -12,14 +12,14 @@ func TestConfigLoadChanged(t *testing.T) {
yamlData := loadConfigFile("./testingConfig.yaml")
// Marshal the YAML data to a more human-readable format
newConfig := setDefaults()
GlobalConfiguration := setDefaults()
Config := setDefaults()
err := yaml.Unmarshal(yamlData, &newConfig)
if err != nil {
log.Fatalf("Error unmarshaling YAML data: %v", err)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(newConfig, GlobalConfiguration); diff == "" {
if diff := cmp.Diff(newConfig, Config); diff == "" {
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
}
}
@@ -28,17 +28,27 @@ func TestConfigLoadSpecificValues(t *testing.T) {
yamlData := loadConfigFile("./testingConfig.yaml")
// Marshal the YAML data to a more human-readable format
newConfig := setDefaults()
GlobalConfiguration := setDefaults()
Config := setDefaults()
err := yaml.Unmarshal(yamlData, &newConfig)
if err != nil {
log.Fatalf("Error unmarshaling YAML data: %v", err)
}
if GlobalConfiguration.Auth.Method == newConfig.Auth.Method {
log.Fatalf("Differences should have been found, but were not on Auth method")
testCases := []struct {
fieldName string
globalVal interface{}
newVal interface{}
}{
{"Auth.Method", Config.Auth.Method, newConfig.Auth.Method},
{"Auth.Method", Config.Auth.Method, newConfig.Auth.Method},
{"Frontend.disableExternal", Config.Frontend.DisableExternal, newConfig.Frontend.DisableExternal},
{"UserDefaults.HideDotfiles", Config.UserDefaults.HideDotfiles, newConfig.UserDefaults.HideDotfiles},
{"Server.Database", Config.Server.Database, newConfig.Server.Database},
}
if GlobalConfiguration.UserDefaults.HideDotfiles == newConfig.UserDefaults.HideDotfiles {
log.Fatalf("Differences should have been found, but were not on Auth method")
for _, tc := range testCases {
if tc.globalVal == tc.newVal {
t.Errorf("Differences should have been found:\n\tConfig.%s: %v \n\tSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
}
}
}
+7 -13
View File
@@ -1,9 +1,8 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/rules"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// StorageBackend is a settings storage backend.
@@ -30,8 +29,8 @@ func (s *Storage) Get() (*Settings, error) {
if err != nil {
return nil, err
}
if set.UserHomeBasePath == "" {
set.UserHomeBasePath = DefaultUsersHomeBasePath
if set.Server.UserHomeBasePath == "" {
set.Server.UserHomeBasePath = DefaultUsersHomeBasePath
}
return set, nil
}
@@ -46,7 +45,7 @@ var defaultEvents = []string{
// Save saves the settings for the current instance.
func (s *Storage) Save(set *Settings) error {
if len(set.Key) == 0 {
if len(set.Auth.Key) == 0 {
return errors.ErrEmptyKey
}
@@ -59,15 +58,11 @@ func (s *Storage) Save(set *Settings) error {
}
if set.UserDefaults.ViewMode == "" {
set.UserDefaults.ViewMode = users.MosaicViewMode
set.UserDefaults.ViewMode = "normal"
}
if set.Rules == nil {
set.Rules = []rules.Rule{}
}
if set.Shell == nil {
set.Shell = []string{}
set.Rules = []users.Rule{}
}
if set.Commands == nil {
@@ -99,6 +94,5 @@ func (s *Storage) GetServer() (*Server, error) {
// SaveServer wraps StorageBackend.SaveServer and adds some verification.
func (s *Storage) SaveServer(ser *Server) error {
ser.Clean()
return s.back.SaveServer(ser)
}
+34 -42
View File
@@ -1,46 +1,31 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/rules"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// Apply applies the default options to a user.
func (d *UserDefaults) Apply(u *users.User) {
u.Scope = d.Scope
u.Locale = d.Locale
u.ViewMode = d.ViewMode
u.SingleClick = d.SingleClick
u.Perm = d.Perm
u.Sorting = d.Sorting
u.Commands = d.Commands
u.HideDotfiles = d.HideDotfiles
u.DateFormat = d.DateFormat
}
type Settings struct {
Key []byte `json:"key"`
Signup bool `json:"signup"`
CreateUserDir bool `json:"createUserDir"`
UserHomeBasePath string `json:"userHomeBasePath"`
Commands map[string][]string `json:"commands"`
Shell []string `json:"shell"`
AdminUsername string `json:"adminUsername"`
AdminPassword string `json:"adminPassword"`
Rules []rules.Rule `json:"rules"`
Server Server `json:"server"`
Auth Auth `json:"auth"`
Frontend Frontend `json:"frontend"`
UserDefaults UserDefaults `json:"userDefaults"`
Commands map[string][]string `json:"commands"`
Shell []string `json:"shell"`
Rules []users.Rule `json:"rules"`
Server Server `json:"server"`
Auth Auth `json:"auth"`
Frontend Frontend `json:"frontend"`
Users []UserDefaults `json:"users,omitempty"`
UserDefaults UserDefaults `json:"userDefaults"`
}
type Auth struct {
Recaptcha Recaptcha `json:"recaptcha"`
Header string `json:"header"`
Method string `json:"method"`
Command string `json:"command"`
Signup bool `json:"signup"`
Shell string `json:"shell"`
TokenExpirationTime string `json:"tokenExpirationTime"`
Recaptcha Recaptcha `json:"recaptcha"`
Header string `json:"header"`
Method string `json:"method"`
Command string `json:"command"`
Signup bool `json:"signup"`
Shell string `json:"shell"`
AdminUsername string `json:"adminUsername"`
AdminPassword string `json:"adminPassword"`
Key []byte `json:"key"`
}
type Recaptcha struct {
@@ -66,6 +51,9 @@ type Server struct {
Log string `json:"log"`
Database string `json:"database"`
Root string `json:"root"`
UserHomeBasePath string `json:"userHomeBasePath"`
CreateUserDir bool `json:"createUserDir"`
Indexing bool `json:"indexing"`
}
type Frontend struct {
@@ -73,25 +61,29 @@ type Frontend struct {
DisableExternal bool `json:"disableExternal"`
DisableUsedPercentage bool `json:"disableUsedPercentage"`
Files string `json:"files"`
Theme string `json:"theme"`
Color string `json:"color"`
}
// UserDefaults is a type that holds the default values
// for some fields on User.
type UserDefaults struct {
LockPassword bool `json:"lockPassword"`
Scope string `json:"scope"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Sorting struct {
StickySidebar bool `json:"stickySidebar"`
DarkMode bool `json:"darkMode"`
LockPassword bool `json:"lockPassword"`
DisableSettings bool `json:"disableSettings,omitempty"`
Scope string `json:"scope"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
GallerySize int `json:"gallerySize"`
SingleClick bool `json:"singleClick"`
Rules []users.Rule `json:"rules"`
Sorting struct {
By string `json:"by"`
Asc bool `json:"asc"`
} `json:"sorting"`
Perm users.Permissions `json:"perm"`
Permissions users.Permissions `json:"permissions"`
Commands []string `json:"commands"`
Commands []string `json:"commands,omitempty"`
HideDotfiles bool `json:"hideDotfiles"`
DateFormat bool `json:"dateFormat"`
}
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff. Show more