Compare commits

..

No commits in common. "master" and "v2.34.1" have entirely different histories.

283 changed files with 9921 additions and 11384 deletions

6
.github/CODEOWNERS vendored
View File

@ -1 +1,5 @@
* @filebrowser/maintainers # 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 @hacdias

View File

@ -1,6 +1,6 @@
name: Bug Report name: Bug Report
description: Report a bug in FileBrowser. description: Report a bug in FileBrowser.
labels: [bug, 'waiting: triage'] labels: [bug, triage]
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
@ -20,32 +20,22 @@ body:
render: Text render: Text
description: | description: |
Enter the version of FileBrowser you are using. Enter the version of FileBrowser you are using.
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Description label: Description
description: | description: |
A clear and concise description of what the issue is about. What are you trying to do? A clear and concise description of what the issue is about. What are you trying to do?
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: What did you expect to happen? label: What did you expect to happen?
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: What actually happened? label: What actually happened?
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Reproduction Steps label: Reproduction Steps
description: | description: |
Tell us how to reproduce this issue. How can someone who is starting from scratch reproduce this behavior as minimally as possible? Tell us how to reproduce this issue. How can someone who is starting from scratch reproduce this behavior as minimally as possible?
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Files label: Files

View File

@ -1,115 +0,0 @@
name: Continuous Integration
on:
push:
branches:
- "master"
tags:
- "v*"
pull_request:
jobs:
lint-frontend:
name: Lint Frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v6
with:
node-version: "24.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- working-directory: frontend
run: |
pnpm install --frozen-lockfile
pnpm run lint
lint-backend:
name: Lint Backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: "1.25.x"
- uses: golangci/golangci-lint-action@v9
with:
version: "latest"
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: "1.25.x"
- run: go test --race ./...
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.25'
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v6
with:
node-version: "24.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- name: Install Task
uses: go-task/setup-task@v1
- run: task build
release:
name: Release
needs: ["lint-frontend", "lint-backend", "test", "build"]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.25'
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v6
with:
node-version: "24.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install Task
uses: go-task/setup-task@v1
- run: task build:frontend
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}

View File

@ -1,52 +0,0 @@
name: Docs
on:
pull_request:
paths:
- 'www'
- '*.md'
push:
branches:
- master
jobs:
build:
name: Build Docs
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install Task
uses: go-task/setup-task@v1
- name: Build site
run: task docs
build-and-release:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
name: Build and Release Docs
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install Task
uses: go-task/setup-task@v1
- name: Build site
run: task docs
- name: Upload static files as artifact
uses: actions/upload-pages-artifact@v4
with:
path: www/public
- name: Deploy to GitHub Pages
uses: actions/deploy-pages@v4

105
.github/workflows/main.yaml vendored Normal file
View File

@ -0,0 +1,105 @@
name: main
on:
push:
branches:
- "master"
tags:
- "v*"
pull_request:
jobs:
# linters
lint-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- run: make lint-frontend
lint-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 1.23.0
- run: make lint-backend
lint:
runs-on: ubuntu-latest
needs: [lint-frontend, lint-backend]
steps:
- run: echo "done"
# tests
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- run: make test-frontend
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 1.23.0
- run: make test-backend
test:
runs-on: ubuntu-latest
needs: [test-frontend, test-backend]
steps:
- run: echo "done"
# release
release:
runs-on: ubuntu-latest
needs: [lint, test]
if: startsWith(github.event.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: 1.23.0
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build frontend
run: make build-frontend
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}

View File

@ -13,10 +13,10 @@ permissions:
jobs: jobs:
main: main:
name: Validate Title name: Validate PR title
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: amannn/action-semantic-pull-request@v6 - uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title id: lint_pr_title
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -43,4 +43,4 @@ jobs:
uses: marocchino/sticky-pull-request-comment@v2 uses: marocchino/sticky-pull-request-comment@v2
with: with:
header: pr-title-lint-error header: pr-title-lint-error
delete: true delete: true

20
.github/workflows/site-pr.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Build Site
on:
pull_request:
paths:
- 'www'
- '*.md'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build site
run: make site

32
.github/workflows/site-publish.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: Build and Deploy Site
on:
push:
branches:
- master
jobs:
deploy:
permissions:
contents: read
deployments: write
pull-requests: write
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build site
run: make site
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy www/public --project-name=${{ secrets.CLOUDFLARE_PROJECT_NAME }}
gitHubToken: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored
View File

@ -35,5 +35,10 @@ build/
/frontend/dist/* /frontend/dist/*
!/frontend/dist/.gitkeep !/frontend/dist/.gitkeep
# Playwright files
/frontend/test-results/
/frontend/playwright-report/
/frontend/playwright/.cache/
default.nix default.nix
Dockerfile.dev Dockerfile.dev

View File

@ -1,14 +1,132 @@
version: "2" version: "2"
linters: linters:
default: standard # inverted configuration with `default: all` and `disable` is not scalable during updates of golangci-lint
default: none
enable: enable:
- bodyclose
- dogsled
- dupl
- errcheck
- errorlint
- exhaustive
- funlen
- gocheckcompilerdirectives
- gochecknoinits
- gocritic - gocritic
- gocyclo
- godox
- goprintffuncname
- gosec
- govet - govet
- ineffassign
- lll
- misspell
- mnd
- nakedret
- nolintlint
- prealloc
- revive - revive
- rowserrcheck
- staticcheck
- testifylint
- unconvert
- unparam
- unused
- whitespace
settings:
dupl:
threshold: 100
exhaustive:
default-signifies-exhaustive: false
funlen:
lines: 100
statements: 50
gocritic:
disabled-checks:
- dupImport # https://github.com/go-critic/go-critic/issues/845
- ifElseChain
- octalLiteral
- whyNoLint
- wrapperFunc
enabled-tags:
- diagnostic
- experimental
- opinionated
- performance
- style
gocyclo:
min-complexity: 15
govet:
enable:
- nilness
- shadow
lll:
line-length: 140
misspell:
locale: US
mnd:
# don't include the "operation" and "assign"
checks:
- argument
- case
- condition
- return
ignored-numbers:
- "0"
- "1"
- "2"
- "3"
- "0666"
- "0700"
- "0700"
ignored-functions:
- strings.SplitN
- make
nolintlint:
allow-unused: false # report any unused nolint directives
require-explanation: false # require an explanation for nolint directives
require-specific: true # require nolint directives to be specific about which linter is being skipped
staticcheck:
checks:
- "all"
- "-QF*"
exclusions: exclusions:
generated: lax
presets: presets:
- std-error-handling
- comments - comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- gochecknoinits
path: cmd/.*.go
- linters:
- dupl
- funlen
- gochecknoinits
- gocyclo
- lll
- scopelint
path: .*_test.go
- linters:
- misspell
text: "[aA]uther"
- linters:
- mnd
text: strconv.Parse
paths:
- frontend/
formatters:
enable:
- goimports
settings:
goimports:
local-prefixes:
- github.com/filebrowser/filebrowser
exclusions:
generated: lax
paths: paths:
- frontend/ - frontend/

View File

@ -131,7 +131,7 @@ dockers:
- "filebrowser/filebrowser:v{{ .Major }}-amd64-s6" - "filebrowser/filebrowser:v{{ .Major }}-amd64-s6"
extra_files: extra_files:
- docker - docker
- dockerfile: Dockerfile.s6 - dockerfile: Dockerfile.s6.aarch64
use: buildx use: buildx
build_flag_templates: build_flag_templates:
- "--pull" - "--pull"

View File

@ -1,536 +1,6 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. 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.
## [2.54.0](https://github.com/filebrowser/filebrowser/compare/v2.53.1...v2.54.0) (2026-01-10)
### Features
* add "redirect after copy/move" user setting ([#5662](https://github.com/filebrowser/filebrowser/issues/5662)) ([fda8a99](https://github.com/filebrowser/filebrowser/commit/fda8a992929b1466e75fb2813f2c4e293c12d244))
* force file sync while uploading file ([#5668](https://github.com/filebrowser/filebrowser/issues/5668)) ([4fd18a3](https://github.com/filebrowser/filebrowser/commit/4fd18a382c31bbe7059d6733ffa371e70051865b))
* update translations ([#5659](https://github.com/filebrowser/filebrowser/issues/5659)) ([464b581](https://github.com/filebrowser/filebrowser/commit/464b581953139c17e3276b774e381e4052827125))
### Bug Fixes
* clear selection by clicking on empty area ([#5663](https://github.com/filebrowser/filebrowser/issues/5663)) ([208535a](https://github.com/filebrowser/filebrowser/commit/208535a8cc23254de0013dfab9008486707ee6c2))
* hide "change password form" in noauth setting ([#5652](https://github.com/filebrowser/filebrowser/issues/5652)) ([219582c](https://github.com/filebrowser/filebrowser/commit/219582c0b03fd90979b1d1398dba7919d086a23f))
## [2.53.1](https://github.com/filebrowser/filebrowser/compare/v2.53.0...v2.53.1) (2026-01-03)
### Bug Fixes
* download path encoding file paths ([#5655](https://github.com/filebrowser/filebrowser/issues/5655)) ([ffa893e](https://github.com/filebrowser/filebrowser/commit/ffa893e9ac387a49dba5917a41df7c3b7ce120fc))
* request a password to change sensitive user data ([#5629](https://github.com/filebrowser/filebrowser/issues/5629)) ([b8151a0](https://github.com/filebrowser/filebrowser/commit/b8151a038a1ea55afae8073b439b74e364cac12f))
## [2.53.0](https://github.com/filebrowser/filebrowser/compare/v2.52.0...v2.53.0) (2025-12-29)
### Features
* add "disable image resolution calculation" flag ([#5638](https://github.com/filebrowser/filebrowser/issues/5638)) ([a2d80c6](https://github.com/filebrowser/filebrowser/commit/a2d80c62c1c17962e566f68fb7cac6960ed3e4cb))
* support streaming response for search results ([#5630](https://github.com/filebrowser/filebrowser/issues/5630)) ([20bfd13](https://github.com/filebrowser/filebrowser/commit/20bfd131c6a4fca48a645b52171c2d1cc3ce92b7))
* update translations ([a12a612](https://github.com/filebrowser/filebrowser/commit/a12a612970d6cc3dfbca1b35ef3a60a887a4effb))
* update translations ([#5626](https://github.com/filebrowser/filebrowser/issues/5626)) ([f899756](https://github.com/filebrowser/filebrowser/commit/f89975603e29b9f1fc05aec58afb42bbd56ed696))
* update translations ([#5631](https://github.com/filebrowser/filebrowser/issues/5631)) ([032d6c7](https://github.com/filebrowser/filebrowser/commit/032d6c7520a64686c9d9b1218562256f629b4703))
### Bug Fixes
* conversion of backslashes in file paths for archive creation ([#5637](https://github.com/filebrowser/filebrowser/issues/5637)) ([9595f39](https://github.com/filebrowser/filebrowser/commit/9595f3939c1c129ed875a47adcc4fbcfad9a0e65))
* Don't crash on invalid config import ([#5640](https://github.com/filebrowser/filebrowser/issues/5640)) ([79d1aa9](https://github.com/filebrowser/filebrowser/commit/79d1aa9229b076ee8e3b71d6cf061fc90738f4da))
* fix nil deref in config set command ([#5641](https://github.com/filebrowser/filebrowser/issues/5641)) ([60b1ee8](https://github.com/filebrowser/filebrowser/commit/60b1ee8bb9e18b21d7f2c04cb1cc90046cecd3e1))
## [2.52.0](https://github.com/filebrowser/filebrowser/compare/v2.51.2...v2.52.0) (2025-12-13)
### Features
* sync translations with Transifex ([7fa3432](https://github.com/filebrowser/filebrowser/commit/7fa3432f25610bbb55a718bc709b9a7bf41d92f0))
* update translations ([#5615](https://github.com/filebrowser/filebrowser/issues/5615)) ([3fdca6d](https://github.com/filebrowser/filebrowser/commit/3fdca6dfd9a18c3f4895b4ef3cbd216824dbb57a))
### Bug Fixes
* display the directory name in the shared folder view ([#5617](https://github.com/filebrowser/filebrowser/issues/5617)) ([6d4c867](https://github.com/filebrowser/filebrowser/commit/6d4c86767239dad4f09f30f48678f2f3a716eb12))
* hide the context menu when changing the route ([#5613](https://github.com/filebrowser/filebrowser/issues/5613)) ([cf96657](https://github.com/filebrowser/filebrowser/commit/cf966578d8c6beab111b74f495bac6bdec173f41))
## [2.51.2](https://github.com/filebrowser/filebrowser/compare/v2.51.1...v2.51.2) (2025-12-07)
### Bug Fixes
* **frontend:** add missing i18n strings ([c171599](https://github.com/filebrowser/filebrowser/commit/c1715992bda46517f801c1aa496df8a3b42a4e4d))
## [2.51.1](https://github.com/filebrowser/filebrowser/compare/v2.51.0...v2.51.1) (2025-12-07)
### Bug Fixes
* **frontend:** csv viewer i18n strings ([4cbb4b7](https://github.com/filebrowser/filebrowser/commit/4cbb4b73af816104475f15c1d996640b56203602))
* prevent the right-click from selecting multiple items when the "single-click" option is active ([#5608](https://github.com/filebrowser/filebrowser/issues/5608)) ([152f830](https://github.com/filebrowser/filebrowser/commit/152f8302f7cda21bde37692b175c22c124233f45))
## [2.51.0](https://github.com/filebrowser/filebrowser/compare/v2.50.0...v2.51.0) (2025-12-06)
### Features
* update translations ([2d88c06](https://github.com/filebrowser/filebrowser/commit/2d88c067611e936056dbbf04247f1c1c709b2a09))
### Bug Fixes
* added column separator select (comma, semicolon and both) in CSV viewer ([#5604](https://github.com/filebrowser/filebrowser/issues/5604)) ([204a3f0](https://github.com/filebrowser/filebrowser/commit/204a3f0eeaa0c68781b60651bf27c4b27eac44e6))
### Refactorings
* cleanup package names ([#5605](https://github.com/filebrowser/filebrowser/issues/5605)) ([f029c30](https://github.com/filebrowser/filebrowser/commit/f029c3005e450cfbebb074c42dbdf65db9c8d56a))
## [2.50.0](https://github.com/filebrowser/filebrowser/compare/v2.49.0...v2.50.0) (2025-11-30)
### Features
* configurable logout page URL for proxy/hook auth ([#3884](https://github.com/filebrowser/filebrowser/issues/3884)) ([b9ac45d](https://github.com/filebrowser/filebrowser/commit/b9ac45d5dac4b4eb2ba364629090fbf306cffd2b))
* render CSVs as table ([#5569](https://github.com/filebrowser/filebrowser/issues/5569)) ([982405e](https://github.com/filebrowser/filebrowser/commit/982405ec944f94baf43594b0ed2f06329ff4e9ed))
* update frontend/src/i18n/hr.json ([279a5cc](https://github.com/filebrowser/filebrowser/commit/279a5ccd1e8d7bde4568b63cb3c506af48b6c618))
* update translations ([78e0395](https://github.com/filebrowser/filebrowser/commit/78e039596070a3a9e643a693cc99960c69dcfe92))
### Bug Fixes
* do not close editor if save failed ([701522a](https://github.com/filebrowser/filebrowser/commit/701522a0600cfa542469540ed764630c0ba1a732)), closes [#5591](https://github.com/filebrowser/filebrowser/issues/5591)
## [2.49.0](https://github.com/filebrowser/filebrowser/compare/v2.48.2...v2.49.0) (2025-11-22)
### Features
* add "copy download link to clipboard" button to Share prompt ([#5173](https://github.com/filebrowser/filebrowser/issues/5173)) ([d48f566](https://github.com/filebrowser/filebrowser/commit/d48f5665d6975c4cbbdf9be20dc2e0106db02f01))
* add Bulgarian language ([8db2411](https://github.com/filebrowser/filebrowser/commit/8db2411cd43a23ae3292a817e3524cfdb5ae9b86))
* Updates for project File Browser ([#5566](https://github.com/filebrowser/filebrowser/issues/5566)) ([54306bd](https://github.com/filebrowser/filebrowser/commit/54306bdc8700fac489326ae81e28ac5db0580d13))
### Bug Fixes
* display friendly error message for password validation on signup ([#5563](https://github.com/filebrowser/filebrowser/issues/5563)) ([6d5aa35](https://github.com/filebrowser/filebrowser/commit/6d5aa355e433d613e5a3ae137f410c63baeddf0f))
## [2.48.2](https://github.com/filebrowser/filebrowser/compare/v2.48.1...v2.48.2) (2025-11-18)
### Bug Fixes
* add transitionary support for FB_BASEURL ([984ea7b](https://github.com/filebrowser/filebrowser/commit/984ea7b569e3bd33b6f91ebdf63684a618d51e94))
### Refactorings
* rename python for clarification ([fd7b70c](https://github.com/filebrowser/filebrowser/commit/fd7b70cf38ac67c8c9ff79f2e7fde5e2ec45a1de))
## [2.48.1](https://github.com/filebrowser/filebrowser/compare/v2.48.0...v2.48.1) (2025-11-17)
### Bug Fixes
* options should only override if set ([420adea](https://github.com/filebrowser/filebrowser/commit/420adea7e61a1c182cddd6fb2544a0752e5709f7))
## [2.48.0](https://github.com/filebrowser/filebrowser/compare/v2.47.0...v2.48.0) (2025-11-17)
### Features
* consistent flags and environment variables ([#5549](https://github.com/filebrowser/filebrowser/issues/5549)) ([0a0cb80](https://github.com/filebrowser/filebrowser/commit/0a0cb8046fce52f1ff926171b34bcdb7cd39aab3))
### Bug Fixes
* add tokenExpirationTime to `config init` and troubleshoot docs ([#5546](https://github.com/filebrowser/filebrowser/issues/5546)) ([8c5dc76](https://github.com/filebrowser/filebrowser/commit/8c5dc7641e6f8aadd9e5d5d3b25a2ad9f1ec9a1e))
* use all available flags in quick setup ([f41585f](https://github.com/filebrowser/filebrowser/commit/f41585f0392d65c08c01ab65b62d3eeb04c03b7d))
### Refactorings
* reuse logic for config init and set ([89be0b1](https://github.com/filebrowser/filebrowser/commit/89be0b1873527987dd2dddac746e93b8bc684d46))
## [2.47.0](https://github.com/filebrowser/filebrowser/compare/v2.46.1...v2.47.0) (2025-11-16)
### Features
* add TUS settings to the command line ([#5556](https://github.com/filebrowser/filebrowser/issues/5556)) ([e24e1f1](https://github.com/filebrowser/filebrowser/commit/e24e1f1abae9e80add620c4ad65660ca1b575a49))
* remove importer of v1 config ([#5550](https://github.com/filebrowser/filebrowser/issues/5550)) ([ceb5e72](https://github.com/filebrowser/filebrowser/commit/ceb5e723f3ee2c966bb561a804015246450280ca))
### Bug Fixes
* exit 0 when gracefully shutting down ([#5555](https://github.com/filebrowser/filebrowser/issues/5555)) ([5de4099](https://github.com/filebrowser/filebrowser/commit/5de4099cba2cf012d4a213c8eb29c412fc72c151))
## [2.46.1](https://github.com/filebrowser/filebrowser/compare/v2.46.0...v2.46.1) (2025-11-15)
### Bug Fixes
* env key replacer and remove unused function ([#5547](https://github.com/filebrowser/filebrowser/issues/5547)) ([13814e1](https://github.com/filebrowser/filebrowser/commit/13814e11197ebd9101940883e3ca85998f86d442))
* remove duplicated 'hide-defaults' flag (is 'hideDefaults') ([#5548](https://github.com/filebrowser/filebrowser/issues/5548)) ([ffc8504](https://github.com/filebrowser/filebrowser/commit/ffc850454e4cb8f10b970511681d6c627340afc7))
## [2.46.0](https://github.com/filebrowser/filebrowser/compare/v2.45.3...v2.46.0) (2025-11-14)
### Features
* add 'hide-dotfiles' as command line parameter ([#3802](https://github.com/filebrowser/filebrowser/issues/3802)) ([0d973d3](https://github.com/filebrowser/filebrowser/commit/0d973d3aad70ceb88950f2cd9c297fc76e7955b1))
* add context menu ([#3343](https://github.com/filebrowser/filebrowser/issues/3343)) ([1ace579](https://github.com/filebrowser/filebrowser/commit/1ace579a553486bb15af2d11f537414156606434))
* add option to hide the login button from public-facing pages ([#3922](https://github.com/filebrowser/filebrowser/issues/3922)) ([ac7b49c](https://github.com/filebrowser/filebrowser/commit/ac7b49c1484b4e27a1149310542ccd1e90659ee2))
* Updates for project File Browser ([#5544](https://github.com/filebrowser/filebrowser/issues/5544)) ([fb5d099](https://github.com/filebrowser/filebrowser/commit/fb5d099f8514516216f407be012d2e3f25de2441))
## [2.45.3](https://github.com/filebrowser/filebrowser/compare/v2.45.2...v2.45.3) (2025-11-13)
This is a test release to ensure the updated workflow works.
## [2.45.2](https://github.com/filebrowser/filebrowser/compare/v2.45.1...v2.45.2) (2025-11-13)
### Bug Fixes
* **deps:** update module github.com/shirou/gopsutil/v3 to v4 ([#5536](https://github.com/filebrowser/filebrowser/issues/5536)) ([fdff7a3](https://github.com/filebrowser/filebrowser/commit/fdff7a38f4711f2b58dfdd60bebbb057bd3a478d))
* **deps:** update module gopkg.in/yaml.v2 to v3 ([#5537](https://github.com/filebrowser/filebrowser/issues/5537)) ([f26a685](https://github.com/filebrowser/filebrowser/commit/f26a68587d8432b536453093f42dc255d19d10fa))
### [2.45.1](https://github.com/filebrowser/filebrowser/compare/v2.45.0...v2.45.1) (2025-11-11)
### Bug Fixes
* share page preview items to contain baseUrl ([#5510](https://github.com/filebrowser/filebrowser/issues/5510)) ([6950c2e](https://github.com/filebrowser/filebrowser/commit/6950c2e4d2868f06235f93c0a18b303b4095ca0a))
## [2.45.0](https://github.com/filebrowser/filebrowser/compare/v2.44.2...v2.45.0) (2025-11-01)
### Features
* update translations ([#5458](https://github.com/filebrowser/filebrowser/issues/5458)) ([b9a03fa](https://github.com/filebrowser/filebrowser/commit/b9a03fabd98119d6588882f5ba2a7d29b012d729))
### Bug Fixes
* support croatian ([#5502](https://github.com/filebrowser/filebrowser/issues/5502)) ([93fe31c](https://github.com/filebrowser/filebrowser/commit/93fe31cc55c9d9d27c634993619a768fa700da1d))
### [2.44.2](https://github.com/filebrowser/filebrowser/compare/v2.44.1...v2.44.2) (2025-10-22)
### Bug Fixes
* **http:** remove auth query parameter ([57db25d](https://github.com/filebrowser/filebrowser/commit/57db25d08a1ef2cd0b41f34e312b7b7c35c7ed38))
### Build
* **deps-dev:** bump vite from 6.3.6 to 6.4.1 in /frontend ([b8f64a1](https://github.com/filebrowser/filebrowser/commit/b8f64a1c1bc235df784d7f52abd3a9e84c6db6ce))
### [2.44.1](https://github.com/filebrowser/filebrowser/compare/v2.44.0...v2.44.1) (2025-10-17)
### Bug Fixes
* **auth:** prevent integer overflow in logout timer using safeTimeout ([#5470](https://github.com/filebrowser/filebrowser/issues/5470)) ([dd88398](https://github.com/filebrowser/filebrowser/commit/dd883985bb484af9dfea2677a40d56999fdc72f3))
* editor discard prompt doesn't save nor discard ([a397e73](https://github.com/filebrowser/filebrowser/commit/a397e7305d1572baf67823413f97a29eea38f0cc))
* wrong url on settings branding link ([d0039af](https://github.com/filebrowser/filebrowser/commit/d0039afbb76a9364c1e6ac9715ccc3c239dc8cb6))
### Refactorings
* use slices.Contains to simplify code ([#5483](https://github.com/filebrowser/filebrowser/issues/5483)) ([97b8911](https://github.com/filebrowser/filebrowser/commit/97b8911ba8a65456091cbec0202f6b5209fcf363))
## [2.44.0](https://github.com/filebrowser/filebrowser/compare/v2.43.0...v2.44.0) (2025-09-25)
### Features
* allow setting ace editor theme ([#3826](https://github.com/filebrowser/filebrowser/issues/3826)) ([b9787c7](https://github.com/filebrowser/filebrowser/commit/b9787c78f3889171f94db19e7655dce68c64b6fb))
* Improved path display in the new file and directory modal ([#5451](https://github.com/filebrowser/filebrowser/issues/5451)) ([d29ad35](https://github.com/filebrowser/filebrowser/commit/d29ad356d1067c87b2821debab91286549f512a0))
* Translate frontend/src/i18n/en.json in no ([dec7a02](https://github.com/filebrowser/filebrowser/commit/dec7a027378fbc6948d203199c44a640a141bcad))
* Updates for project File Browser ([#5446](https://github.com/filebrowser/filebrowser/issues/5446)) ([4ff247e](https://github.com/filebrowser/filebrowser/commit/4ff247e134e4d61668ee656a258ed67f71414e18))
* Updates for project File Browser ([#5450](https://github.com/filebrowser/filebrowser/issues/5450)) ([0eade71](https://github.com/filebrowser/filebrowser/commit/0eade717ce9d04bf48051922f11d983edbc7c2d0))
* Updates for project File Browser ([#5457](https://github.com/filebrowser/filebrowser/issues/5457)) ([1165f00](https://github.com/filebrowser/filebrowser/commit/1165f00bd4dcb0dcfbc084f54f51902ba4b4a714))
### Bug Fixes
* computation of file path ([c472542](https://github.com/filebrowser/filebrowser/commit/c4725428e07da72b855009e2c13c6ed91d32e0b7))
* show login when session token expires ([e6c674b](https://github.com/filebrowser/filebrowser/commit/e6c674b3c616831942c4d4aacab0907d58003e23))
* some formatting issues with i18n files ([949ddff](https://github.com/filebrowser/filebrowser/commit/949ddffef20e38169902c5fd74dca4815dcecf11))
* **upload:** throttle upload speed calculation to 100ms to avoid Infinity MB/s ([#5456](https://github.com/filebrowser/filebrowser/issues/5456)) ([692ca5e](https://github.com/filebrowser/filebrowser/commit/692ca5eaf01e4dcf346ba03f82c5dbd50cce246b))
## [2.43.0](https://github.com/filebrowser/filebrowser/compare/v2.42.5...v2.43.0) (2025-09-13)
### Features
* "save changes" button to discard changes dialog ([84e8632](https://github.com/filebrowser/filebrowser/commit/84e8632b98e315bfef2da77dd7d1049daec99241))
* Translate frontend/src/i18n/en.json in es ([571ce6c](https://github.com/filebrowser/filebrowser/commit/571ce6cb0d7c8725d1cc1a3238ea506ddc72b060))
* Translate frontend/src/i18n/en.json in fr ([6b1fa87](https://github.com/filebrowser/filebrowser/commit/6b1fa87ad38ebbb1a9c5d0e5fc88ba796c148bcf))
* Updates for project File Browser ([#5427](https://github.com/filebrowser/filebrowser/issues/5427)) ([8950585](https://github.com/filebrowser/filebrowser/commit/89505851414bfcee6b9ff02087eb4cec51c330f6))
### Bug Fixes
* optimize markdown preview height ([783503a](https://github.com/filebrowser/filebrowser/commit/783503aece7fca9e26f7e849b0e7478aba976acb))
### Reverts
* build(deps): bump github.com/ulikunitz/xz from 0.5.12 to 0.5.14 ([0769265](https://github.com/filebrowser/filebrowser/commit/07692653ffe0ea5e517e6dc1fd3961172e931843))
### Build
* **deps-dev:** bump vite from 6.1.6 to 6.3.6 in /frontend ([36c6cc2](https://github.com/filebrowser/filebrowser/commit/36c6cc203e10947439519a0413d5817921a1690d))
* **deps:** bump github.com/go-viper/mapstructure/v2 in /tools ([280fa56](https://github.com/filebrowser/filebrowser/commit/280fa562a67824887ae6e2530a3b73739d6e1bb4))
* **deps:** bump github.com/ulikunitz/xz from 0.5.12 to 0.5.14 ([950028a](https://github.com/filebrowser/filebrowser/commit/950028abebe2898bac4ecfd8715c0967246310cb))
### Refactorings
* to use strings.Lines ([b482a9b](https://github.com/filebrowser/filebrowser/commit/b482a9bf0d292ec6542d2145a4408971e4c985f1))
## [2.43.0](https://github.com/filebrowser/filebrowser/compare/v2.42.5...v2.43.0) (2025-09-13)
### Features
* "save changes" button to discard changes dialog ([84e8632](https://github.com/filebrowser/filebrowser/commit/84e8632b98e315bfef2da77dd7d1049daec99241))
* Translate frontend/src/i18n/en.json in es ([571ce6c](https://github.com/filebrowser/filebrowser/commit/571ce6cb0d7c8725d1cc1a3238ea506ddc72b060))
* Translate frontend/src/i18n/en.json in fr ([6b1fa87](https://github.com/filebrowser/filebrowser/commit/6b1fa87ad38ebbb1a9c5d0e5fc88ba796c148bcf))
* Updates for project File Browser ([#5427](https://github.com/filebrowser/filebrowser/issues/5427)) ([8950585](https://github.com/filebrowser/filebrowser/commit/89505851414bfcee6b9ff02087eb4cec51c330f6))
### Bug Fixes
* optimize markdown preview height ([783503a](https://github.com/filebrowser/filebrowser/commit/783503aece7fca9e26f7e849b0e7478aba976acb))
### Build
* **deps-dev:** bump vite from 6.1.6 to 6.3.6 in /frontend ([36c6cc2](https://github.com/filebrowser/filebrowser/commit/36c6cc203e10947439519a0413d5817921a1690d))
* **deps:** bump github.com/go-viper/mapstructure/v2 in /tools ([280fa56](https://github.com/filebrowser/filebrowser/commit/280fa562a67824887ae6e2530a3b73739d6e1bb4))
* **deps:** bump github.com/ulikunitz/xz from 0.5.12 to 0.5.14 ([950028a](https://github.com/filebrowser/filebrowser/commit/950028abebe2898bac4ecfd8715c0967246310cb))
### Refactorings
* to use strings.Lines ([b482a9b](https://github.com/filebrowser/filebrowser/commit/b482a9bf0d292ec6542d2145a4408971e4c985f1))
### [2.42.5](https://github.com/filebrowser/filebrowser/compare/v2.42.4...v2.42.5) (2025-08-16)
### Bug Fixes
* "new folder" button not working in the move and copy popup ([#5368](https://github.com/filebrowser/filebrowser/issues/5368)) ([3107ae4](https://github.com/filebrowser/filebrowser/commit/3107ae41475ae9383c3af414d25a133e549f8087))
### [2.42.4](https://github.com/filebrowser/filebrowser/compare/v2.42.3...v2.42.4) (2025-08-16)
### Bug Fixes
* add libcap to Dockerfile.s6 ([342b239](https://github.com/filebrowser/filebrowser/commit/342b239ac6f4af2453d5f7aa27f7f0093024dd72))
### [2.42.3](https://github.com/filebrowser/filebrowser/compare/v2.42.2...v2.42.3) (2025-08-09)
### Bug Fixes
* add missing CLI flags for user management ([#5351](https://github.com/filebrowser/filebrowser/issues/5351)) ([cd51a59](https://github.com/filebrowser/filebrowser/commit/cd51a59e72c72560fce7bcc9b12aaf02646b699c))
### [2.42.2](https://github.com/filebrowser/filebrowser/compare/v2.42.1...v2.42.2) (2025-08-06)
### Bug Fixes
* show file upload errors ([06e8713](https://github.com/filebrowser/filebrowser/commit/06e8713fa55065d38f02499d3e8d39fc86926cab))
### Refactorings
* upload progress calculation ([#5350](https://github.com/filebrowser/filebrowser/issues/5350)) ([c14cf86](https://github.com/filebrowser/filebrowser/commit/c14cf86f8304e01d804e01a7eef5ea093627ef37))
### [2.42.1](https://github.com/filebrowser/filebrowser/compare/v2.42.0...v2.42.1) (2025-07-31)
### Features
* Translate frontend/src/i18n/en.json in sk ([14ee054](https://github.com/filebrowser/filebrowser/commit/14ee0543599f2ec73b7f5d2dbd8415f47fe592aa))
* Translate frontend/src/i18n/en.json in vi ([75baf7c](https://github.com/filebrowser/filebrowser/commit/75baf7ce337671a1045f897ba4a19967a31b1aec))
### Bug Fixes
* directory mode on config init ([4ff6347](https://github.com/filebrowser/filebrowser/commit/4ff634715543b65878943273dff70f340167900b))
## [2.42.0](https://github.com/filebrowser/filebrowser/compare/v2.41.0...v2.42.0) (2025-07-27)
### Features
* add Norwegian support ([#5332](https://github.com/filebrowser/filebrowser/issues/5332)) ([25e47c3](https://github.com/filebrowser/filebrowser/commit/25e47c3ce8b35b820b5370a4b8bfdf682bd5ae0b))
* select item on file list after navigating back ([#5329](https://github.com/filebrowser/filebrowser/issues/5329)) ([cbeec6d](https://github.com/filebrowser/filebrowser/commit/cbeec6d225691723c4750d7f84122ebb14d662bf))
* Translate frontend/src/i18n/en.json in no ([5eb3bf4](https://github.com/filebrowser/filebrowser/commit/5eb3bf40586c2ffc32f4834b5dd59f0eb719c1f7))
* Translate frontend/src/i18n/en.json in sk ([07dfdce](https://github.com/filebrowser/filebrowser/commit/07dfdce8e4c371f4ca7480f3cef0bd66ff5c9abb))
### Bug Fixes
* norsk loading ([619f683](https://github.com/filebrowser/filebrowser/commit/619f6837b0d1ec6c654d30f4ecedd6696874721f))
### Reverts
* Revert "chore(release): 2.42.0" ([d778c19](https://github.com/filebrowser/filebrowser/commit/d778c192ae02c5e73781f7632e3b7276c5811e17))
### Build
* bump go version to 1.23.11 ([c7a5c7e](https://github.com/filebrowser/filebrowser/commit/c7a5c7efee2b2bede89ec90bafd1af61c39519ff))
* bump to go 1.24 ([c1b0207](https://github.com/filebrowser/filebrowser/commit/c1b0207800b4bb52c8dd459c1d69ce0f785473b6))
## [2.41.0](https://github.com/filebrowser/filebrowser/compare/v2.40.2...v2.41.0) (2025-07-22)
### Features
* Allow file and directory creation modes to be configured ([21ad653](https://github.com/filebrowser/filebrowser/commit/21ad653b7eb246c0e95ccdc131f8d59267de7818)), closes [#5316](https://github.com/filebrowser/filebrowser/issues/5316) [#5200](https://github.com/filebrowser/filebrowser/issues/5200)
* better error handling for sys kill signals ([1582b8b](https://github.com/filebrowser/filebrowser/commit/1582b8b2cd1c62fa93e60ca9b4e740e940b02e84))
### [2.40.2](https://github.com/filebrowser/filebrowser/compare/v2.40.1...v2.40.2) (2025-07-17)
### Bug Fixes
* Location header on TUS endpoint ([#5302](https://github.com/filebrowser/filebrowser/issues/5302)) ([607f570](https://github.com/filebrowser/filebrowser/commit/607f5708a2484428ab837781a5ef26b8cc3194f4))
### Build
* **deps:** bump vue-i18n from 11.1.9 to 11.1.10 in /frontend ([d61110e](https://github.com/filebrowser/filebrowser/commit/d61110e4d7155a5849557adf3b75dc0191f17e80))
### [2.40.1](https://github.com/filebrowser/filebrowser/compare/v2.40.0...v2.40.1) (2025-07-15)
### Bug Fixes
* print correct user on setup ([88f1442](https://github.com/filebrowser/filebrowser/commit/88f144293267260fd4d823e3259783309b1a57b3))
## [2.40.0](https://github.com/filebrowser/filebrowser/compare/v2.39.0...v2.40.0) (2025-07-13)
### Features
* add font size botton to text editor ([#5290](https://github.com/filebrowser/filebrowser/issues/5290)) ([035084d](https://github.com/filebrowser/filebrowser/commit/035084d8e83243065fad69bfac1b69559fbad5fb))
### Bug Fixes
* invalid path when uploading files ([9072cbc](https://github.com/filebrowser/filebrowser/commit/9072cbce340da55477906f5419a4cfb6d6937dc0))
* Only left click should drag the image in extended image view ([b8454bb](https://github.com/filebrowser/filebrowser/commit/b8454bb2e41ca2848b926b66354468ba4b1c7ba5))
## [2.39.0](https://github.com/filebrowser/filebrowser/compare/v2.38.0...v2.39.0) (2025-07-13)
### Features
* Improve Docker entrypoint and config handling ([01c814c](https://github.com/filebrowser/filebrowser/commit/01c814cf98f81f2bcd622aea75e5b1efe3484940))
* rewrite the archiver and added support for zstd and brotli ([#5283](https://github.com/filebrowser/filebrowser/issues/5283)) ([7c71686](https://github.com/filebrowser/filebrowser/commit/7c716862c1bd3cdedd3c02d3a37207293db197ca))
### Bug Fixes
* drop modify permission for uploading new file ([#5270](https://github.com/filebrowser/filebrowser/issues/5270)) ([0f27c91](https://github.com/filebrowser/filebrowser/commit/0f27c91eca581482ce4f82f6429f5dac12f8b64e))
* Settings button in the sidebar ([5a8e717](https://github.com/filebrowser/filebrowser/commit/5a8e7171b1b41eff771fe27133c91d2c250896a8))
### Build
* improve docker image and binary sizes ([35ca24a](https://github.com/filebrowser/filebrowser/commit/35ca24adb886721fc9d5e1a68cfc577e2c5f0230))
* lightweight busybox-based container build ([#5285](https://github.com/filebrowser/filebrowser/issues/5285)) ([5c5942d](https://github.com/filebrowser/filebrowser/commit/5c5942d99514b433e09d90624bbe58992eab6be2))
* remove upx ([1a5c83b](https://github.com/filebrowser/filebrowser/commit/1a5c83bcfe847f1e41a44cef23fd795b19b6b434))
## [2.38.0](https://github.com/filebrowser/filebrowser/compare/v2.37.0...v2.38.0) (2025-07-12)
### Features
* Show the current users name in the sidebar ([#2821](https://github.com/filebrowser/filebrowser/issues/2821)) ([528ce92](https://github.com/filebrowser/filebrowser/commit/528ce92fad6dcc8e8b7910036bf9175146e27bf7))
* Updates for project File Browser ([b4eddf4](https://github.com/filebrowser/filebrowser/commit/b4eddf45e4d7e6f6ccf242e67fe20f89f5e2f9a9))
### Bug Fixes
* prevent page change if there are outstanding edits ([#5260](https://github.com/filebrowser/filebrowser/issues/5260)) ([fbe169b](https://github.com/filebrowser/filebrowser/commit/fbe169b84f28cba22ea87f01b52f2420f1ea6814))
## [2.37.0](https://github.com/filebrowser/filebrowser/compare/v2.36.3...v2.37.0) (2025-07-08)
### Features
* Translate frontend/src/i18n/en.json in zh_CN ([65bbf44](https://github.com/filebrowser/filebrowser/commit/65bbf44e3c0bff83e64193d46e9d6ad302952276))
* Translate frontend/src/i18n/en.json in zh_TW ([b28952c](https://github.com/filebrowser/filebrowser/commit/b28952cb2582bd4eb44e91d0676e2803c458cf31))
* Translate frontend/src/i18n/en.json in zh_TW ([1e96fd9](https://github.com/filebrowser/filebrowser/commit/1e96fd9035d5185dc80970a2826ccb573b5f000e))
### Bug Fixes
* long file name overlap ([fcb248a](https://github.com/filebrowser/filebrowser/commit/fcb248a5feb7b7404ca5923aae17f6d3f8d3cc96))
* preview PDF is correctly displayed ([bf73e4d](https://github.com/filebrowser/filebrowser/commit/bf73e4dea3b27c01c8f6e60fb2048e1a2122a70e))
* Upload progress size calculation ([e423395](https://github.com/filebrowser/filebrowser/commit/e423395ef0bcd106ddc7d460c055b95b5208415e))
### [2.36.3](https://github.com/filebrowser/filebrowser/compare/v2.36.2...v2.36.3) (2025-07-06)
### Bug Fixes
* log error if branding file exists but cannot be loaded ([3645b57](https://github.com/filebrowser/filebrowser/commit/3645b578cddb9fc8f25a00e0153fb600ad1b9266))
### [2.36.2](https://github.com/filebrowser/filebrowser/compare/v2.36.1...v2.36.2) (2025-07-06)
### Bug Fixes
* lookup directory name if blank when downloading shared directory ([046d619](https://github.com/filebrowser/filebrowser/commit/046d6193c57b4df0e3dc583b6518b43d29d302c9))
### [2.36.1](https://github.com/filebrowser/filebrowser/compare/v2.36.0...v2.36.1) (2025-07-03)
### Bug Fixes
* remove associated shares when deleting file/folder ([e99e0b3](https://github.com/filebrowser/filebrowser/commit/e99e0b3028e1c8a50e1744bb07ecc8e809bdb8e6))
## [2.36.0](https://github.com/filebrowser/filebrowser/compare/v2.35.0...v2.36.0) (2025-07-02)
### Features
* update icons, remove deprecated Microsoft Tiles ([04166e8](https://github.com/filebrowser/filebrowser/commit/04166e81e52d38b1f66ba3313ccb1291c239eea2))
## [2.35.0](https://github.com/filebrowser/filebrowser/compare/v2.34.2...v2.35.0) (2025-06-30)
### Features
* Long press selects item in single click mode ([8d75220](https://github.com/filebrowser/filebrowser/commit/8d7522049ced83f28f0933b55772c32e3ad04627))
### Bug Fixes
* shell value must be joined by blank space ([4403cd3](https://github.com/filebrowser/filebrowser/commit/4403cd35720dbda5a8bb1013b92582accf3317bc))
* update documentation links ([38d0366](https://github.com/filebrowser/filebrowser/commit/38d0366acf88352b5a9a97c45837b0f865efae0b))
### [2.34.2](https://github.com/filebrowser/filebrowser/compare/v2.34.1...v2.34.2) (2025-06-29)
### Bug Fixes
* mitigate unprotected shares ([2b5d6cb](https://github.com/filebrowser/filebrowser/commit/2b5d6cbb996a61a769acc56af0acc12eec2d8d8f))
### [2.34.1](https://github.com/filebrowser/filebrowser/compare/v2.34.0...v2.34.1) (2025-06-29) ### [2.34.1](https://github.com/filebrowser/filebrowser/compare/v2.34.0...v2.34.1) (2025-06-29)

View File

@ -15,23 +15,11 @@ We encourage you to use git to manage your fork. To clone the main repository, j
git clone https://github.com/filebrowser/filebrowser git clone https://github.com/filebrowser/filebrowser
``` ```
We use [Taskfile](https://taskfile.dev/) to manage the different processes (building, releasing, etc) automatically.
## Build ## Build
You can fully build the project in order to produce a binary by running:
```bash
task build
```
## Development
For development, there are a few things to have in mind.
### Frontend ### Frontend
We use [Node.js](https://nodejs.org/en/) on the frontend to manage the build process. Prepare the frontend environment: We are using [Node.js](https://nodejs.org/en/) on the frontend to manage the build process. The steps to build it are:
```bash ```bash
# From the root of the repo, go to frontend/ # From the root of the repo, go to frontend/
@ -39,62 +27,37 @@ cd frontend
# Install the dependencies # Install the dependencies
pnpm install pnpm install
```
If you just want to develop the backend, you can create a static build of the frontend: # Build the frontend
```bash
pnpm run build pnpm run build
``` ```
If you want to develop the frontend, start a development server which watches for changes: This will install the dependencies and build the frontend so you can then embed it into the Go app. Although, if you want to play with it, you'll get bored of building it after every change you do. So, you can run the command below to watch for changes:
```bash ```bash
pnpm run dev pnpm run dev
``` ```
Please note that you need to access File Browser's interface through the development server of the frontend.
### Backend ### Backend
First prepare the backend environment by downloading all required dependencies: First of all, you need to download the required dependencies. We are using the built-in `go mod` tool for dependency management. To get the modules, run:
```bash ```bash
go mod download go mod download
``` ```
You can now build or run File Browser as any other Go project: The magic of File Browser is that the static assets are bundled into the final binary. For that, we use [Go embed.FS](https://golang.org/pkg/embed/). The files from `frontend/dist` will be embedded during the build process.
To build File Browser is just like any other Go program:
```bash ```bash
# Build
go build go build
# Run
go run .
``` ```
## Documentation To create a development build use the "dev" tag, this way the content inside the frontend folder will not be embedded in the binary but will be reloaded at every change:
We rely on Docker to abstract all the dependencies required for building the documentation.
To build the documentation to `www/public`:
```bash ```bash
task docs go build -tags dev
```
To start a local server on port `8000` to view the built documentation:
```bash
task docs:serve
```
## Release
To make a release, just run:
```bash
task release
``` ```
## Translations ## Translations

View File

@ -1,37 +1,23 @@
## Multistage build: First stage fetches dependencies FROM alpine:3.22
FROM alpine:3.23 AS fetcher
# install and copy ca-certificates, mailcap, and tini-static; download JSON.sh
RUN apk update && \ RUN apk update && \
apk --no-cache add ca-certificates mailcap tini-static && \ apk --no-cache add ca-certificates mailcap curl jq tini
wget -O /JSON.sh https://raw.githubusercontent.com/dominictarr/JSON.sh/0d5e5c77365f63809bf6e77ef44a1f34b0e05840/JSON.sh
## Second stage: Use lightweight BusyBox image for final runtime environment # Make user and create necessary directories
FROM busybox:1.37.0-musl
# Define non-root user UID and GID
ENV UID=1000 ENV UID=1000
ENV GID=1000 ENV GID=1000
# Create user group and user
RUN addgroup -g $GID user && \ RUN addgroup -g $GID user && \
adduser -D -u $UID -G user user adduser -D -u $UID -G user user && \
mkdir -p /config /database /srv && \
chown -R user:user /config /database /srv
# Copy binary, scripts, and configurations into image with proper ownership # Copy files and set permissions
COPY --chown=user:user filebrowser /bin/filebrowser COPY filebrowser /bin/filebrowser
COPY --chown=user:user docker/common/ / COPY docker/common/ /
COPY --chown=user:user docker/alpine/ / COPY docker/alpine/ /
COPY --chown=user:user --from=fetcher /sbin/tini-static /bin/tini
COPY --from=fetcher /JSON.sh /JSON.sh
COPY --from=fetcher /etc/ca-certificates.conf /etc/ca-certificates.conf
COPY --from=fetcher /etc/ca-certificates /etc/ca-certificates
COPY --from=fetcher /etc/mime.types /etc/mime.types
COPY --from=fetcher /etc/ssl /etc/ssl
# Create data directories, set ownership, and ensure healthcheck script is executable RUN chown -R user:user /bin/filebrowser /defaults healthcheck.sh init.sh
RUN mkdir -p /config /database /srv && \
chown -R user:user /config /database /srv \
&& chmod +x /healthcheck.sh
# Define healthcheck script # Define healthcheck script
HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh
@ -43,4 +29,4 @@ VOLUME /srv /config /database
EXPOSE 80 EXPOSE 80
ENTRYPOINT [ "tini", "--", "/init.sh" ] ENTRYPOINT [ "tini", "--", "/init.sh", "filebrowser", "--config", "/config/settings.json" ]

View File

@ -1,7 +1,7 @@
FROM ghcr.io/linuxserver/baseimage-alpine:3.23 FROM ghcr.io/linuxserver/baseimage-alpine:3.22
RUN apk update && \ RUN apk update && \
apk --no-cache add ca-certificates mailcap jq libcap apk --no-cache add ca-certificates mailcap curl jq
# Make user and create necessary directories # Make user and create necessary directories
RUN mkdir -p /config /database /srv && \ RUN mkdir -p /config /database /srv && \
@ -12,8 +12,7 @@ COPY filebrowser /bin/filebrowser
COPY docker/common/ / COPY docker/common/ /
COPY docker/s6/ / COPY docker/s6/ /
RUN chown -R abc:abc /bin/filebrowser /defaults healthcheck.sh && \ RUN chown -R abc:abc /bin/filebrowser /defaults healthcheck.sh
setcap 'cap_net_bind_service=+ep' /bin/filebrowser
# Define healthcheck script # Define healthcheck script
HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh

23
Dockerfile.s6.aarch64 Normal file
View File

@ -0,0 +1,23 @@
FROM ghcr.io/linuxserver/baseimage-alpine:arm64v8-3.22
RUN apk update && \
apk --no-cache add ca-certificates mailcap curl jq
# Make user and create necessary directories
RUN mkdir -p /config /database /srv && \
chown -R abc:abc /config /database /srv
# Copy files and set permissions
COPY filebrowser /bin/filebrowser
COPY docker/common/ /
COPY docker/s6/ /
RUN chown -R abc:abc /bin/filebrowser /defaults healthcheck.sh
# Define healthcheck script
HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh
# Set the volumes and exposed ports
VOLUME /srv /config /database
EXPOSE 80

88
Makefile Normal file
View File

@ -0,0 +1,88 @@
include common.mk
include tools.mk
LDFLAGS += -X "$(MODULE)/version.Version=$(VERSION)" -X "$(MODULE)/version.CommitSHA=$(VERSION_HASH)"
SITE_DOCKER_FLAGS = \
-v $(CURDIR)/www:/docs \
-v $(CURDIR)/LICENSE:/docs/docs/LICENSE \
-v $(CURDIR)/SECURITY.md:/docs/docs/security.md \
-v $(CURDIR)/CHANGELOG.md:/docs/docs/changelog.md \
-v $(CURDIR)/CODE-OF-CONDUCT.md:/docs/docs/code-of-conduct.md \
-v $(CURDIR)/CONTRIBUTING.md:/docs/docs/contributing.md
## Build:
.PHONY: build
build: | build-frontend build-backend ## Build binary
.PHONY: build-frontend
build-frontend: ## Build frontend
$Q cd frontend && pnpm install --frozen-lockfile && pnpm run build
.PHONY: build-backend
build-backend: ## Build backend
$Q $(go) build -ldflags '$(LDFLAGS)' -o .
.PHONY: test
test: | test-frontend test-backend ## Run all tests
.PHONY: test-frontend
test-frontend: ## Run frontend tests
$Q cd frontend && pnpm install --frozen-lockfile && pnpm run typecheck
.PHONY: test-backend
test-backend: ## Run backend tests
$Q $(go) test -v ./...
.PHONY: lint
lint: lint-frontend lint-backend ## Run all linters
.PHONY: lint-frontend
lint-frontend: ## Run frontend linters
$Q cd frontend && pnpm install --frozen-lockfile && pnpm run lint
.PHONY: lint-backend
lint-backend: | $(golangci-lint) ## Run backend linters
$Q $(golangci-lint) run -v
.PHONY: lint-commits
lint-commits: $(commitlint) ## Run commit linters
$Q ./scripts/commitlint.sh
fmt: $(goimports) ## Format source files
$Q $(goimports) -local $(MODULE) -w $$(find . -type f -name '*.go' -not -path "./vendor/*")
clean: clean-tools ## Clean
## Release:
.PHONY: bump-version
bump-version: $(standard-version) ## Bump app version
$Q ./scripts/bump_version.sh
.PHONY: site
site: ## Build site
@rm -rf www/public
docker build -f www/Dockerfile --progress=plain -t filebrowser.site www
docker run --rm $(SITE_DOCKER_FLAGS) filebrowser.site build -d "public"
.PHONY: site-serve
site-serve: ## Serve site for development
docker build -f www/Dockerfile --progress=plain -t filebrowser.site www
docker run --rm -it -p 8000:8000 $(SITE_DOCKER_FLAGS) filebrowser.site
## Help:
help: ## Show this help
@echo ''
@echo 'Usage:'
@echo ' ${YELLOW}make${RESET} ${GREEN}<target> [options]${RESET}'
@echo ''
@echo 'Options:'
@$(call global_option, "V [0|1]", "enable verbose mode (default:0)")
@echo ''
@echo 'Targets:'
@awk 'BEGIN {FS = ":.*?## "} { \
if (/^[a-zA-Z_-]+:.*?##.*$$/) {printf " ${YELLOW}%-20s${GREEN}%s${RESET}\n", $$1, $$2} \
else if (/^## .*$$/) {printf " ${CYAN}%s${RESET}\n", substr($$1,4)} \
}' $(MAKEFILE_LIST)

View File

@ -1,10 +1,12 @@
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/filebrowser/filebrowser/master/branding/banner.png" width="550"/> <img src="https://raw.githubusercontent.com/filebrowser/logo/master/banner.png" width="550"/>
</p> </p>
[![Build](https://github.com/filebrowser/filebrowser/actions/workflows/ci.yaml/badge.svg)](https://github.com/filebrowser/filebrowser/actions/workflows/ci.yaml) [![Build](https://github.com/filebrowser/filebrowser/actions/workflows/main.yaml/badge.svg)](https://github.com/filebrowser/filebrowser/actions/workflows/main.yaml)
[![Go Report Card](https://goreportcard.com/badge/github.com/filebrowser/filebrowser/v2)](https://goreportcard.com/report/github.com/filebrowser/filebrowser/v2) [![Go Report Card](https://goreportcard.com/badge/github.com/filebrowser/filebrowser)](https://goreportcard.com/report/github.com/filebrowser/filebrowser)
[![Documentation](https://img.shields.io/badge/godoc-reference-blue.svg)](http://godoc.org/github.com/filebrowser/filebrowser)
[![Version](https://img.shields.io/github/release/filebrowser/filebrowser.svg)](https://github.com/filebrowser/filebrowser/releases/latest) [![Version](https://img.shields.io/github/release/filebrowser/filebrowser.svg)](https://github.com/filebrowser/filebrowser/releases/latest)
[![Chat IRC](https://img.shields.io/badge/freenode-%23filebrowser-blue.svg)](http://webchat.freenode.net/?channels=%23filebrowser)
File Browser provides a file managing interface within a specified directory and it can be used to upload, delete, preview and edit your files. It is a **create-your-own-cloud**-kind of software where you can just install it on your server, direct it to a path and access your files through a nice web interface. File Browser provides a file managing interface within a specified directory and it can be used to upload, delete, preview and edit your files. It is a **create-your-own-cloud**-kind of software where you can just install it on your server, direct it to a path and access your files through a nice web interface.
@ -14,12 +16,18 @@ Documentation on how to install, configure, and contribute to this project is ho
## Project Status ## Project Status
This project is a finished product which fulfills its goal: be a single binary web File Browser which can be run by anyone anywhere. That means that File Browser is currently on **maintenance-only** mode. Therefore, please note the following: > [!WARNING]
>
> This project is currently on **maintenance-only** mode, and is looking for new maintainers. For more information, please read the [discussion #4906](https://github.com/filebrowser/filebrowser/discussions/4906). Therefore, please note the following:
>
> - It can take a while until someone gets back to you. Please be patient.
> - [Issues][issues] are only being used to track bugs. Any unrelated issues will be converted into a [discussion][discussions].
> - No new features will be implemented until further notice. The priority is on triaging issues and merge bug fixes.
>
> If you're interested in maintaining this project, please reach out via the discussion above.
- It can take a while until someone gets back to you. Please be patient. [issues]: https://github.com/filebrowser/filebrowser/issues
- [Issues](https://github.com/filebrowser/filebrowser/issues) are meant to track bugs. Unrelated issues will be converted into [discussions](https://github.com/filebrowser/filebrowser/discussions). [discussions]: https://github.com/filebrowser/filebrowser/discussions
- No new features will be implemented by maintainers. Pull requests for new features will be reviewed on a case by case basis.
- The priority is triaging issues, addressing security issues and reviewing pull requests meant to solve bugs.
## Contributing ## Contributing

View File

@ -1,83 +0,0 @@
version: '3'
vars:
SITE_DOCKER_FLAGS: >-
-v ./www:/docs
-v ./LICENSE:/docs/docs/LICENSE
-v ./SECURITY.md:/docs/docs/security.md
-v ./CHANGELOG.md:/docs/docs/changelog.md
-v ./CODE-OF-CONDUCT.md:/docs/docs/code-of-conduct.md
-v ./CONTRIBUTING.md:/docs/docs/contributing.md
tasks:
build:frontend:
desc: Build frontend assets
dir: frontend
cmds:
- pnpm install --frozen-lockfile
- pnpm run build
build:backend:
desc: Build backend binary
cmds:
- go build -ldflags='-s -w -X "github.com/filebrowser/filebrowser/v2/version.Version={{.VERSION}}" -X "github.com/filebrowser/filebrowser/v2/version.CommitSHA={{.GIT_COMMIT}}"' -o filebrowser .
vars:
GIT_COMMIT:
sh: git log -n 1 --format=%h
VERSION:
sh: git describe --tags --abbrev=0 --match=v* | cut -c 2-
build:
desc: Build both frontend and backend
cmds:
- task: build:frontend
- task: build:backend
release:make:
internal: true
prompt: Do you wish to proceed?
cmds:
- pnpm dlx commit-and-tag-version -s
release:dry-run:
internal: true
cmds:
- pnpm dlx commit-and-tag-version --dry-run --skip
release:
desc: Create a new release
cmds:
- task: docs:cli:generate
- git add www/docs/cli
- |
if [[ `git status www/docs/cli --porcelain` ]]; then
git commit -m 'chore(docs): update CLI documentation'
fi
- task: release:dry-run
- task: release:make
docs:cli:generate:
cmds:
- rm -rf www/docs/cli
- mkdir -p www/docs/cli
- go run . docs
generates:
- www/docs/cli
docs:docker:generate:
internal: true
cmds:
- docker build -f www/Dockerfile --progress=plain -t filebrowser.site www
docs:
desc: Generate documentation
cmds:
- rm -rf www/public
- task: docs:docker:generate
- docker run --rm {{.SITE_DOCKER_FLAGS}} filebrowser.site build -d "public"
docs:serve:
desc: Serve documentation
cmds:
- task: docs:docker:generate
- docker run --rm -it -p 8000:8000 {{.SITE_DOCKER_FLAGS}} filebrowser.site

View File

@ -8,10 +8,9 @@ import (
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"slices"
"strings" "strings"
fberrors "github.com/filebrowser/filebrowser/v2/errors" fbErrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
@ -103,7 +102,7 @@ func (a *HookAuth) RunCommand() (string, error) {
command[i] = os.Expand(arg, envMapping) command[i] = os.Expand(arg, envMapping)
} }
cmd := exec.Command(command[0], command[1:]...) cmd := exec.Command(command[0], command[1:]...) //nolint:gosec
cmd.Env = append(os.Environ(), fmt.Sprintf("USERNAME=%s", a.Cred.Username)) cmd.Env = append(os.Environ(), fmt.Sprintf("USERNAME=%s", a.Cred.Username))
cmd.Env = append(cmd.Env, fmt.Sprintf("PASSWORD=%s", a.Cred.Password)) cmd.Env = append(cmd.Env, fmt.Sprintf("PASSWORD=%s", a.Cred.Password))
out, err := cmd.Output() out, err := cmd.Output()
@ -124,7 +123,7 @@ func (a *HookAuth) GetValues(s string) {
s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r\n", "\n")
// iterate input lines // iterate input lines
for val := range strings.Lines(s) { for _, val := range strings.Split(s, "\n") {
v := strings.SplitN(val, "=", 2) v := strings.SplitN(val, "=", 2)
// skips non key and value format // skips non key and value format
@ -146,7 +145,7 @@ func (a *HookAuth) GetValues(s string) {
// SaveUser updates the existing user or creates a new one when not found // SaveUser updates the existing user or creates a new one when not found
func (a *HookAuth) SaveUser() (*users.User, error) { func (a *HookAuth) SaveUser() (*users.User, error) {
u, err := a.Users.Get(a.Server.Root, a.Cred.Username) u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
if err != nil && !errors.Is(err, fberrors.ErrNotExist) { if err != nil && !errors.Is(err, fbErrors.ErrNotExist) {
return nil, err return nil, err
} }
@ -158,17 +157,16 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
// create user with the provided credentials // create user with the provided credentials
d := &users.User{ d := &users.User{
Username: a.Cred.Username, Username: a.Cred.Username,
Password: pass, Password: pass,
Scope: a.Settings.Defaults.Scope, Scope: a.Settings.Defaults.Scope,
Locale: a.Settings.Defaults.Locale, Locale: a.Settings.Defaults.Locale,
ViewMode: a.Settings.Defaults.ViewMode, ViewMode: a.Settings.Defaults.ViewMode,
SingleClick: a.Settings.Defaults.SingleClick, SingleClick: a.Settings.Defaults.SingleClick,
RedirectAfterCopyMove: a.Settings.Defaults.RedirectAfterCopyMove, Sorting: a.Settings.Defaults.Sorting,
Sorting: a.Settings.Defaults.Sorting, Perm: a.Settings.Defaults.Perm,
Perm: a.Settings.Defaults.Perm, Commands: a.Settings.Defaults.Commands,
Commands: a.Settings.Defaults.Commands, HideDotfiles: a.Settings.Defaults.HideDotfiles,
HideDotfiles: a.Settings.Defaults.HideDotfiles,
} }
u = a.GetUser(d) u = a.GetUser(d)
@ -220,14 +218,13 @@ func (a *HookAuth) GetUser(d *users.User) *users.User {
Download: isAdmin || a.Fields.GetBoolean("user.perm.download", d.Perm.Download), Download: isAdmin || a.Fields.GetBoolean("user.perm.download", d.Perm.Download),
} }
user := users.User{ user := users.User{
ID: d.ID, ID: d.ID,
Username: d.Username, Username: d.Username,
Password: d.Password, Password: d.Password,
Scope: a.Fields.GetString("user.scope", d.Scope), Scope: a.Fields.GetString("user.scope", d.Scope),
Locale: a.Fields.GetString("user.locale", d.Locale), Locale: a.Fields.GetString("user.locale", d.Locale),
ViewMode: users.ViewMode(a.Fields.GetString("user.viewMode", string(d.ViewMode))), ViewMode: users.ViewMode(a.Fields.GetString("user.viewMode", string(d.ViewMode))),
SingleClick: a.Fields.GetBoolean("user.singleClick", d.SingleClick), SingleClick: a.Fields.GetBoolean("user.singleClick", d.SingleClick),
RedirectAfterCopyMove: a.Fields.GetBoolean("user.redirectAfterCopyMove", d.RedirectAfterCopyMove),
Sorting: files.Sorting{ Sorting: files.Sorting{
Asc: a.Fields.GetBoolean("user.sorting.asc", d.Sorting.Asc), Asc: a.Fields.GetBoolean("user.sorting.asc", d.Sorting.Asc),
By: a.Fields.GetString("user.sorting.by", d.Sorting.By), By: a.Fields.GetString("user.sorting.by", d.Sorting.By),
@ -253,7 +250,6 @@ var validHookFields = []string{
"user.locale", "user.locale",
"user.viewMode", "user.viewMode",
"user.singleClick", "user.singleClick",
"user.redirectAfterCopyMove",
"user.sorting.by", "user.sorting.by",
"user.sorting.asc", "user.sorting.asc",
"user.commands", "user.commands",
@ -270,7 +266,13 @@ var validHookFields = []string{
// IsValid checks if the provided field is on the valid fields list // IsValid checks if the provided field is on the valid fields list
func (hf *hookFields) IsValid(field string) bool { func (hf *hookFields) IsValid(field string) bool {
return slices.Contains(validHookFields, field) for _, val := range validHookFields {
if field == val {
return true
}
}
return false
} }
// GetString returns the string value or provided default // GetString returns the string value or provided default

View File

@ -40,7 +40,7 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store, _ *settings.Settings, s
// If ReCaptcha is enabled, check the code. // If ReCaptcha is enabled, check the code.
if a.ReCaptcha != nil && a.ReCaptcha.Secret != "" { if a.ReCaptcha != nil && a.ReCaptcha.Secret != "" {
ok, err := a.ReCaptcha.Ok(cred.ReCaptcha) ok, err := a.ReCaptcha.Ok(cred.ReCaptcha) //nolint:govet
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -4,7 +4,7 @@ import (
"errors" "errors"
"net/http" "net/http"
fberrors "github.com/filebrowser/filebrowser/v2/errors" fbErrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
) )
@ -21,7 +21,7 @@ type ProxyAuth struct {
func (a ProxyAuth) Auth(r *http.Request, usr users.Store, setting *settings.Settings, srv *settings.Server) (*users.User, error) { func (a ProxyAuth) Auth(r *http.Request, usr users.Store, setting *settings.Settings, srv *settings.Server) (*users.User, error) {
username := r.Header.Get(a.Header) username := r.Header.Get(a.Header)
user, err := usr.Get(srv.Root, username) user, err := usr.Get(srv.Root, username)
if errors.Is(err, fberrors.ErrNotExist) { if errors.Is(err, fbErrors.ErrNotExist) {
return a.createUser(usr, setting, srv, username) return a.createUser(usr, setting, srv, username)
} }
return user, err return user, err

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="700" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd"><defs><style>.prefix__fil1{fill:#fefefe}.prefix__fil6{fill:#006498}.prefix__fil5{fill:#bdeaff}</style></defs><g id="prefix__Layer_x0020_1"><path d="M80 0h540c44 0 80 36 80 80v540c0 44-36 80-80 80H80c-44 0-80-36-80-80V80C0 36 36 0 80 0z" fill="#455a64"/><path class="prefix__fil1" d="M350 71c154 0 279 125 279 279S504 629 350 629 71 504 71 350 196 71 350 71z"/><path d="M475 236l118 151c3 116-149 252-292 198l-76-99 114-156s138-95 136-94z" fill="#332c2b" fill-opacity=".149"/><path d="M231 211h208l38 24v246c0 5-3 8-8 8H231c-5 0-8-3-8-8V219c0-5 3-8 8-8z" fill="#2bbcff"/><path d="M231 211h208l38 24v2l-37-23H231c-4 0-7 3-7 7v263c-1-1-1-2-1-3V219c0-5 3-8 8-8z" fill="#53c6fc"/><path class="prefix__fil5" d="M305 212h113v98H305zM255 363h189c3 0 5 2 5 4v116H250V367c0-2 2-4 5-4z"/><path class="prefix__fil6" d="M250 470h199v13H250zM380 226h10c3 0 6 2 6 5v40c0 3-3 6-6 6h-10c-3 0-6-3-6-6v-40c0-3 3-5 6-5z"/><path class="prefix__fil1" d="M254 226c10 0 17 7 17 17 0 9-7 16-17 16-9 0-17-7-17-16 0-10 8-17 17-17z"/><path class="prefix__fil6" d="M267 448h165c2 0 3 1 3 3 0 1-1 3-3 3H267c-2 0-3-2-3-3 0-2 1-3 3-3zM267 415h165c2 0 3 1 3 3 0 1-1 2-3 2H267c-2 0-3-1-3-2 0-2 1-3 3-3zM267 381h165c2 0 3 2 3 3 0 2-1 3-3 3H267c-2 0-3-1-3-3 0-1 1-3 3-3z"/><path class="prefix__fil1" d="M236 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5zM463 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z"/><path class="prefix__fil6" d="M305 212h-21v98h21z"/><path d="M477 479v2c0 5-3 8-8 8H231c-5 0-8-3-8-8v-2c0 4 3 8 8 8h238c5 0 8-4 8-8z" fill="#0ea5eb"/><path d="M350 70c155 0 280 125 280 280S505 630 350 630 70 505 70 350 195 70 350 70zm0 46c129 0 234 105 234 234S479 584 350 584 116 479 116 350s105-234 234-234z" fill="#2979ff"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="560" height="560" version="1.1" id="prefix__svg44" clip-rule="evenodd" fill-rule="evenodd" image-rendering="optimizeQuality" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"><defs id="prefix__defs4"><style type="text/css" id="style2">.prefix__fil1{fill:#fefefe}.prefix__fil6{fill:#006498}.prefix__fil5{fill:#bdeaff}</style></defs><g id="prefix__g85" transform="translate(-70 -70)"><path class="prefix__fil1" d="M350 71c154 0 279 125 279 279S504 629 350 629 71 504 71 350 196 71 350 71z" id="prefix__path9" fill="#fefefe"/><path d="M475 236l118 151c3 116-149 252-292 198l-76-99 114-156s138-95 136-94z" id="prefix__path11" fill="#332c2b" fill-opacity=".149"/><path d="M231 211h208l38 24v246c0 5-3 8-8 8H231c-5 0-8-3-8-8V219c0-5 3-8 8-8z" id="prefix__path13" fill="#2bbcff"/><path d="M231 211h208l38 24v2l-37-23H231c-4 0-7 3-7 7v263c-1-1-1-2-1-3V219c0-5 3-8 8-8z" id="prefix__path15" fill="#53c6fc"/><path class="prefix__fil5" id="prefix__polygon17" fill="#bdeaff" d="M305 212h113v98H305z"/><path class="prefix__fil5" d="M255 363h189c3 0 5 2 5 4v116H250V367c0-2 2-4 5-4z" id="prefix__path19" fill="#bdeaff"/><path class="prefix__fil6" id="prefix__polygon21" fill="#006498" d="M250 470h199v13H250z"/><path class="prefix__fil6" d="M380 226h10c3 0 6 2 6 5v40c0 3-3 6-6 6h-10c-3 0-6-3-6-6v-40c0-3 3-5 6-5z" id="prefix__path23" fill="#006498"/><path class="prefix__fil1" d="M254 226c10 0 17 7 17 17 0 9-7 16-17 16-9 0-17-7-17-16 0-10 8-17 17-17z" id="prefix__path25" fill="#fefefe"/><path class="prefix__fil6" d="M267 448h165c2 0 3 1 3 3 0 1-1 3-3 3H267c-2 0-3-2-3-3 0-2 1-3 3-3z" id="prefix__path27" fill="#006498"/><path class="prefix__fil6" d="M267 415h165c2 0 3 1 3 3 0 1-1 2-3 2H267c-2 0-3-1-3-2 0-2 1-3 3-3z" id="prefix__path29" fill="#006498"/><path class="prefix__fil6" d="M267 381h165c2 0 3 2 3 3 0 2-1 3-3 3H267c-2 0-3-1-3-3 0-1 1-3 3-3z" id="prefix__path31" fill="#006498"/><path class="prefix__fil1" d="M236 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z" id="prefix__path33" fill="#fefefe"/><path class="prefix__fil1" d="M463 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z" id="prefix__path35" fill="#fefefe"/><path class="prefix__fil6" id="prefix__polygon37" fill="#006498" d="M305 212h-21v98h21z"/><path d="M477 479v2c0 5-3 8-8 8H231c-5 0-8-3-8-8v-2c0 4 3 8 8 8h238c5 0 8-4 8-8z" id="prefix__path39" fill="#0ea5eb"/><path d="M350 70c155 0 280 125 280 280S505 630 350 630 70 505 70 350 195 70 350 70zm0 46c129 0 234 105 234 234S479 584 350 584 116 479 116 350s105-234 234-234z" id="prefix__path41" fill="#2979ff"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,6 +1,12 @@
package cmd package cmd
import (
"log"
)
// Execute executes the commands. // Execute executes the commands.
func Execute() error { func Execute() {
return rootCmd.Execute() if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
} }

View File

@ -1,35 +0,0 @@
package cmd
import (
"testing"
"github.com/samber/lo"
"github.com/spf13/cobra"
)
// TestEnvCollisions ensures that there are no collisions in the produced environment
// variable names for all commands and their flags.
func TestEnvCollisions(t *testing.T) {
testEnvCollisions(t, rootCmd)
}
func testEnvCollisions(t *testing.T, cmd *cobra.Command) {
for _, cmd := range cmd.Commands() {
testEnvCollisions(t, cmd)
}
replacements := generateEnvKeyReplacements(cmd)
envVariables := []string{}
for i := range replacements {
if i%2 != 0 {
envVariables = append(envVariables, replacements[i])
}
}
duplicates := lo.FindDuplicates(envVariables)
if len(duplicates) > 0 {
t.Errorf("Found duplicate environment variable keys for command %q: %v", cmd.Name(), duplicates)
}
}

View File

@ -15,18 +15,13 @@ var cmdsAddCmd = &cobra.Command{
Short: "Add a command to run on a specific event", Short: "Add a command to run on a specific event",
Long: `Add a command to run on a specific event.`, Long: `Add a command to run on a specific event.`,
Args: cobra.MinimumNArgs(2), Args: cobra.MinimumNArgs(2),
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error { Run: python(func(_ *cobra.Command, args []string, d pythonData) {
s, err := st.Settings.Get() s, err := d.store.Settings.Get()
if err != nil { checkErr(err)
return err
}
command := strings.Join(args[1:], " ") command := strings.Join(args[1:], " ")
s.Commands[args[0]] = append(s.Commands[args[0]], command) s.Commands[args[0]] = append(s.Commands[args[0]], command)
err = st.Settings.Save(s) err = d.store.Settings.Save(s)
if err != nil { checkErr(err)
return err
}
printEvents(s.Commands) printEvents(s.Commands)
return nil }, pythonConfig{}),
}, storeOptions{}),
} }

View File

@ -14,16 +14,10 @@ var cmdsLsCmd = &cobra.Command{
Short: "List all commands for each event", Short: "List all commands for each event",
Long: `List all commands for each event.`, Long: `List all commands for each event.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error { Run: python(func(cmd *cobra.Command, _ []string, d pythonData) {
s, err := st.Settings.Get() s, err := d.store.Settings.Get()
if err != nil { checkErr(err)
return err evt := mustGetString(cmd.Flags(), "event")
}
evt, err := cmd.Flags().GetString("event")
if err != nil {
return err
}
if evt == "" { if evt == "" {
printEvents(s.Commands) printEvents(s.Commands)
@ -33,7 +27,5 @@ var cmdsLsCmd = &cobra.Command{
show["after_"+evt] = s.Commands["after_"+evt] show["after_"+evt] = s.Commands["after_"+evt]
printEvents(show) printEvents(show)
} }
}, pythonConfig{}),
return nil
}, storeOptions{}),
} }

View File

@ -35,31 +35,22 @@ including 'index_end'.`,
return nil return nil
}, },
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error { Run: python(func(_ *cobra.Command, args []string, d pythonData) {
s, err := st.Settings.Get() s, err := d.store.Settings.Get()
if err != nil { checkErr(err)
return err
}
evt := args[0] evt := args[0]
i, err := strconv.Atoi(args[1]) i, err := strconv.Atoi(args[1])
if err != nil { checkErr(err)
return err
}
f := i f := i
if len(args) == 3 { if len(args) == 3 {
f, err = strconv.Atoi(args[2]) f, err = strconv.Atoi(args[2])
if err != nil { checkErr(err)
return err
}
} }
s.Commands[evt] = append(s.Commands[evt][:i], s.Commands[evt][f+1:]...) s.Commands[evt] = append(s.Commands[evt][:i], s.Commands[evt][f+1:]...)
err = st.Settings.Save(s) err = d.store.Settings.Save(s)
if err != nil { checkErr(err)
return err
}
printEvents(s.Commands) printEvents(s.Commands)
return nil }, pythonConfig{}),
}, storeOptions{}),
} }

View File

@ -2,7 +2,7 @@ package cmd
import ( import (
"encoding/json" "encoding/json"
"errors" nerrors "errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@ -12,7 +12,7 @@ import (
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/filebrowser/filebrowser/v2/auth" "github.com/filebrowser/filebrowser/v2/auth"
fberrors "github.com/filebrowser/filebrowser/v2/errors" "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
) )
@ -30,22 +30,14 @@ var configCmd = &cobra.Command{
func addConfigFlags(flags *pflag.FlagSet) { func addConfigFlags(flags *pflag.FlagSet) {
addServerFlags(flags) addServerFlags(flags)
addUserFlags(flags) addUserFlags(flags)
flags.BoolP("signup", "s", false, "allow users to signup") flags.BoolP("signup", "s", false, "allow users to signup")
flags.Bool("hideLoginButton", false, "hide login button from public pages") flags.Bool("create-user-dir", false, "generate user's home directory automatically")
flags.Bool("createUserDir", false, "generate user's home directory automatically") flags.Uint("minimum-password-length", settings.DefaultMinimumPasswordLength, "minimum password length for new users")
flags.Uint("minimumPasswordLength", settings.DefaultMinimumPasswordLength, "minimum password length for new users")
flags.String("shell", "", "shell command to which other commands should be appended") flags.String("shell", "", "shell command to which other commands should be appended")
// NB: these are string so they can be presented as octal in the help text
// as that's the conventional representation for modes in Unix.
flags.String("fileMode", fmt.Sprintf("%O", settings.DefaultFileMode), "mode bits that new files are created with")
flags.String("dirMode", fmt.Sprintf("%O", settings.DefaultDirMode), "mode bits that new directories are created with")
flags.String("auth.method", string(auth.MethodJSONAuth), "authentication type") flags.String("auth.method", string(auth.MethodJSONAuth), "authentication type")
flags.String("auth.header", "", "HTTP header for auth.method=proxy") flags.String("auth.header", "", "HTTP header for auth.method=proxy")
flags.String("auth.command", "", "command for auth.method=hook") flags.String("auth.command", "", "command for auth.method=hook")
flags.String("auth.logoutPage", "", "url of custom logout page")
flags.String("recaptcha.host", "https://www.google.com", "use another host for ReCAPTCHA. recaptcha.net might be useful in China") flags.String("recaptcha.host", "https://www.google.com", "use another host for ReCAPTCHA. recaptcha.net might be useful in China")
flags.String("recaptcha.key", "", "ReCaptcha site key") flags.String("recaptcha.key", "", "ReCaptcha site key")
@ -57,17 +49,11 @@ func addConfigFlags(flags *pflag.FlagSet) {
flags.String("branding.files", "", "path to directory with images and custom styles") flags.String("branding.files", "", "path to directory with images and custom styles")
flags.Bool("branding.disableExternal", false, "disable external links such as GitHub links") flags.Bool("branding.disableExternal", false, "disable external links such as GitHub links")
flags.Bool("branding.disableUsedPercentage", false, "disable used disk percentage graph") flags.Bool("branding.disableUsedPercentage", false, "disable used disk percentage graph")
flags.Uint64("tus.chunkSize", settings.DefaultTusChunkSize, "the tus chunk size")
flags.Uint16("tus.retryCount", settings.DefaultTusRetryCount, "the tus retry count")
} }
func getAuthMethod(flags *pflag.FlagSet, defaults ...interface{}) (settings.AuthMethod, map[string]interface{}, error) { //nolint:gocyclo
methodStr, err := flags.GetString("auth.method") func getAuthentication(flags *pflag.FlagSet, defaults ...interface{}) (settings.AuthMethod, auth.Auther) {
if err != nil { method := settings.AuthMethod(mustGetString(flags, "auth.method"))
return "", nil, err
}
method := settings.AuthMethod(methodStr)
var defaultAuther map[string]interface{} var defaultAuther map[string]interface{}
if len(defaults) > 0 { if len(defaults) > 0 {
@ -78,135 +64,90 @@ func getAuthMethod(flags *pflag.FlagSet, defaults ...interface{}) (settings.Auth
method = def.AuthMethod method = def.AuthMethod
case auth.Auther: case auth.Auther:
ms, err := json.Marshal(def) ms, err := json.Marshal(def)
if err != nil { checkErr(err)
return "", nil, err
}
err = json.Unmarshal(ms, &defaultAuther) err = json.Unmarshal(ms, &defaultAuther)
if err != nil { checkErr(err)
return "", nil, err
}
} }
} }
} }
} }
return method, defaultAuther, nil
}
func getProxyAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
header, err := flags.GetString("auth.header")
if err != nil {
return nil, err
}
if header == "" && defaultAuther != nil {
header = defaultAuther["header"].(string)
}
if header == "" {
return nil, errors.New("you must set the flag 'auth.header' for method 'proxy'")
}
return &auth.ProxyAuth{Header: header}, nil
}
func getNoAuth() auth.Auther {
return &auth.NoAuth{}
}
func getJSONAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
jsonAuth := &auth.JSONAuth{}
host, err := flags.GetString("recaptcha.host")
if err != nil {
return nil, err
}
key, err := flags.GetString("recaptcha.key")
if err != nil {
return nil, err
}
secret, err := flags.GetString("recaptcha.secret")
if err != nil {
return nil, err
}
if key == "" {
if kmap, ok := defaultAuther["recaptcha"].(map[string]interface{}); ok {
key = kmap["key"].(string)
}
}
if secret == "" {
if smap, ok := defaultAuther["recaptcha"].(map[string]interface{}); ok {
secret = smap["secret"].(string)
}
}
if key != "" && secret != "" {
jsonAuth.ReCaptcha = &auth.ReCaptcha{
Host: host,
Key: key,
Secret: secret,
}
}
return jsonAuth, nil
}
func getHookAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
command, err := flags.GetString("auth.command")
if err != nil {
return nil, err
}
if command == "" {
command = defaultAuther["command"].(string)
}
if command == "" {
return nil, errors.New("you must set the flag 'auth.command' for method 'hook'")
}
return &auth.HookAuth{Command: command}, nil
}
func getAuthentication(flags *pflag.FlagSet, defaults ...interface{}) (settings.AuthMethod, auth.Auther, error) {
method, defaultAuther, err := getAuthMethod(flags, defaults...)
if err != nil {
return "", nil, err
}
var auther auth.Auther var auther auth.Auther
switch method { if method == auth.MethodProxyAuth {
case auth.MethodProxyAuth: header := mustGetString(flags, "auth.header")
auther, err = getProxyAuth(flags, defaultAuther)
case auth.MethodNoAuth: if header == "" {
auther = getNoAuth() header = defaultAuther["header"].(string)
case auth.MethodJSONAuth: }
auther, err = getJSONAuth(flags, defaultAuther)
case auth.MethodHookAuth: if header == "" {
auther, err = getHookAuth(flags, defaultAuther) checkErr(nerrors.New("you must set the flag 'auth.header' for method 'proxy'"))
default: }
return "", nil, fberrors.ErrInvalidAuthMethod
auther = &auth.ProxyAuth{Header: header}
} }
if err != nil { if method == auth.MethodNoAuth {
return "", nil, err auther = &auth.NoAuth{}
} }
return method, auther, nil if method == auth.MethodJSONAuth {
jsonAuth := &auth.JSONAuth{}
host := mustGetString(flags, "recaptcha.host")
key := mustGetString(flags, "recaptcha.key")
secret := mustGetString(flags, "recaptcha.secret")
if key == "" {
if kmap, ok := defaultAuther["recaptcha"].(map[string]interface{}); ok {
key = kmap["key"].(string)
}
}
if secret == "" {
if smap, ok := defaultAuther["recaptcha"].(map[string]interface{}); ok {
secret = smap["secret"].(string)
}
}
if key != "" && secret != "" {
jsonAuth.ReCaptcha = &auth.ReCaptcha{
Host: host,
Key: key,
Secret: secret,
}
}
auther = jsonAuth
}
if method == auth.MethodHookAuth {
command := mustGetString(flags, "auth.command")
if command == "" {
command = defaultAuther["command"].(string)
}
if command == "" {
checkErr(nerrors.New("you must set the flag 'auth.command' for method 'hook'"))
}
auther = &auth.HookAuth{Command: command}
}
if auther == nil {
panic(errors.ErrInvalidAuthMethod)
}
return method, auther
} }
func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Auther) error { func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Auther) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "Sign up:\t%t\n", set.Signup) fmt.Fprintf(w, "Sign up:\t%t\n", set.Signup)
fmt.Fprintf(w, "Hide Login Button:\t%t\n", set.HideLoginButton)
fmt.Fprintf(w, "Create User Dir:\t%t\n", set.CreateUserDir) fmt.Fprintf(w, "Create User Dir:\t%t\n", set.CreateUserDir)
fmt.Fprintf(w, "Logout Page:\t%s\n", set.LogoutPage)
fmt.Fprintf(w, "Minimum Password Length:\t%d\n", set.MinimumPasswordLength) fmt.Fprintf(w, "Minimum Password Length:\t%d\n", set.MinimumPasswordLength)
fmt.Fprintf(w, "Auth Method:\t%s\n", set.AuthMethod) fmt.Fprintf(w, "Auth method:\t%s\n", set.AuthMethod)
fmt.Fprintf(w, "Shell:\t%s\t\n", strings.Join(set.Shell, " ")) fmt.Fprintf(w, "Shell:\t%s\t\n", strings.Join(set.Shell, " "))
fmt.Fprintln(w, "\nBranding:") fmt.Fprintln(w, "\nBranding:")
fmt.Fprintf(w, "\tName:\t%s\n", set.Branding.Name) fmt.Fprintf(w, "\tName:\t%s\n", set.Branding.Name)
fmt.Fprintf(w, "\tFiles override:\t%s\n", set.Branding.Files) fmt.Fprintf(w, "\tFiles override:\t%s\n", set.Branding.Files)
@ -214,7 +155,6 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Fprintf(w, "\tDisable used disk percentage graph:\t%t\n", set.Branding.DisableUsedPercentage) fmt.Fprintf(w, "\tDisable used disk percentage graph:\t%t\n", set.Branding.DisableUsedPercentage)
fmt.Fprintf(w, "\tColor:\t%s\n", set.Branding.Color) fmt.Fprintf(w, "\tColor:\t%s\n", set.Branding.Color)
fmt.Fprintf(w, "\tTheme:\t%s\n", set.Branding.Theme) fmt.Fprintf(w, "\tTheme:\t%s\n", set.Branding.Theme)
fmt.Fprintln(w, "\nServer:") fmt.Fprintln(w, "\nServer:")
fmt.Fprintf(w, "\tLog:\t%s\n", ser.Log) fmt.Fprintf(w, "\tLog:\t%s\n", ser.Log)
fmt.Fprintf(w, "\tPort:\t%s\n", ser.Port) fmt.Fprintf(w, "\tPort:\t%s\n", ser.Port)
@ -224,32 +164,16 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Fprintf(w, "\tAddress:\t%s\n", ser.Address) fmt.Fprintf(w, "\tAddress:\t%s\n", ser.Address)
fmt.Fprintf(w, "\tTLS Cert:\t%s\n", ser.TLSCert) fmt.Fprintf(w, "\tTLS Cert:\t%s\n", ser.TLSCert)
fmt.Fprintf(w, "\tTLS Key:\t%s\n", ser.TLSKey) fmt.Fprintf(w, "\tTLS Key:\t%s\n", ser.TLSKey)
fmt.Fprintf(w, "\tToken Expiration Time:\t%s\n", ser.TokenExpirationTime)
fmt.Fprintf(w, "\tExec Enabled:\t%t\n", ser.EnableExec) fmt.Fprintf(w, "\tExec Enabled:\t%t\n", ser.EnableExec)
fmt.Fprintf(w, "\tThumbnails Enabled:\t%t\n", ser.EnableThumbnails)
fmt.Fprintf(w, "\tResize Preview:\t%t\n", ser.ResizePreview)
fmt.Fprintf(w, "\tType Detection by Header:\t%t\n", ser.TypeDetectionByHeader)
fmt.Fprintln(w, "\nTUS:")
fmt.Fprintf(w, "\tChunk size:\t%d\n", set.Tus.ChunkSize)
fmt.Fprintf(w, "\tRetry count:\t%d\n", set.Tus.RetryCount)
fmt.Fprintln(w, "\nDefaults:") fmt.Fprintln(w, "\nDefaults:")
fmt.Fprintf(w, "\tScope:\t%s\n", set.Defaults.Scope) fmt.Fprintf(w, "\tScope:\t%s\n", set.Defaults.Scope)
fmt.Fprintf(w, "\tHideDotfiles:\t%t\n", set.Defaults.HideDotfiles)
fmt.Fprintf(w, "\tLocale:\t%s\n", set.Defaults.Locale) fmt.Fprintf(w, "\tLocale:\t%s\n", set.Defaults.Locale)
fmt.Fprintf(w, "\tView mode:\t%s\n", set.Defaults.ViewMode) fmt.Fprintf(w, "\tView mode:\t%s\n", set.Defaults.ViewMode)
fmt.Fprintf(w, "\tSingle Click:\t%t\n", set.Defaults.SingleClick) fmt.Fprintf(w, "\tSingle Click:\t%t\n", set.Defaults.SingleClick)
fmt.Fprintf(w, "\tRedirect after Copy/Move:\t%t\n", set.Defaults.RedirectAfterCopyMove)
fmt.Fprintf(w, "\tFile Creation Mode:\t%O\n", set.FileMode)
fmt.Fprintf(w, "\tDirectory Creation Mode:\t%O\n", set.DirMode)
fmt.Fprintf(w, "\tCommands:\t%s\n", strings.Join(set.Defaults.Commands, " ")) fmt.Fprintf(w, "\tCommands:\t%s\n", strings.Join(set.Defaults.Commands, " "))
fmt.Fprintf(w, "\tAce editor syntax highlighting theme:\t%s\n", set.Defaults.AceEditorTheme)
fmt.Fprintf(w, "\tSorting:\n") fmt.Fprintf(w, "\tSorting:\n")
fmt.Fprintf(w, "\t\tBy:\t%s\n", set.Defaults.Sorting.By) fmt.Fprintf(w, "\t\tBy:\t%s\n", set.Defaults.Sorting.By)
fmt.Fprintf(w, "\t\tAsc:\t%t\n", set.Defaults.Sorting.Asc) fmt.Fprintf(w, "\t\tAsc:\t%t\n", set.Defaults.Sorting.Asc)
fmt.Fprintf(w, "\tPermissions:\n") fmt.Fprintf(w, "\tPermissions:\n")
fmt.Fprintf(w, "\t\tAdmin:\t%t\n", set.Defaults.Perm.Admin) fmt.Fprintf(w, "\t\tAdmin:\t%t\n", set.Defaults.Perm.Admin)
fmt.Fprintf(w, "\t\tExecute:\t%t\n", set.Defaults.Perm.Execute) fmt.Fprintf(w, "\t\tExecute:\t%t\n", set.Defaults.Perm.Execute)
@ -259,133 +183,9 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Fprintf(w, "\t\tDelete:\t%t\n", set.Defaults.Perm.Delete) fmt.Fprintf(w, "\t\tDelete:\t%t\n", set.Defaults.Perm.Delete)
fmt.Fprintf(w, "\t\tShare:\t%t\n", set.Defaults.Perm.Share) fmt.Fprintf(w, "\t\tShare:\t%t\n", set.Defaults.Perm.Share)
fmt.Fprintf(w, "\t\tDownload:\t%t\n", set.Defaults.Perm.Download) fmt.Fprintf(w, "\t\tDownload:\t%t\n", set.Defaults.Perm.Download)
w.Flush() w.Flush()
b, err := json.MarshalIndent(auther, "", " ") b, err := json.MarshalIndent(auther, "", " ")
if err != nil { checkErr(err)
return err
}
fmt.Printf("\nAuther configuration (raw):\n\n%s\n\n", string(b)) fmt.Printf("\nAuther configuration (raw):\n\n%s\n\n", string(b))
return nil
}
func getSettings(flags *pflag.FlagSet, set *settings.Settings, ser *settings.Server, auther auth.Auther, all bool) (auth.Auther, error) {
errs := []error{}
hasAuth := false
visit := func(flag *pflag.Flag) {
var err error
switch flag.Name {
// Server flags from [addServerFlags]
case "address":
ser.Address, err = flags.GetString(flag.Name)
case "log":
ser.Log, err = flags.GetString(flag.Name)
case "port":
ser.Port, err = flags.GetString(flag.Name)
case "cert":
ser.TLSCert, err = flags.GetString(flag.Name)
case "key":
ser.TLSKey, err = flags.GetString(flag.Name)
case "root":
ser.Root, err = flags.GetString(flag.Name)
case "socket":
ser.Socket, err = flags.GetString(flag.Name)
case "baseURL":
ser.BaseURL, err = flags.GetString(flag.Name)
case "tokenExpirationTime":
ser.TokenExpirationTime, err = flags.GetString(flag.Name)
case "disableThumbnails":
ser.EnableThumbnails, err = flags.GetBool(flag.Name)
ser.EnableThumbnails = !ser.EnableThumbnails
case "disablePreviewResize":
ser.ResizePreview, err = flags.GetBool(flag.Name)
ser.ResizePreview = !ser.ResizePreview
case "disableExec":
ser.EnableExec, err = flags.GetBool(flag.Name)
ser.EnableExec = !ser.EnableExec
case "disableTypeDetectionByHeader":
ser.TypeDetectionByHeader, err = flags.GetBool(flag.Name)
ser.TypeDetectionByHeader = !ser.TypeDetectionByHeader
case "disableImageResolutionCalc":
ser.ImageResolutionCal, err = flags.GetBool(flag.Name)
ser.ImageResolutionCal = !ser.ImageResolutionCal
// Settings flags from [addConfigFlags]
case "signup":
set.Signup, err = flags.GetBool(flag.Name)
case "hideLoginButton":
set.HideLoginButton, err = flags.GetBool(flag.Name)
case "createUserDir":
set.CreateUserDir, err = flags.GetBool(flag.Name)
case "minimumPasswordLength":
set.MinimumPasswordLength, err = flags.GetUint(flag.Name)
case "shell":
var shell string
shell, err = flags.GetString(flag.Name)
if err == nil {
set.Shell = convertCmdStrToCmdArray(shell)
}
case "fileMode":
set.FileMode, err = getAndParseFileMode(flags, flag.Name)
case "dirMode":
set.DirMode, err = getAndParseFileMode(flags, flag.Name)
case "auth.method":
hasAuth = true
case "auth.logoutPage":
set.LogoutPage, err = flags.GetString(flag.Name)
case "branding.name":
set.Branding.Name, err = flags.GetString(flag.Name)
case "branding.theme":
set.Branding.Theme, err = flags.GetString(flag.Name)
case "branding.color":
set.Branding.Color, err = flags.GetString(flag.Name)
case "branding.files":
set.Branding.Files, err = flags.GetString(flag.Name)
case "branding.disableExternal":
set.Branding.DisableExternal, err = flags.GetBool(flag.Name)
case "branding.disableUsedPercentage":
set.Branding.DisableUsedPercentage, err = flags.GetBool(flag.Name)
case "tus.chunkSize":
set.Tus.ChunkSize, err = flags.GetUint64(flag.Name)
case "tus.retryCount":
set.Tus.RetryCount, err = flags.GetUint16(flag.Name)
}
if err != nil {
errs = append(errs, err)
}
}
if all {
flags.VisitAll(visit)
} else {
flags.Visit(visit)
}
err := errors.Join(errs...)
if err != nil {
return nil, err
}
err = getUserDefaults(flags, &set.Defaults, all)
if err != nil {
return nil, err
}
if all {
set.AuthMethod, auther, err = getAuthentication(flags)
if err != nil {
return nil, err
}
} else {
set.AuthMethod, auther, err = getAuthentication(flags, hasAuth, set, auther)
if err != nil {
return nil, err
}
}
return auther, nil
} }

View File

@ -13,19 +13,13 @@ var configCatCmd = &cobra.Command{
Short: "Prints the configuration", Short: "Prints the configuration",
Long: `Prints the configuration.`, Long: `Prints the configuration.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: withStore(func(_ *cobra.Command, _ []string, st *store) error { Run: python(func(_ *cobra.Command, _ []string, d pythonData) {
set, err := st.Settings.Get() set, err := d.store.Settings.Get()
if err != nil { checkErr(err)
return err ser, err := d.store.Settings.GetServer()
} checkErr(err)
ser, err := st.Settings.GetServer() auther, err := d.store.Auth.Get(set.AuthMethod)
if err != nil { checkErr(err)
return err printSettings(ser, set, auther)
} }, pythonConfig{}),
auther, err := st.Auth.Get(set.AuthMethod)
if err != nil {
return err
}
return printSettings(ser, set, auther)
}, storeOptions{}),
} }

View File

@ -15,21 +15,15 @@ var configExportCmd = &cobra.Command{
json or yaml file. This exported configuration can be changed, json or yaml file. This exported configuration can be changed,
and imported again with 'config import' command.`, and imported again with 'config import' command.`,
Args: jsonYamlArg, Args: jsonYamlArg,
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error { Run: python(func(_ *cobra.Command, args []string, d pythonData) {
settings, err := st.Settings.Get() settings, err := d.store.Settings.Get()
if err != nil { checkErr(err)
return err
}
server, err := st.Settings.GetServer() server, err := d.store.Settings.GetServer()
if err != nil { checkErr(err)
return err
}
auther, err := st.Auth.Get(settings.AuthMethod) auther, err := d.store.Auth.Get(settings.AuthMethod)
if err != nil { checkErr(err)
return err
}
data := &settingsFile{ data := &settingsFile{
Settings: settings, Settings: settings,
@ -38,9 +32,6 @@ and imported again with 'config import' command.`,
} }
err = marshal(args[0], data) err = marshal(args[0], data)
if err != nil { checkErr(err)
return err }, pythonConfig{}),
}
return nil
}, storeOptions{}),
} }

View File

@ -34,39 +34,26 @@ database.
The path must be for a json or yaml file.`, The path must be for a json or yaml file.`,
Args: jsonYamlArg, Args: jsonYamlArg,
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error { Run: python(func(_ *cobra.Command, args []string, d pythonData) {
var key []byte var key []byte
var err error if d.hadDB {
if st.databaseExisted { settings, err := d.store.Settings.Get()
settings, settingErr := st.Settings.Get() checkErr(err)
if settingErr != nil {
return settingErr
}
key = settings.Key key = settings.Key
} else { } else {
key = generateKey() key = generateKey()
} }
file := settingsFile{} file := settingsFile{}
err = unmarshal(args[0], &file) err := unmarshal(args[0], &file)
if err != nil { checkErr(err)
return err
}
if file.Settings == nil || file.Server == nil {
return errors.New("invalid configuration file: 'settings' or 'server' fields are missing. Please ensure you are importing a file generated by the 'config export' command")
}
file.Settings.Key = key file.Settings.Key = key
err = st.Settings.Save(file.Settings) err = d.store.Settings.Save(file.Settings)
if err != nil { checkErr(err)
return err
}
err = st.Settings.SaveServer(file.Server) err = d.store.Settings.SaveServer(file.Server)
if err != nil { checkErr(err)
return err
}
var rawAuther interface{} var rawAuther interface{}
if filepath.Ext(args[0]) != ".json" { if filepath.Ext(args[0]) != ".json" {
@ -76,51 +63,32 @@ The path must be for a json or yaml file.`,
} }
var auther auth.Auther var auther auth.Auther
var autherErr error
switch file.Settings.AuthMethod { switch file.Settings.AuthMethod {
case auth.MethodJSONAuth: case auth.MethodJSONAuth:
var a interface{} auther = getAuther(auth.JSONAuth{}, rawAuther).(*auth.JSONAuth)
a, autherErr = getAuther(auth.JSONAuth{}, rawAuther)
auther = a.(*auth.JSONAuth)
case auth.MethodNoAuth: case auth.MethodNoAuth:
var a interface{} auther = getAuther(auth.NoAuth{}, rawAuther).(*auth.NoAuth)
a, autherErr = getAuther(auth.NoAuth{}, rawAuther)
auther = a.(*auth.NoAuth)
case auth.MethodProxyAuth: case auth.MethodProxyAuth:
var a interface{} auther = getAuther(auth.ProxyAuth{}, rawAuther).(*auth.ProxyAuth)
a, autherErr = getAuther(auth.ProxyAuth{}, rawAuther)
auther = a.(*auth.ProxyAuth)
case auth.MethodHookAuth: case auth.MethodHookAuth:
var a interface{} auther = getAuther(&auth.HookAuth{}, rawAuther).(*auth.HookAuth)
a, autherErr = getAuther(&auth.HookAuth{}, rawAuther)
auther = a.(*auth.HookAuth)
default: default:
return errors.New("invalid auth method") checkErr(errors.New("invalid auth method"))
} }
if autherErr != nil { err = d.store.Auth.Save(auther)
return autherErr checkErr(err)
}
err = st.Auth.Save(auther) printSettings(file.Server, file.Settings, auther)
if err != nil { }, pythonConfig{allowNoDB: true}),
return err
}
return printSettings(file.Server, file.Settings, auther)
}, storeOptions{allowsNoDatabase: true}),
} }
func getAuther(sample auth.Auther, data interface{}) (interface{}, error) { func getAuther(sample auth.Auther, data interface{}) interface{} {
authType := reflect.TypeOf(sample) authType := reflect.TypeOf(sample)
auther := reflect.New(authType).Interface() auther := reflect.New(authType).Interface()
bytes, err := json.Marshal(data) bytes, err := json.Marshal(data)
if err != nil { checkErr(err)
return nil, err
}
err = json.Unmarshal(bytes, &auther) err = json.Unmarshal(bytes, &auther)
if err != nil { checkErr(err)
return nil, err return auther
}
return auther, nil
} }

View File

@ -22,40 +22,52 @@ this options can be changed in the future with the command
to the defaults when creating new users and you don't to the defaults when creating new users and you don't
override the options.`, override the options.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error { Run: python(func(cmd *cobra.Command, _ []string, d pythonData) {
defaults := settings.UserDefaults{}
flags := cmd.Flags() flags := cmd.Flags()
getUserDefaults(flags, &defaults, true)
authMethod, auther := getAuthentication(flags)
// Initialize config s := &settings.Settings{
s := &settings.Settings{Key: generateKey()} Key: generateKey(),
ser := &settings.Server{} Signup: mustGetBool(flags, "signup"),
CreateUserDir: mustGetBool(flags, "create-user-dir"),
// Fill config with options MinimumPasswordLength: mustGetUint(flags, "minimum-password-length"),
auther, err := getSettings(flags, s, ser, nil, true) Shell: convertCmdStrToCmdArray(mustGetString(flags, "shell")),
if err != nil { AuthMethod: authMethod,
return err Defaults: defaults,
Branding: settings.Branding{
Name: mustGetString(flags, "branding.name"),
DisableExternal: mustGetBool(flags, "branding.disableExternal"),
DisableUsedPercentage: mustGetBool(flags, "branding.disableUsedPercentage"),
Theme: mustGetString(flags, "branding.theme"),
Files: mustGetString(flags, "branding.files"),
},
} }
// Save updated config ser := &settings.Server{
err = st.Settings.Save(s) Address: mustGetString(flags, "address"),
if err != nil { Socket: mustGetString(flags, "socket"),
return err Root: mustGetString(flags, "root"),
BaseURL: mustGetString(flags, "baseurl"),
TLSKey: mustGetString(flags, "key"),
TLSCert: mustGetString(flags, "cert"),
Port: mustGetString(flags, "port"),
Log: mustGetString(flags, "log"),
} }
err = st.Settings.SaveServer(ser) err := d.store.Settings.Save(s)
if err != nil { checkErr(err)
return err err = d.store.Settings.SaveServer(ser)
} checkErr(err)
err = d.store.Auth.Save(auther)
err = st.Auth.Save(auther) checkErr(err)
if err != nil {
return err
}
fmt.Printf(` fmt.Printf(`
Congratulations! You've set up your database to use with File Browser. Congratulations! You've set up your database to use with File Browser.
Now add your first user via 'filebrowser users add' and then you just Now add your first user via 'filebrowser users add' and then you just
need to call the main command to boot up the server. need to call the main command to boot up the server.
`) `)
return printSettings(ser, s, auther) printSettings(ser, s, auther)
}, storeOptions{expectsNoDatabase: true}), }, pythonConfig{noDB: true}),
} }

View File

@ -2,6 +2,7 @@ package cmd
import ( import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
func init() { func init() {
@ -15,47 +16,73 @@ var configSetCmd = &cobra.Command{
Long: `Updates the configuration. Set the flags for the options Long: `Updates the configuration. Set the flags for the options
you want to change. Other options will remain unchanged.`, you want to change. Other options will remain unchanged.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error { Run: python(func(cmd *cobra.Command, _ []string, d pythonData) {
flags := cmd.Flags() flags := cmd.Flags()
set, err := d.store.Settings.Get()
checkErr(err)
// Read existing config ser, err := d.store.Settings.GetServer()
set, err := st.Settings.Get() checkErr(err)
if err != nil {
return err
}
ser, err := st.Settings.GetServer() hasAuth := false
if err != nil { flags.Visit(func(flag *pflag.Flag) {
return err switch flag.Name {
} case "baseurl":
ser.BaseURL = mustGetString(flags, flag.Name)
case "root":
ser.Root = mustGetString(flags, flag.Name)
case "socket":
ser.Socket = mustGetString(flags, flag.Name)
case "cert":
ser.TLSCert = mustGetString(flags, flag.Name)
case "key":
ser.TLSKey = mustGetString(flags, flag.Name)
case "address":
ser.Address = mustGetString(flags, flag.Name)
case "port":
ser.Port = mustGetString(flags, flag.Name)
case "log":
ser.Log = mustGetString(flags, flag.Name)
case "signup":
set.Signup = mustGetBool(flags, flag.Name)
case "auth.method":
hasAuth = true
case "shell":
set.Shell = convertCmdStrToCmdArray(mustGetString(flags, flag.Name))
case "create-user-dir":
set.CreateUserDir = mustGetBool(flags, flag.Name)
case "minimum-password-length":
set.MinimumPasswordLength = mustGetUint(flags, flag.Name)
case "branding.name":
set.Branding.Name = mustGetString(flags, flag.Name)
case "branding.color":
set.Branding.Color = mustGetString(flags, flag.Name)
case "branding.theme":
set.Branding.Theme = mustGetString(flags, flag.Name)
case "branding.disableExternal":
set.Branding.DisableExternal = mustGetBool(flags, flag.Name)
case "branding.disableUsedPercentage":
set.Branding.DisableUsedPercentage = mustGetBool(flags, flag.Name)
case "branding.files":
set.Branding.Files = mustGetString(flags, flag.Name)
}
})
auther, err := st.Auth.Get(set.AuthMethod) getUserDefaults(flags, &set.Defaults, false)
if err != nil {
return err
}
// Get updated config // read the defaults
auther, err = getSettings(flags, set, ser, auther, false) auther, err := d.store.Auth.Get(set.AuthMethod)
if err != nil { checkErr(err)
return err
}
// Save updated config // check if there are new flags for existing auth method
err = st.Auth.Save(auther) set.AuthMethod, auther = getAuthentication(flags, hasAuth, set, auther)
if err != nil {
return err
}
err = st.Settings.Save(set) err = d.store.Auth.Save(auther)
if err != nil { checkErr(err)
return err err = d.store.Settings.Save(set)
} checkErr(err)
err = d.store.Settings.SaveServer(ser)
err = st.Settings.SaveServer(ser) checkErr(err)
if err != nil { printSettings(ser, set, auther)
return err }, pythonConfig{}),
}
return printSettings(ser, set, auther)
}, storeOptions{}),
} }

View File

@ -3,80 +3,137 @@ package cmd
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io"
"os" "os"
"path" "path/filepath"
"regexp" "sort"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/cobra/doc" "github.com/spf13/pflag"
) )
func init() { func init() {
rootCmd.AddCommand(docsCmd) rootCmd.AddCommand(docsCmd)
docsCmd.Flags().String("out", "www/docs/cli", "directory to write the docs to") docsCmd.Flags().StringP("path", "p", "./docs", "path to save the docs")
}
func printToc(names []string) {
for i, name := range names {
name = strings.TrimSuffix(name, filepath.Ext(name))
name = strings.Replace(name, "-", " ", -1)
names[i] = name
}
sort.Strings(names)
toc := ""
for _, name := range names {
toc += "* [" + name + "](cli/" + strings.Replace(name, " ", "-", -1) + ".md)\n"
}
fmt.Println(toc)
} }
var docsCmd = &cobra.Command{ var docsCmd = &cobra.Command{
Use: "docs", Use: "docs",
Hidden: true, Hidden: true,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { Run: func(cmd *cobra.Command, _ []string) {
outputDir, err := cmd.Flags().GetString("out") dir := mustGetString(cmd.Flags(), "path")
if err != nil { generateDocs(rootCmd, dir)
return err names := []string{}
}
tempDir, err := os.MkdirTemp(os.TempDir(), "filebrowser-docs-") err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil { if err != nil || info.IsDir() {
return err return err
} }
defer os.RemoveAll(tempDir)
rootCmd.Root().DisableAutoGenTag = true if !strings.HasPrefix(info.Name(), "filebrowser") {
return nil
}
err = doc.GenMarkdownTreeCustom(cmd.Root(), tempDir, func(_ string) string { names = append(names, info.Name())
return "" return nil
}, func(s string) string {
return s
}) })
if err != nil {
return err
}
entries, err := os.ReadDir(tempDir) checkErr(err)
if err != nil { printToc(names)
return err
}
headerRegex := regexp.MustCompile(`(?m)^(##)(.*)$`)
linkRegex := regexp.MustCompile(`\(filebrowser(.*)\.md\)`)
fmt.Println("Generated Documents:")
for _, entry := range entries {
srcPath := path.Join(tempDir, entry.Name())
dstPath := path.Join(outputDir, strings.ReplaceAll(entry.Name(), "_", "-"))
data, err := os.ReadFile(srcPath)
if err != nil {
return err
}
data = headerRegex.ReplaceAll(data, []byte("#$2"))
data = linkRegex.ReplaceAllFunc(data, func(b []byte) []byte {
return bytes.ReplaceAll(b, []byte("_"), []byte("-"))
})
data = bytes.ReplaceAll(data, []byte("## SEE ALSO"), []byte("## See Also"))
err = os.WriteFile(dstPath, data, 0666)
if err != nil {
return err
}
fmt.Println("- " + dstPath)
}
return nil
}, },
} }
func generateDocs(cmd *cobra.Command, dir string) {
for _, c := range cmd.Commands() {
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
continue
}
generateDocs(c, dir)
}
basename := strings.Replace(cmd.CommandPath(), " ", "-", -1) + ".md"
filename := filepath.Join(dir, basename)
f, err := os.Create(filename)
checkErr(err)
defer f.Close()
generateMarkdown(cmd, f)
}
func generateMarkdown(cmd *cobra.Command, w io.Writer) {
cmd.InitDefaultHelpCmd()
cmd.InitDefaultHelpFlag()
buf := new(bytes.Buffer)
name := cmd.CommandPath()
short := cmd.Short
long := cmd.Long
if long == "" {
long = short
}
buf.WriteString("---\ndescription: " + short + "\n---\n\n")
buf.WriteString("# " + name + "\n\n")
buf.WriteString("## Synopsis\n\n")
buf.WriteString(long + "\n\n")
if cmd.Runnable() {
_, _ = fmt.Fprintf(buf, "```\n%s\n```\n\n", cmd.UseLine())
}
if cmd.Example != "" {
buf.WriteString("## Examples\n\n")
_, _ = fmt.Fprintf(buf, "```\n%s\n```\n\n", cmd.Example)
}
printOptions(buf, cmd)
_, err := buf.WriteTo(w)
checkErr(err)
}
func generateFlagsTable(fs *pflag.FlagSet, buf io.StringWriter) {
_, _ = buf.WriteString("| Name | Shorthand | Usage |\n")
_, _ = buf.WriteString("|------|-----------|-------|\n")
fs.VisitAll(func(f *pflag.Flag) {
_, _ = buf.WriteString("|" + f.Name + "|" + f.Shorthand + "|" + f.Usage + "|\n")
})
}
func printOptions(buf *bytes.Buffer, cmd *cobra.Command) {
flags := cmd.NonInheritedFlags()
flags.SetOutput(buf)
if flags.HasAvailableFlags() {
buf.WriteString("## Options\n\n")
generateFlagsTable(flags, buf)
buf.WriteString("\n")
}
parentFlags := cmd.InheritedFlags()
parentFlags.SetOutput(buf)
if parentFlags.HasAvailableFlags() {
buf.WriteString("### Inherited\n\n")
generateFlagsTable(parentFlags, buf)
buf.WriteString("\n")
}
}

View File

@ -17,12 +17,9 @@ var hashCmd = &cobra.Command{
Short: "Hashes a password", Short: "Hashes a password",
Long: `Hashes a password using bcrypt algorithm.`, Long: `Hashes a password using bcrypt algorithm.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error { Run: func(_ *cobra.Command, args []string) {
pwd, err := users.HashPwd(args[0]) pwd, err := users.HashPwd(args[0])
if err != nil { checkErr(err)
return err
}
fmt.Println(pwd) fmt.Println(pwd)
return nil
}, },
} }

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt"
"io" "io"
"io/fs" "io/fs"
"log" "log"
@ -13,13 +12,15 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
homedir "github.com/mitchellh/go-homedir"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/spf13/viper" v "github.com/spf13/viper"
lumberjack "gopkg.in/natefinch/lumberjack.v2" lumberjack "gopkg.in/natefinch/lumberjack.v2"
"github.com/filebrowser/filebrowser/v2/auth" "github.com/filebrowser/filebrowser/v2/auth"
@ -33,67 +34,27 @@ import (
) )
var ( var (
flagNamesMigrations = map[string]string{ cfgFile string
"file-mode": "fileMode",
"dir-mode": "dirMode",
"hide-login-button": "hideLoginButton",
"create-user-dir": "createUserDir",
"minimum-password-length": "minimumPasswordLength",
"socket-perm": "socketPerm",
"disable-thumbnails": "disableThumbnails",
"disable-preview-resize": "disablePreviewResize",
"disable-exec": "disableExec",
"disable-type-detection-by-header": "disableTypeDetectionByHeader",
"img-processors": "imageProcessors",
"cache-dir": "cacheDir",
"token-expiration-time": "tokenExpirationTime",
"baseurl": "baseURL",
}
warnedFlags = map[string]bool{}
) )
// TODO(remove): remove after July 2026.
func migrateFlagNames(_ *pflag.FlagSet, name string) pflag.NormalizedName {
if newName, ok := flagNamesMigrations[name]; ok {
if !warnedFlags[name] {
warnedFlags[name] = true
log.Printf("DEPRECATION NOTICE: Flag --%s has been deprecated, use --%s instead\n", name, newName)
}
name = newName
}
return pflag.NormalizedName(name)
}
func init() { func init() {
rootCmd.SilenceUsage = true cobra.OnInitialize(initConfig)
rootCmd.SetGlobalNormalizationFunc(migrateFlagNames)
cobra.MousetrapHelpText = "" cobra.MousetrapHelpText = ""
rootCmd.SetVersionTemplate("File Browser version {{printf \"%s\" .Version}}\n") rootCmd.SetVersionTemplate("File Browser version {{printf \"%s\" .Version}}\n")
// Flags available across the whole program
persistent := rootCmd.PersistentFlags()
persistent.StringP("config", "c", "", "config file path")
persistent.StringP("database", "d", "./filebrowser.db", "database path")
// Runtime flags for the root command
flags := rootCmd.Flags() flags := rootCmd.Flags()
persistent := rootCmd.PersistentFlags()
persistent.StringVarP(&cfgFile, "config", "c", "", "config file path")
persistent.StringP("database", "d", "./filebrowser.db", "database path")
flags.Bool("noauth", false, "use the noauth auther when using quick setup") flags.Bool("noauth", false, "use the noauth auther when using quick setup")
flags.String("username", "admin", "username for the first user when using quick setup") flags.String("username", "admin", "username for the first user when using quick config")
flags.String("password", "", "hashed password for the first user when using quick setup") flags.String("password", "", "hashed password for the first user when using quick config")
flags.Uint32("socketPerm", 0666, "unix socket file permissions")
flags.String("cacheDir", "", "file cache directory (disabled if empty)")
flags.Int("imageProcessors", 4, "image processors count")
addServerFlags(flags) addServerFlags(flags)
} }
// addServerFlags adds server related flags to the given FlagSet. These flags are available
// in both the root command, config set and config init commands.
func addServerFlags(flags *pflag.FlagSet) { func addServerFlags(flags *pflag.FlagSet) {
flags.StringP("address", "a", "127.0.0.1", "address to listen on") flags.StringP("address", "a", "127.0.0.1", "address to listen on")
flags.StringP("log", "l", "stdout", "log output") flags.StringP("log", "l", "stdout", "log output")
@ -102,13 +63,15 @@ func addServerFlags(flags *pflag.FlagSet) {
flags.StringP("key", "k", "", "tls key") flags.StringP("key", "k", "", "tls key")
flags.StringP("root", "r", ".", "root to prepend to relative paths") flags.StringP("root", "r", ".", "root to prepend to relative paths")
flags.String("socket", "", "socket to listen to (cannot be used with address, port, cert nor key flags)") flags.String("socket", "", "socket to listen to (cannot be used with address, port, cert nor key flags)")
flags.StringP("baseURL", "b", "", "base url") flags.Uint32("socket-perm", 0666, "unix socket file permissions")
flags.String("tokenExpirationTime", "2h", "user session timeout") flags.StringP("baseurl", "b", "", "base url")
flags.Bool("disableThumbnails", false, "disable image thumbnails") flags.String("cache-dir", "", "file cache directory (disabled if empty)")
flags.Bool("disablePreviewResize", false, "disable resize of image previews") flags.String("token-expiration-time", "2h", "user session timeout")
flags.Bool("disableExec", true, "disables Command Runner feature") flags.Int("img-processors", 4, "image processors count") //nolint:mnd
flags.Bool("disableTypeDetectionByHeader", false, "disables type detection by reading file headers") flags.Bool("disable-thumbnails", false, "disable image thumbnails")
flags.Bool("disableImageResolutionCalc", false, "disables image resolution calculation by reading image files") flags.Bool("disable-preview-resize", false, "disable resize of image previews")
flags.Bool("disable-exec", true, "disables Command Runner feature")
flags.Bool("disable-type-detection-by-header", false, "disables type detection by reading file headers")
} }
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@ -123,69 +86,62 @@ it. Don't worry: you don't need to setup a separate database server.
We're using Bolt DB which is a single file database and all managed We're using Bolt DB which is a single file database and all managed
by ourselves. by ourselves.
For this command, all flags are available as environmental variables, For this specific command, all the flags you have available (except
except for "--config", which specifies the configuration file to use. "config" for the configuration file), can be given either through
The environment variables are prefixed by "FB_" followed by the flag name in environment variables or configuration files.
UPPER_SNAKE_CASE. For example, the flag "--disablePreviewResize" is available
as FB_DISABLE_PREVIEW_RESIZE.
If "--config" is not specified, File Browser will look for a configuration If you don't set "config", it will look for a configuration file called
file named .filebrowser.{json, toml, yaml, yml} in the following directories: .filebrowser.{json, toml, yaml, yml} in the following directories:
- ./ - ./
- $HOME/ - $HOME/
- /etc/filebrowser/ - /etc/filebrowser/
**Note:** Only the options listed below can be set via the config file or
environment variables. Other configuration options live exclusively in the
database and so they must be set by the "config set" or "config
import" commands.
The precedence of the configuration values are as follows: The precedence of the configuration values are as follows:
- Flags - flags
- Environment variables - environment variables
- Configuration file - configuration file
- Database values - database values
- Defaults - defaults
The environment variables are prefixed by "FB_" followed by the option
name in caps. So to set "database" via an env variable, you should
set FB_DATABASE.
Also, if the database path doesn't exist, File Browser will enter into Also, if the database path doesn't exist, File Browser will enter into
the quick setup mode and a new database will be bootstrapped and a new the quick setup mode and a new database will be bootstrapped and a new
user created with the credentials from options "username" and "password".`, user created with the credentials from options "username" and "password".`,
RunE: withViperAndStore(func(_ *cobra.Command, _ []string, v *viper.Viper, st *store) error { Run: python(func(cmd *cobra.Command, _ []string, d pythonData) {
if !st.databaseExisted { log.Println(cfgFile)
err := quickSetup(v, st.Storage)
if err != nil { if !d.hadDB {
return err quickSetup(cmd.Flags(), d)
}
} }
// build img service // build img service
imgWorkersCount := v.GetInt("imageProcessors") workersCount, err := cmd.Flags().GetInt("img-processors")
if imgWorkersCount < 1 { checkErr(err)
return errors.New("image resize workers count could not be < 1") if workersCount < 1 {
log.Fatal("Image resize workers count could not be < 1")
} }
imageService := img.New(imgWorkersCount) imgSvc := img.New(workersCount)
var fileCache diskcache.Interface = diskcache.NewNoOp() var fileCache diskcache.Interface = diskcache.NewNoOp()
cacheDir := v.GetString("cacheDir") cacheDir, err := cmd.Flags().GetString("cache-dir")
checkErr(err)
if cacheDir != "" { if cacheDir != "" {
if err := os.MkdirAll(cacheDir, 0700); err != nil { if err := os.MkdirAll(cacheDir, 0700); err != nil { //nolint:govet
return fmt.Errorf("can't make directory %s: %w", cacheDir, err) log.Fatalf("can't make directory %s: %s", cacheDir, err)
} }
fileCache = diskcache.New(afero.NewOsFs(), cacheDir) fileCache = diskcache.New(afero.NewOsFs(), cacheDir)
} }
server, err := getServerSettings(v, st.Storage) server := getRunParams(cmd.Flags(), d.store)
if err != nil {
return err
}
setupLog(server.Log) setupLog(server.Log)
root, err := filepath.Abs(server.Root) root, err := filepath.Abs(server.Root)
if err != nil { checkErr(err)
return err
}
server.Root = root server.Root = root
adr := server.Address + ":" + server.Port adr := server.Address + ":" + server.Port
@ -195,31 +151,22 @@ user created with the credentials from options "username" and "password".`,
switch { switch {
case server.Socket != "": case server.Socket != "":
listener, err = net.Listen("unix", server.Socket) listener, err = net.Listen("unix", server.Socket)
if err != nil { checkErr(err)
return err socketPerm, err := cmd.Flags().GetUint32("socket-perm") //nolint:govet
} checkErr(err)
socketPerm := v.GetUint32("socketPerm")
err = os.Chmod(server.Socket, os.FileMode(socketPerm)) err = os.Chmod(server.Socket, os.FileMode(socketPerm))
if err != nil { checkErr(err)
return err
}
case server.TLSKey != "" && server.TLSCert != "": case server.TLSKey != "" && server.TLSCert != "":
cer, err := tls.LoadX509KeyPair(server.TLSCert, server.TLSKey) cer, err := tls.LoadX509KeyPair(server.TLSCert, server.TLSKey) //nolint:govet
if err != nil { checkErr(err)
return err
}
listener, err = tls.Listen("tcp", adr, &tls.Config{ listener, err = tls.Listen("tcp", adr, &tls.Config{
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cer}}, Certificates: []tls.Certificate{cer}},
) )
if err != nil { checkErr(err)
return err
}
default: default:
listener, err = net.Listen("tcp", adr) listener, err = net.Listen("tcp", adr)
if err != nil { checkErr(err)
return err
}
} }
assetsFs, err := fs.Sub(frontend.Assets(), "dist") assetsFs, err := fs.Sub(frontend.Assets(), "dist")
@ -227,10 +174,8 @@ user created with the credentials from options "username" and "password".`,
panic(err) panic(err)
} }
handler, err := fbhttp.NewHandler(imageService, fileCache, st.Storage, server, assetsFs) handler, err := fbhttp.NewHandler(imgSvc, fileCache, d.store, server, assetsFs)
if err != nil { checkErr(err)
return err
}
defer listener.Close() defer listener.Close()
@ -249,104 +194,66 @@ user created with the credentials from options "username" and "password".`,
}() }()
sigc := make(chan os.Signal, 1) sigc := make(chan os.Signal, 1)
signal.Notify(sigc, signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
os.Interrupt, <-sigc
syscall.SIGHUP,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGQUIT,
)
sig := <-sigc
log.Println("Got signal:", sig)
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) //nolint:mnd
defer shutdownRelease() defer shutdownRelease()
if err := srv.Shutdown(shutdownCtx); err != nil { if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("HTTP shutdown error: %v", err) log.Fatalf("HTTP shutdown error: %v", err)
} }
log.Println("Graceful shutdown complete.") log.Println("Graceful shutdown complete.")
}, pythonConfig{allowNoDB: true}),
return nil
}, storeOptions{allowsNoDatabase: true}),
} }
func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, error) { //nolint:gocyclo
func getRunParams(flags *pflag.FlagSet, st *storage.Storage) *settings.Server {
server, err := st.Settings.GetServer() server, err := st.Settings.GetServer()
if err != nil { checkErr(err)
return nil, err
if val, set := getStringParamB(flags, "root"); set {
server.Root = val
}
if val, set := getStringParamB(flags, "baseurl"); set {
server.BaseURL = val
}
if val, set := getStringParamB(flags, "log"); set {
server.Log = val
} }
isSocketSet := false isSocketSet := false
isAddrSet := false isAddrSet := false
if v.IsSet("address") { if val, set := getStringParamB(flags, "address"); set {
server.Address = v.GetString("address") server.Address = val
isAddrSet = true isAddrSet = isAddrSet || set
} }
if v.IsSet("log") { if val, set := getStringParamB(flags, "port"); set {
server.Log = v.GetString("log") server.Port = val
isAddrSet = isAddrSet || set
} }
if v.IsSet("port") { if val, set := getStringParamB(flags, "key"); set {
server.Port = v.GetString("port") server.TLSKey = val
isAddrSet = true isAddrSet = isAddrSet || set
} }
if v.IsSet("cert") { if val, set := getStringParamB(flags, "cert"); set {
server.TLSCert = v.GetString("cert") server.TLSCert = val
isAddrSet = true isAddrSet = isAddrSet || set
} }
if v.IsSet("key") { if val, set := getStringParamB(flags, "socket"); set {
server.TLSKey = v.GetString("key") server.Socket = val
isAddrSet = true isSocketSet = isSocketSet || set
}
if v.IsSet("root") {
server.Root = v.GetString("root")
}
if v.IsSet("socket") {
server.Socket = v.GetString("socket")
isSocketSet = true
}
if v.IsSet("baseURL") {
server.BaseURL = v.GetString("baseURL")
// TODO(remove): remove after July 2026.
} else if v := os.Getenv("FB_BASEURL"); v != "" {
log.Println("DEPRECATION NOTICE: Environment variable FB_BASEURL has been deprecated, use FB_BASE_URL instead")
server.BaseURL = v
}
if v.IsSet("tokenExpirationTime") {
server.TokenExpirationTime = v.GetString("tokenExpirationTime")
}
if v.IsSet("disableThumbnails") {
server.EnableThumbnails = !v.GetBool("disableThumbnails")
}
if v.IsSet("disablePreviewResize") {
server.ResizePreview = !v.GetBool("disablePreviewResize")
}
if v.IsSet("disableTypeDetectionByHeader") {
server.TypeDetectionByHeader = !v.GetBool("disableTypeDetectionByHeader")
}
if v.IsSet("disableImageResolutionCalc") {
server.ImageResolutionCal = !v.GetBool("disableImageResolutionCalc")
}
if v.IsSet("disableExec") {
server.EnableExec = !v.GetBool("disableExec")
} }
if isAddrSet && isSocketSet { if isAddrSet && isSocketSet {
return nil, errors.New("--socket flag cannot be used with --address, --port, --key nor --cert") checkErr(errors.New("--socket flag cannot be used with --address, --port, --key nor --cert"))
} }
// Do not use saved Socket if address was manually set. // Do not use saved Socket if address was manually set.
@ -354,6 +261,18 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
server.Socket = "" server.Socket = ""
} }
disableThumbnails := getBoolParam(flags, "disable-thumbnails")
server.EnableThumbnails = !disableThumbnails
disablePreviewResize := getBoolParam(flags, "disable-preview-resize")
server.ResizePreview = !disablePreviewResize
disableTypeDetectionByHeader := getBoolParam(flags, "disable-type-detection-by-header")
server.TypeDetectionByHeader = !disableTypeDetectionByHeader
disableExec := getBoolParam(flags, "disable-exec")
server.EnableExec = !disableExec
if server.EnableExec { if server.EnableExec {
log.Println("WARNING: Command Runner feature enabled!") log.Println("WARNING: Command Runner feature enabled!")
log.Println("WARNING: This feature has known security vulnerabilities and should not") log.Println("WARNING: This feature has known security vulnerabilities and should not")
@ -361,7 +280,69 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
log.Println("WARNING: read https://github.com/filebrowser/filebrowser/issues/5199") log.Println("WARNING: read https://github.com/filebrowser/filebrowser/issues/5199")
} }
return server, nil if val, set := getStringParamB(flags, "token-expiration-time"); set {
server.TokenExpirationTime = val
}
return server
}
// getBoolParamB returns a parameter as a string and a boolean to tell if it is different from the default
//
// NOTE: we could simply bind the flags to viper and use IsSet.
// Although there is a bug on Viper that always returns true on IsSet
// if a flag is binded. Our alternative way is to manually check
// the flag and then the value from env/config/gotten by viper.
// https://github.com/spf13/viper/pull/331
func getBoolParamB(flags *pflag.FlagSet, key string) (value, ok bool) {
value, _ = flags.GetBool(key)
// If set on Flags, use it.
if flags.Changed(key) {
return value, true
}
// If set through viper (env, config), return it.
if v.IsSet(key) {
return v.GetBool(key), true
}
// Otherwise use default value on flags.
return value, false
}
func getBoolParam(flags *pflag.FlagSet, key string) bool {
val, _ := getBoolParamB(flags, key)
return val
}
// getStringParamB returns a parameter as a string and a boolean to tell if it is different from the default
//
// NOTE: we could simply bind the flags to viper and use IsSet.
// Although there is a bug on Viper that always returns true on IsSet
// if a flag is binded. Our alternative way is to manually check
// the flag and then the value from env/config/gotten by viper.
// https://github.com/spf13/viper/pull/331
func getStringParamB(flags *pflag.FlagSet, key string) (string, bool) {
value, _ := flags.GetString(key)
// If set on Flags, use it.
if flags.Changed(key) {
return value, true
}
// If set through viper (env, config), return it.
if v.IsSet(key) {
return v.GetString(key), true
}
// Otherwise use default value on flags.
return value, false
}
func getStringParam(flags *pflag.FlagSet, key string) string {
val, _ := getStringParamB(flags, key)
return val
} }
func setupLog(logMethod string) { func setupLog(logMethod string) {
@ -382,22 +363,17 @@ func setupLog(logMethod string) {
} }
} }
func quickSetup(v *viper.Viper, s *storage.Storage) error { func quickSetup(flags *pflag.FlagSet, d pythonData) {
log.Println("Performing quick setup")
set := &settings.Settings{ set := &settings.Settings{
Key: generateKey(), Key: generateKey(),
Signup: false, Signup: false,
HideLoginButton: true,
CreateUserDir: false, CreateUserDir: false,
MinimumPasswordLength: settings.DefaultMinimumPasswordLength, MinimumPasswordLength: settings.DefaultMinimumPasswordLength,
UserHomeBasePath: settings.DefaultUsersHomeBasePath, UserHomeBasePath: settings.DefaultUsersHomeBasePath,
Defaults: settings.UserDefaults{ Defaults: settings.UserDefaults{
Scope: ".", Scope: ".",
Locale: "en", Locale: "en",
SingleClick: false, SingleClick: false,
RedirectAfterCopyMove: true,
AceEditorTheme: v.GetString("defaults.aceEditorTheme"),
Perm: users.Permissions{ Perm: users.Permissions{
Admin: false, Admin: false,
Execute: true, Execute: true,
@ -421,60 +397,43 @@ func quickSetup(v *viper.Viper, s *storage.Storage) error {
} }
var err error var err error
if v.GetBool("noauth") { if _, noauth := getStringParamB(flags, "noauth"); noauth {
set.AuthMethod = auth.MethodNoAuth set.AuthMethod = auth.MethodNoAuth
err = s.Auth.Save(&auth.NoAuth{}) err = d.store.Auth.Save(&auth.NoAuth{})
} else { } else {
set.AuthMethod = auth.MethodJSONAuth set.AuthMethod = auth.MethodJSONAuth
err = s.Auth.Save(&auth.JSONAuth{}) err = d.store.Auth.Save(&auth.JSONAuth{})
}
if err != nil {
return err
} }
err = s.Settings.Save(set) checkErr(err)
if err != nil { err = d.store.Settings.Save(set)
return err checkErr(err)
}
ser := &settings.Server{ ser := &settings.Server{
BaseURL: v.GetString("baseURL"), BaseURL: getStringParam(flags, "baseurl"),
Port: v.GetString("port"), Port: getStringParam(flags, "port"),
Log: v.GetString("log"), Log: getStringParam(flags, "log"),
TLSKey: v.GetString("key"), TLSKey: getStringParam(flags, "key"),
TLSCert: v.GetString("cert"), TLSCert: getStringParam(flags, "cert"),
Address: v.GetString("address"), Address: getStringParam(flags, "address"),
Root: v.GetString("root"), Root: getStringParam(flags, "root"),
TokenExpirationTime: v.GetString("tokenExpirationTime"),
EnableThumbnails: !v.GetBool("disableThumbnails"),
ResizePreview: !v.GetBool("disablePreviewResize"),
EnableExec: !v.GetBool("disableExec"),
TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"),
ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"),
} }
err = s.Settings.SaveServer(ser) err = d.store.Settings.SaveServer(ser)
if err != nil { checkErr(err)
return err
}
username := v.GetString("username") username := getStringParam(flags, "username")
password := v.GetString("password") password := getStringParam(flags, "password")
if password == "" { if password == "" {
var pwd string var pwd string
pwd, err = users.RandomPwd(set.MinimumPasswordLength) pwd, err = users.RandomPwd(set.MinimumPasswordLength)
if err != nil { checkErr(err)
return err
} log.Println("Randomly generated password for user 'admin':", pwd)
log.Printf("User '%s' initialized with randomly generated password: %s\n", username, pwd)
password, err = users.ValidateAndHashPwd(pwd, set.MinimumPasswordLength) password, err = users.ValidateAndHashPwd(pwd, set.MinimumPasswordLength)
if err != nil { checkErr(err)
return err
}
} else {
log.Printf("User '%s' initialize wth user-provided password\n", username)
} }
if username == "" || password == "" { if username == "" || password == "" {
@ -490,5 +449,34 @@ func quickSetup(v *viper.Viper, s *storage.Storage) error {
set.Defaults.Apply(user) set.Defaults.Apply(user)
user.Perm.Admin = true user.Perm.Admin = true
return s.Users.Save(user) err = d.store.Users.Save(user)
checkErr(err)
}
func initConfig() {
if cfgFile == "" {
home, err := homedir.Dir()
checkErr(err)
v.AddConfigPath(".")
v.AddConfigPath(home)
v.AddConfigPath("/etc/filebrowser/")
v.SetConfigName(".filebrowser")
} else {
v.SetConfigFile(cfgFile)
}
v.SetEnvPrefix("FB")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
if err := v.ReadInConfig(); err != nil {
var configParseError v.ConfigParseError
if errors.As(err, &configParseError) {
panic(err)
}
cfgFile = "No config file used"
} else {
cfgFile = "Using config file: " + v.ConfigFileUsed()
}
} }

View File

@ -40,29 +40,27 @@ including 'index_end'.`,
return nil return nil
}, },
RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error { Run: python(func(cmd *cobra.Command, args []string, d pythonData) {
i, err := strconv.Atoi(args[0]) i, err := strconv.Atoi(args[0])
if err != nil { checkErr(err)
return err
}
f := i f := i
if len(args) == 2 { if len(args) == 2 {
f, err = strconv.Atoi(args[1]) f, err = strconv.Atoi(args[1])
if err != nil { checkErr(err)
return err
}
} }
user := func(u *users.User) error { user := func(u *users.User) {
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...) u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
return st.Users.Save(u) err := d.store.Users.Save(u)
checkErr(err)
} }
global := func(s *settings.Settings) error { global := func(s *settings.Settings) {
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...) s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
return st.Settings.Save(s) err := d.store.Settings.Save(s)
checkErr(err)
} }
return runRules(st.Storage, cmd, user, global) runRules(d.store, cmd, user, global)
}, storeOptions{}), }, pythonConfig{}),
} }

View File

@ -29,63 +29,41 @@ rules.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
} }
func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User) error, globalFn func(*settings.Settings) error) error { func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User), globalFn func(*settings.Settings)) {
id, err := getUserIdentifier(cmd.Flags()) id := getUserIdentifier(cmd.Flags())
if err != nil {
return err
}
if id != nil { if id != nil {
var user *users.User user, err := st.Users.Get("", id)
user, err = st.Users.Get("", id) checkErr(err)
if err != nil {
return err
}
if usersFn != nil { if usersFn != nil {
err = usersFn(user) usersFn(user)
if err != nil {
return err
}
} }
printRules(user.Rules, id) printRules(user.Rules, id)
return nil return
} }
s, err := st.Settings.Get() s, err := st.Settings.Get()
if err != nil { checkErr(err)
return err
}
if globalFn != nil { if globalFn != nil {
err = globalFn(s) globalFn(s)
if err != nil {
return err
}
} }
printRules(s.Rules, id) printRules(s.Rules, id)
return nil
} }
func getUserIdentifier(flags *pflag.FlagSet) (interface{}, error) { func getUserIdentifier(flags *pflag.FlagSet) interface{} {
id, err := flags.GetUint("id") id := mustGetUint(flags, "id")
if err != nil { username := mustGetString(flags, "username")
return nil, err
}
username, err := flags.GetString("username")
if err != nil {
return nil, err
}
if id != 0 { if id != 0 {
return id, nil return id
} else if username != "" { } else if username != "" {
return username, nil return username
} }
return nil, nil return nil
} }
func printRules(rulez []rules.Rule, id interface{}) { func printRules(rulez []rules.Rule, id interface{}) {

View File

@ -21,19 +21,9 @@ var rulesAddCmd = &cobra.Command{
Short: "Add a global rule or user rule", Short: "Add a global rule or user rule",
Long: `Add a global rule or user rule.`, Long: `Add a global rule or user rule.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error { Run: python(func(cmd *cobra.Command, args []string, d pythonData) {
flags := cmd.Flags() allow := mustGetBool(cmd.Flags(), "allow")
regex := mustGetBool(cmd.Flags(), "regex")
allow, err := flags.GetBool("allow")
if err != nil {
return err
}
regex, err := flags.GetBool("regex")
if err != nil {
return err
}
exp := args[0] exp := args[0]
if regex { if regex {
@ -51,16 +41,18 @@ var rulesAddCmd = &cobra.Command{
rule.Path = exp rule.Path = exp
} }
user := func(u *users.User) error { user := func(u *users.User) {
u.Rules = append(u.Rules, rule) u.Rules = append(u.Rules, rule)
return st.Users.Save(u) err := d.store.Users.Save(u)
checkErr(err)
} }
global := func(s *settings.Settings) error { global := func(s *settings.Settings) {
s.Rules = append(s.Rules, rule) s.Rules = append(s.Rules, rule)
return st.Settings.Save(s) err := d.store.Settings.Save(s)
checkErr(err)
} }
return runRules(st.Storage, cmd, user, global) runRules(d.store, cmd, user, global)
}, storeOptions{}), }, pythonConfig{}),
} }

View File

@ -13,7 +13,7 @@ var rulesLsCommand = &cobra.Command{
Short: "List global rules or user specific rules", Short: "List global rules or user specific rules",
Long: `List global rules or user specific rules.`, Long: `List global rules or user specific rules.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error { Run: python(func(cmd *cobra.Command, _ []string, d pythonData) {
return runRules(st.Storage, cmd, nil, nil) runRules(d.store, cmd, nil, nil)
}, storeOptions{}), }, pythonConfig{}),
} }

31
cmd/upgrade.go Normal file
View File

@ -0,0 +1,31 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/filebrowser/filebrowser/v2/storage/bolt/importer"
)
func init() {
rootCmd.AddCommand(upgradeCmd)
upgradeCmd.Flags().String("old.database", "", "")
upgradeCmd.Flags().String("old.config", "", "")
_ = upgradeCmd.MarkFlagRequired("old.database")
}
var upgradeCmd = &cobra.Command{
Use: "upgrade",
Short: "Upgrades an old configuration",
Long: `Upgrades an old configuration. This command DOES NOT
import share links because they are incompatible with
this version.`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, _ []string) {
flags := cmd.Flags()
oldDB := mustGetString(flags, "old.database")
oldConf := mustGetString(flags, "old.config")
err := importer.Import(oldDB, oldConf, getStringParam(flags, "database"))
checkErr(err)
},
}

View File

@ -30,14 +30,13 @@ func printUsers(usrs []*users.User) {
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock") fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
for _, u := range usrs { for _, u := range usrs {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n", fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
u.ID, u.ID,
u.Username, u.Username,
u.Scope, u.Scope,
u.Locale, u.Locale,
u.ViewMode, u.ViewMode,
u.SingleClick, u.SingleClick,
u.RedirectAfterCopyMove,
u.Perm.Admin, u.Perm.Admin,
u.Perm.Execute, u.Perm.Execute,
u.Perm.Create, u.Perm.Create,
@ -78,72 +77,52 @@ func addUserFlags(flags *pflag.FlagSet) {
flags.String("locale", "en", "locale for users") flags.String("locale", "en", "locale for users")
flags.String("viewMode", string(users.ListViewMode), "view mode for users") flags.String("viewMode", string(users.ListViewMode), "view mode for users")
flags.Bool("singleClick", false, "use single clicks only") flags.Bool("singleClick", false, "use single clicks only")
flags.Bool("redirectAfterCopyMove", false, "redirect to destination after copy/move")
flags.Bool("dateFormat", false, "use date format (true for absolute time, false for relative)")
flags.Bool("hideDotfiles", false, "hide dotfiles")
flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users")
} }
func getAndParseViewMode(flags *pflag.FlagSet) (users.ViewMode, error) { func getViewMode(flags *pflag.FlagSet) users.ViewMode {
viewModeStr, err := flags.GetString("viewMode") viewMode := users.ViewMode(mustGetString(flags, "viewMode"))
if err != nil {
return "", err
}
viewMode := users.ViewMode(viewModeStr)
if viewMode != users.ListViewMode && viewMode != users.MosaicViewMode { if viewMode != users.ListViewMode && viewMode != users.MosaicViewMode {
return "", errors.New("view mode must be \"" + string(users.ListViewMode) + "\" or \"" + string(users.MosaicViewMode) + "\"") checkErr(errors.New("view mode must be \"" + string(users.ListViewMode) + "\" or \"" + string(users.MosaicViewMode) + "\""))
} }
return viewMode
return viewMode, nil
} }
func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all bool) error { //nolint:gocyclo
errs := []error{} func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all bool) {
visit := func(flag *pflag.Flag) { visit := func(flag *pflag.Flag) {
var err error
switch flag.Name { switch flag.Name {
case "scope": case "scope":
defaults.Scope, err = flags.GetString(flag.Name) defaults.Scope = mustGetString(flags, flag.Name)
case "locale": case "locale":
defaults.Locale, err = flags.GetString(flag.Name) defaults.Locale = mustGetString(flags, flag.Name)
case "viewMode": case "viewMode":
defaults.ViewMode, err = getAndParseViewMode(flags) defaults.ViewMode = getViewMode(flags)
case "singleClick": case "singleClick":
defaults.SingleClick, err = flags.GetBool(flag.Name) defaults.SingleClick = mustGetBool(flags, flag.Name)
case "redirectAfterCopyMove":
defaults.RedirectAfterCopyMove, err = flags.GetBool(flag.Name)
case "aceEditorTheme":
defaults.AceEditorTheme, err = flags.GetString(flag.Name)
case "perm.admin": case "perm.admin":
defaults.Perm.Admin, err = flags.GetBool(flag.Name) defaults.Perm.Admin = mustGetBool(flags, flag.Name)
case "perm.execute": case "perm.execute":
defaults.Perm.Execute, err = flags.GetBool(flag.Name) defaults.Perm.Execute = mustGetBool(flags, flag.Name)
case "perm.create": case "perm.create":
defaults.Perm.Create, err = flags.GetBool(flag.Name) defaults.Perm.Create = mustGetBool(flags, flag.Name)
case "perm.rename": case "perm.rename":
defaults.Perm.Rename, err = flags.GetBool(flag.Name) defaults.Perm.Rename = mustGetBool(flags, flag.Name)
case "perm.modify": case "perm.modify":
defaults.Perm.Modify, err = flags.GetBool(flag.Name) defaults.Perm.Modify = mustGetBool(flags, flag.Name)
case "perm.delete": case "perm.delete":
defaults.Perm.Delete, err = flags.GetBool(flag.Name) defaults.Perm.Delete = mustGetBool(flags, flag.Name)
case "perm.share": case "perm.share":
defaults.Perm.Share, err = flags.GetBool(flag.Name) defaults.Perm.Share = mustGetBool(flags, flag.Name)
case "perm.download": case "perm.download":
defaults.Perm.Download, err = flags.GetBool(flag.Name) defaults.Perm.Download = mustGetBool(flags, flag.Name)
case "commands": case "commands":
defaults.Commands, err = flags.GetStringSlice(flag.Name) commands, err := flags.GetStringSlice(flag.Name)
checkErr(err)
defaults.Commands = commands
case "sorting.by": case "sorting.by":
defaults.Sorting.By, err = flags.GetString(flag.Name) defaults.Sorting.By = mustGetString(flags, flag.Name)
case "sorting.asc": case "sorting.asc":
defaults.Sorting.Asc, err = flags.GetBool(flag.Name) defaults.Sorting.Asc = mustGetBool(flags, flag.Name)
case "hideDotfiles":
defaults.HideDotfiles, err = flags.GetBool(flag.Name)
}
if err != nil {
errs = append(errs, err)
} }
} }
@ -152,6 +131,4 @@ func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all
} else { } else {
flags.Visit(visit) flags.Visit(visit)
} }
return errors.Join(errs...)
} }

View File

@ -16,67 +16,36 @@ var usersAddCmd = &cobra.Command{
Short: "Create a new user", Short: "Create a new user",
Long: `Create a new user and add it to the database.`, Long: `Create a new user and add it to the database.`,
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error { Run: python(func(cmd *cobra.Command, args []string, d pythonData) {
flags := cmd.Flags() s, err := d.store.Settings.Get()
s, err := st.Settings.Get() checkErr(err)
if err != nil { getUserDefaults(cmd.Flags(), &s.Defaults, false)
return err
}
err = getUserDefaults(flags, &s.Defaults, false)
if err != nil {
return err
}
password, err := users.ValidateAndHashPwd(args[1], s.MinimumPasswordLength) password, err := users.ValidateAndHashPwd(args[1], s.MinimumPasswordLength)
if err != nil { checkErr(err)
return err
}
user := &users.User{ user := &users.User{
Username: args[0], Username: args[0],
Password: password, Password: password,
} LockPassword: mustGetBool(cmd.Flags(), "lockPassword"),
user.LockPassword, err = flags.GetBool("lockPassword")
if err != nil {
return err
}
user.DateFormat, err = flags.GetBool("dateFormat")
if err != nil {
return err
}
user.HideDotfiles, err = flags.GetBool("hideDotfiles")
if err != nil {
return err
} }
s.Defaults.Apply(user) s.Defaults.Apply(user)
servSettings, err := st.Settings.GetServer() servSettings, err := d.store.Settings.GetServer()
if err != nil { checkErr(err)
return err
}
// since getUserDefaults() polluted s.Defaults.Scope // since getUserDefaults() polluted s.Defaults.Scope
// which makes the Scope not the one saved in the db // which makes the Scope not the one saved in the db
// we need the right s.Defaults.Scope here // we need the right s.Defaults.Scope here
s2, err := st.Settings.Get() s2, err := d.store.Settings.Get()
if err != nil { checkErr(err)
return err
}
userHome, err := s2.MakeUserDir(user.Username, user.Scope, servSettings.Root) userHome, err := s2.MakeUserDir(user.Username, user.Scope, servSettings.Root)
if err != nil { checkErr(err)
return err
}
user.Scope = userHome user.Scope = userHome
err = st.Users.Save(user) err = d.store.Users.Save(user)
if err != nil { checkErr(err)
return err
}
printUsers([]*users.User{user}) printUsers([]*users.User{user})
return nil }, pythonConfig{}),
}, storeOptions{}),
} }

View File

@ -14,16 +14,11 @@ var usersExportCmd = &cobra.Command{
Long: `Export all users to a json or yaml file. Please indicate the Long: `Export all users to a json or yaml file. Please indicate the
path to the file where you want to write the users.`, path to the file where you want to write the users.`,
Args: jsonYamlArg, Args: jsonYamlArg,
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error { Run: python(func(_ *cobra.Command, args []string, d pythonData) {
list, err := st.Users.Gets("") list, err := d.store.Users.Gets("")
if err != nil { checkErr(err)
return err
}
err = marshal(args[0], list) err = marshal(args[0], list)
if err != nil { checkErr(err)
return err }, pythonConfig{}),
}
return nil
}, storeOptions{}),
} }

View File

@ -16,17 +16,17 @@ var usersFindCmd = &cobra.Command{
Short: "Find a user by username or id", Short: "Find a user by username or id",
Long: `Find a user by username or id. If no flag is set, all users will be printed.`, Long: `Find a user by username or id. If no flag is set, all users will be printed.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: findUsers, Run: findUsers,
} }
var usersLsCmd = &cobra.Command{ var usersLsCmd = &cobra.Command{
Use: "ls", Use: "ls",
Short: "List all users.", Short: "List all users.",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: findUsers, Run: findUsers,
} }
var findUsers = withStore(func(_ *cobra.Command, args []string, st *store) error { var findUsers = python(func(_ *cobra.Command, args []string, d pythonData) {
var ( var (
list []*users.User list []*users.User
user *users.User user *users.User
@ -36,19 +36,16 @@ var findUsers = withStore(func(_ *cobra.Command, args []string, st *store) error
if len(args) == 1 { if len(args) == 1 {
username, id := parseUsernameOrID(args[0]) username, id := parseUsernameOrID(args[0])
if username != "" { if username != "" {
user, err = st.Users.Get("", username) user, err = d.store.Users.Get("", username)
} else { } else {
user, err = st.Users.Get("", id) user, err = d.store.Users.Get("", id)
} }
list = []*users.User{user} list = []*users.User{user}
} else { } else {
list, err = st.Users.Gets("") list, err = d.store.Users.Gets("")
} }
if err != nil { checkErr(err)
return err
}
printUsers(list) printUsers(list)
return nil }, pythonConfig{})
}, storeOptions{})

View File

@ -25,71 +25,50 @@ file. You can use this command to import new users to your
installation. For that, just don't place their ID on the files installation. For that, just don't place their ID on the files
list or set it to 0.`, list or set it to 0.`,
Args: jsonYamlArg, Args: jsonYamlArg,
RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error { Run: python(func(cmd *cobra.Command, args []string, d pythonData) {
flags := cmd.Flags()
fd, err := os.Open(args[0]) fd, err := os.Open(args[0])
if err != nil { checkErr(err)
return err
}
defer fd.Close() defer fd.Close()
list := []*users.User{} list := []*users.User{}
err = unmarshal(args[0], &list) err = unmarshal(args[0], &list)
if err != nil { checkErr(err)
return err
}
for _, user := range list { for _, user := range list {
err = user.Clean("") err = user.Clean("")
if err != nil { checkErr(err)
return err
}
} }
replace, err := flags.GetBool("replace") if mustGetBool(cmd.Flags(), "replace") {
if err != nil { oldUsers, err := d.store.Users.Gets("")
return err checkErr(err)
}
if replace {
oldUsers, userImportErr := st.Users.Gets("")
if userImportErr != nil {
return userImportErr
}
err = marshal("users.backup.json", list) err = marshal("users.backup.json", list)
if err != nil { checkErr(err)
return err
}
for _, user := range oldUsers { for _, user := range oldUsers {
err = st.Users.Delete(user.ID) err = d.store.Users.Delete(user.ID)
if err != nil { checkErr(err)
return err
}
} }
} }
overwrite, err := flags.GetBool("overwrite") overwrite := mustGetBool(cmd.Flags(), "overwrite")
if err != nil {
return err
}
for _, user := range list { for _, user := range list {
onDB, err := st.Users.Get("", user.ID) onDB, err := d.store.Users.Get("", user.ID)
// User exists in DB. // User exists in DB.
if err == nil { if err == nil {
if !overwrite { if !overwrite {
return errors.New("user " + strconv.Itoa(int(user.ID)) + " is already registered") checkErr(errors.New("user " + strconv.Itoa(int(user.ID)) + " is already registered"))
} }
// If the usernames mismatch, check if there is another one in the DB // 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 // with the new username. If there is, print an error and cancel the
// operation // operation
if user.Username != onDB.Username { if user.Username != onDB.Username {
if conflictuous, err := st.Users.Get("", user.Username); err == nil { if conflictuous, err := d.store.Users.Get("", user.Username); err == nil { //nolint:govet
return usernameConflictError(user.Username, conflictuous.ID, user.ID) checkErr(usernameConflictError(user.Username, conflictuous.ID, user.ID))
} }
} }
} else { } else {
@ -98,13 +77,10 @@ list or set it to 0.`,
user.ID = 0 user.ID = 0
} }
err = st.Users.Save(user) err = d.store.Users.Save(user)
if err != nil { checkErr(err)
return err
}
} }
return nil }, pythonConfig{}),
}, storeOptions{}),
} }
func usernameConflictError(username string, originalID, newID uint) error { func usernameConflictError(username string, originalID, newID uint) error {

View File

@ -15,20 +15,17 @@ var usersRmCmd = &cobra.Command{
Short: "Delete a user by username or id", Short: "Delete a user by username or id",
Long: `Delete a user by username or id`, Long: `Delete a user by username or id`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error { Run: python(func(_ *cobra.Command, args []string, d pythonData) {
username, id := parseUsernameOrID(args[0]) username, id := parseUsernameOrID(args[0])
var err error var err error
if username != "" { if username != "" {
err = st.Users.Delete(username) err = d.store.Users.Delete(username)
} else { } else {
err = st.Users.Delete(id) err = d.store.Users.Delete(id)
} }
if err != nil { checkErr(err)
return err
}
fmt.Println("user deleted successfully") fmt.Println("user deleted successfully")
return nil }, pythonConfig{}),
}, storeOptions{}),
} }

View File

@ -21,74 +21,45 @@ var usersUpdateCmd = &cobra.Command{
Long: `Updates an existing user. Set the flags for the Long: `Updates an existing user. Set the flags for the
options you want to change.`, options you want to change.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error { Run: python(func(cmd *cobra.Command, args []string, d pythonData) {
flags := cmd.Flags()
username, id := parseUsernameOrID(args[0]) username, id := parseUsernameOrID(args[0])
password, err := flags.GetString("password") flags := cmd.Flags()
if err != nil { password := mustGetString(flags, "password")
return err newUsername := mustGetString(flags, "username")
}
newUsername, err := flags.GetString("username") s, err := d.store.Settings.Get()
if err != nil { checkErr(err)
return err
}
s, err := st.Settings.Get()
if err != nil {
return err
}
var ( var (
user *users.User user *users.User
) )
if id != 0 { if id != 0 {
user, err = st.Users.Get("", id) user, err = d.store.Users.Get("", id)
} else { } else {
user, err = st.Users.Get("", username) user, err = d.store.Users.Get("", username)
}
if err != nil {
return err
} }
checkErr(err)
defaults := settings.UserDefaults{ defaults := settings.UserDefaults{
Scope: user.Scope, Scope: user.Scope,
Locale: user.Locale, Locale: user.Locale,
ViewMode: user.ViewMode, ViewMode: user.ViewMode,
SingleClick: user.SingleClick, SingleClick: user.SingleClick,
RedirectAfterCopyMove: user.RedirectAfterCopyMove, Perm: user.Perm,
Perm: user.Perm, Sorting: user.Sorting,
Sorting: user.Sorting, Commands: user.Commands,
Commands: user.Commands,
} }
getUserDefaults(flags, &defaults, false)
err = getUserDefaults(flags, &defaults, false)
if err != nil {
return err
}
user.Scope = defaults.Scope user.Scope = defaults.Scope
user.Locale = defaults.Locale user.Locale = defaults.Locale
user.ViewMode = defaults.ViewMode user.ViewMode = defaults.ViewMode
user.SingleClick = defaults.SingleClick user.SingleClick = defaults.SingleClick
user.RedirectAfterCopyMove = defaults.RedirectAfterCopyMove
user.Perm = defaults.Perm user.Perm = defaults.Perm
user.Commands = defaults.Commands user.Commands = defaults.Commands
user.Sorting = defaults.Sorting user.Sorting = defaults.Sorting
user.LockPassword, err = flags.GetBool("lockPassword") user.LockPassword = mustGetBool(flags, "lockPassword")
if err != nil {
return err
}
user.DateFormat, err = flags.GetBool("dateFormat")
if err != nil {
return err
}
user.HideDotfiles, err = flags.GetBool("hideDotfiles")
if err != nil {
return err
}
if newUsername != "" { if newUsername != "" {
user.Username = newUsername user.Username = newUsername
@ -96,16 +67,11 @@ options you want to change.`,
if password != "" { if password != "" {
user.Password, err = users.ValidateAndHashPwd(password, s.MinimumPasswordLength) user.Password, err = users.ValidateAndHashPwd(password, s.MinimumPasswordLength)
if err != nil { checkErr(err)
return err
}
} }
err = st.Users.Update(user) err = d.store.Users.Update(user)
if err != nil { checkErr(err)
return err
}
printUsers([]*users.User{user}) printUsers([]*users.User{user})
return nil }, pythonConfig{}),
}, storeOptions{}),
} }

View File

@ -4,50 +4,65 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/asdine/storm/v3" "github.com/asdine/storm/v3"
homedir "github.com/mitchellh/go-homedir"
"github.com/samber/lo"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/spf13/viper" yaml "gopkg.in/yaml.v2"
yaml "gopkg.in/yaml.v3"
"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/storage" "github.com/filebrowser/filebrowser/v2/storage"
"github.com/filebrowser/filebrowser/v2/storage/bolt" "github.com/filebrowser/filebrowser/v2/storage/bolt"
) )
const databasePermissions = 0640 func checkErr(err error) {
func getAndParseFileMode(flags *pflag.FlagSet, name string) (fs.FileMode, error) {
mode, err := flags.GetString(name)
if err != nil { if err != nil {
return 0, err log.Fatal(err)
} }
}
b, err := strconv.ParseUint(mode, 0, 32) func mustGetString(flags *pflag.FlagSet, flag string) string {
if err != nil { s, err := flags.GetString(flag)
return 0, err checkErr(err)
} return s
}
return fs.FileMode(b), nil func mustGetBool(flags *pflag.FlagSet, flag string) bool {
b, err := flags.GetBool(flag)
checkErr(err)
return b
}
func mustGetUint(flags *pflag.FlagSet, flag string) uint {
b, err := flags.GetUint(flag)
checkErr(err)
return b
} }
func generateKey() []byte { func generateKey() []byte {
k, err := settings.GenerateKey() k, err := settings.GenerateKey()
if err != nil { checkErr(err)
panic(err)
}
return k 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) { func dbExists(path string) (bool, error) {
stat, err := os.Stat(path) stat, err := os.Stat(path)
if err == nil { if err == nil {
@ -58,7 +73,7 @@ func dbExists(path string) (bool, error) {
d := filepath.Dir(path) d := filepath.Dir(path)
_, err = os.Stat(d) _, err = os.Stat(d)
if os.IsNotExist(err) { if os.IsNotExist(err) {
if err := os.MkdirAll(d, 0700); err != nil { if err := os.MkdirAll(d, 0700); err != nil { //nolint:govet
return false, err return false, err
} }
return false, nil return false, nil
@ -68,142 +83,41 @@ func dbExists(path string) (bool, error) {
return false, err return false, err
} }
// Generate the replacements for all environment variables. This allows to func python(fn pythonFunc, cfg pythonConfig) cobraFunc {
// use FB_BRANDING_DISABLE_EXTERNAL environment variables, even when the return func(cmd *cobra.Command, args []string) {
// option name is branding.disableExternal. data := pythonData{hadDB: true}
func generateEnvKeyReplacements(cmd *cobra.Command) []string {
replacements := []string{}
cmd.Flags().VisitAll(func(f *pflag.Flag) { path := getStringParam(cmd.Flags(), "database")
oldName := strings.ToUpper(f.Name) absPath, err := filepath.Abs(path)
newName := strings.ToUpper(lo.SnakeCase(f.Name))
replacements = append(replacements, oldName, newName)
})
return replacements
}
func initViper(cmd *cobra.Command) (*viper.Viper, error) {
v := viper.New()
// Get config file from flag
cfgFile, err := cmd.Flags().GetString("config")
if err != nil {
return nil, err
}
// Configuration file
if cfgFile == "" {
home, err := homedir.Dir()
if err != nil { if err != nil {
return nil, err panic(err)
} }
v.AddConfigPath(".")
v.AddConfigPath(home)
v.AddConfigPath("/etc/filebrowser/")
v.SetConfigName(".filebrowser")
} else {
v.SetConfigFile(cfgFile)
}
// Environment variables
v.SetEnvPrefix("FB")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(generateEnvKeyReplacements(cmd)...))
// Bind the flags
err = v.BindPFlags(cmd.Flags())
if err != nil {
return nil, err
}
// Read in configuration
if err := v.ReadInConfig(); err != nil {
if errors.Is(err, viper.ConfigParseError{}) {
return nil, err
}
log.Println("No config file used")
} else {
log.Printf("Using config file: %s", v.ConfigFileUsed())
}
// Return Viper
return v, nil
}
type store struct {
*storage.Storage
databaseExisted bool
}
type storeOptions struct {
expectsNoDatabase bool
allowsNoDatabase bool
}
type cobraFunc func(cmd *cobra.Command, args []string) error
// withViperAndStore initializes Viper and the storage.Store and passes them to the callback function.
// This function should only be used by [withStore] and the root command. No other command should call
// this function directly.
func withViperAndStore(fn func(cmd *cobra.Command, args []string, v *viper.Viper, store *store) error, options storeOptions) cobraFunc {
return func(cmd *cobra.Command, args []string) error {
v, err := initViper(cmd)
if err != nil {
return err
}
path, err := filepath.Abs(v.GetString("database"))
if err != nil {
return err
}
exists, err := dbExists(path) exists, err := dbExists(path)
switch {
case err != nil:
return err
case exists && options.expectsNoDatabase:
log.Fatal(path + " already exists")
case !exists && !options.expectsNoDatabase && !options.allowsNoDatabase:
log.Fatal(path + " does not exist. Please run 'filebrowser config init' first.")
case !exists && !options.expectsNoDatabase:
log.Println("WARNING: filebrowser.db can't be found. Initialing in " + strings.TrimSuffix(path, "filebrowser.db"))
}
log.Println("Using database: " + path)
db, err := storm.Open(path, storm.BoltOptions(databasePermissions, nil))
if err != nil { if err != nil {
return err panic(err)
} else if exists && cfg.noDB {
log.Fatal(absPath + " already exists")
} else if !exists && !cfg.noDB && !cfg.allowNoDB {
log.Fatal(absPath + " does not exist. Please run 'filebrowser config init' first.")
} else if !exists && !cfg.noDB {
log.Println("Warning: filebrowser.db can't be found. Initialing in " + strings.TrimSuffix(absPath, "filebrowser.db"))
} }
log.Println("Using database: " + absPath)
data.hadDB = exists
db, err := storm.Open(path, storm.BoltOptions(files.PermFile, nil))
checkErr(err)
defer db.Close() defer db.Close()
data.store, err = bolt.NewStorage(db)
storage, err := bolt.NewStorage(db) checkErr(err)
if err != nil { fn(cmd, args, data)
return err
}
store := &store{
Storage: storage,
databaseExisted: exists,
}
return fn(cmd, args, v, store)
} }
} }
func withStore(fn func(cmd *cobra.Command, args []string, store *store) error, options storeOptions) cobraFunc {
return withViperAndStore(func(cmd *cobra.Command, args []string, _ *viper.Viper, store *store) error {
return fn(cmd, args, store)
}, options)
}
func marshal(filename string, data interface{}) error { func marshal(filename string, data interface{}) error {
fd, err := os.Create(filename) fd, err := os.Create(filename)
if err != nil { checkErr(err)
return err
}
defer fd.Close() defer fd.Close()
switch ext := filepath.Ext(filename); ext { switch ext := filepath.Ext(filename); ext {
@ -221,9 +135,7 @@ func marshal(filename string, data interface{}) error {
func unmarshal(filename string, data interface{}) error { func unmarshal(filename string, data interface{}) error {
fd, err := os.Open(filename) fd, err := os.Open(filename)
if err != nil { checkErr(err)
return err
}
defer fd.Close() defer fd.Close()
switch ext := filepath.Ext(filename); ext { switch ext := filepath.Ext(filename); ext {

34
commitlint.config.js Normal file
View File

@ -0,0 +1,34 @@
module.exports = {
rules: {
'body-leading-blank': [1, 'always'],
'body-max-line-length': [2, 'always', 100],
'footer-leading-blank': [1, 'always'],
'footer-max-line-length': [2, 'always', 100],
'header-max-length': [2, 'always', 100],
'scope-case': [2, 'always', 'lower-case'],
'subject-case': [
2,
'never',
['sentence-case', 'start-case', 'pascal-case', 'upper-case'],
],
'subject-full-stop': [2, 'never', '.'],
'type-case': [2, 'always', 'lower-case'],
'type-empty': [2, 'never'],
'type-enum': [
2,
'always',
[
'feat',
'fix',
'perf',
'revert',
'refactor',
'build',
'ci',
'test',
'chore',
'docs',
],
],
},
};

28
common.mk Normal file
View File

@ -0,0 +1,28 @@
SHELL := /usr/bin/env bash
DATE ?= $(shell date +%FT%T%z)
BASE_PATH := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
VERSION ?= $(shell git describe --tags --always --match=v* 2> /dev/null || \
cat $(CURDIR)/.version 2> /dev/null || echo v0)
VERSION_HASH = $(shell git rev-parse HEAD)
BRANCH = $(shell git rev-parse --abbrev-ref HEAD)
go = GOGC=off go
MODULE = $(shell env GO111MODULE=on go list -m)
# printing
# $Q (quiet) is used in the targets as a replacer for @.
# This macro helps to print the command for debugging by setting V to 1. Example `make test-unit V=1`
V = 0
Q = $(if $(filter 1,$V),,@)
# $M is a macro to print a colored ▶ character. Example `$(info $(M) running coverage tests…)` will print "▶ running coverage tests…"
M = $(shell printf "\033[34;1m▶\033[0m")
GREEN := $(shell tput -Txterm setaf 2)
YELLOW := $(shell tput -Txterm setaf 3)
WHITE := $(shell tput -Txterm setaf 7)
CYAN := $(shell tput -Txterm setaf 6)
RESET := $(shell tput -Txterm sgr0)
define global_option
printf " ${YELLOW}%-20s${GREEN}%s${RESET}\n" $(1) $(2)
endef

View File

@ -2,7 +2,7 @@ package diskcache
import ( import (
"context" "context"
"crypto/sha1" "crypto/sha1" //nolint:gosec
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@ -103,7 +103,7 @@ func (f *FileCache) getScopedLocks(key string) (lock sync.Locker) {
} }
func (f *FileCache) getFileName(key string) string { func (f *FileCache) getFileName(key string) string {
hasher := sha1.New() hasher := sha1.New() //nolint:gosec
_, _ = hasher.Write([]byte(key)) _, _ = hasher.Write([]byte(key))
hash := hex.EncodeToString(hasher.Sum(nil)) hash := hex.EncodeToString(hasher.Sum(nil))
return fmt.Sprintf("%s/%s/%s", hash[:1], hash[1:3], hash) return fmt.Sprintf("%s/%s/%s", hash[:1], hash[1:3], hash)

View File

@ -25,12 +25,12 @@ func TestFileCache(t *testing.T) {
// store new key // store new key
err := cache.Store(ctx, key, []byte(value)) err := cache.Store(ctx, key, []byte(value))
require.NoError(t, err) require.NoError(t, err)
checkValue(ctx, t, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, value) checkValue(t, ctx, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, value)
// update existing key // update existing key
err = cache.Store(ctx, key, []byte(newValue)) err = cache.Store(ctx, key, []byte(newValue))
require.NoError(t, err) require.NoError(t, err)
checkValue(ctx, t, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, newValue) checkValue(t, ctx, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, newValue)
// delete key // delete key
err = cache.Delete(ctx, key) err = cache.Delete(ctx, key)
@ -40,7 +40,7 @@ func TestFileCache(t *testing.T) {
require.False(t, exists) require.False(t, exists)
} }
func checkValue(ctx context.Context, t *testing.T, fs afero.Fs, fileFullPath string, cache *FileCache, key, wantValue string) { func checkValue(t *testing.T, ctx context.Context, fs afero.Fs, fileFullPath string, cache *FileCache, key, wantValue string) { //nolint:revive
t.Helper() t.Helper()
// check actual file content // check actual file content
b, err := afero.ReadFile(fs, fileFullPath) b, err := afero.ReadFile(fs, fileFullPath)

View File

@ -1,9 +0,0 @@
#!/bin/sh
set -e
PORT=${FB_PORT:-$(cat /config/settings.json | sh /JSON.sh | grep '\["port"\]' | awk '{print $2}')}
ADDRESS=${FB_ADDRESS:-$(cat /config/settings.json | sh /JSON.sh | grep '\["address"\]' | awk '{print $2}' | sed 's/"//g')}
ADDRESS=${ADDRESS:-localhost}
wget -q --spider http://$ADDRESS:$PORT/health || exit 1

View File

@ -2,34 +2,40 @@
set -e set -e
# Backwards compatibility for old Docker image
if [ -f "/.filebrowser.json" ]; then
ln -s /.filebrowser.json /config/settings.json
echo ""
echo "!!!!!!!!!!!!!!!!!!!!! IMPORTANT INFORMATION !!!!!!!!!!!!!!!!!!!!!"
echo "Symlinking /.filebrowser.json to /config/settings.json for backwards compatibility."
echo ""
echo "The volume mount configuration has changed in the latest release."
echo "Please rename .filebrowser.json to settings.json and mount the parent directory to /config".
echo "Read more on https://github.com/filebrowser/filebrowser/blob/master/docs/installation.md#docker"
echo ""
echo "This workaround will be removed in a future release."
echo ""
fi
# Backwards compatibility for old Docker image
if [ -f "/database.db" ]; then
ln -s /database.db /database/filebrowser.db
echo ""
echo "!!!!!!!!!!!!!!!!!!!!! IMPORTANT INFORMATION !!!!!!!!!!!!!!!!!!!!!"
echo ""
echo "The volume mount configuration has changed in the latest release."
echo "Please rename database.db to filebrowser.db and mount the parent directory to /database".
echo "Read more on https://github.com/filebrowser/filebrowser/blob/master/docs/installation.md#docker"
echo ""
echo "This workaround will be removed in a future release."
echo ""
fi
# Ensure configuration exists # Ensure configuration exists
if [ ! -f "/config/settings.json" ]; then if [ ! -f "/config/settings.json" ]; then
cp -a /defaults/settings.json /config/settings.json cp -a /defaults/settings.json /config/settings.json
fi fi
# Extract config file path from arguments exec "$@"
config_file=""
next_is_config=0
for arg in "$@"; do
if [ "$next_is_config" -eq 1 ]; then
config_file="$arg"
break
fi
case "$arg" in
-c|--config)
next_is_config=1
;;
-c=*|--config=*)
config_file="${arg#*=}"
break
;;
esac
done
# If no config argument is provided, set the default and add it to the args
if [ -z "$config_file" ]; then
config_file="/config/settings.json"
set -- --config=/config/settings.json "$@"
fi
exec filebrowser "$@"

View File

@ -5,4 +5,4 @@
"log": "stdout", "log": "stdout",
"database": "/database/filebrowser.db", "database": "/database/filebrowser.db",
"root": "/srv" "root": "/srv"
} }

View File

@ -6,4 +6,4 @@ PORT=${FB_PORT:-$(jq -r .port /config/settings.json)}
ADDRESS=${FB_ADDRESS:-$(jq -r .address /config/settings.json)} ADDRESS=${FB_ADDRESS:-$(jq -r .address /config/settings.json)}
ADDRESS=${ADDRESS:-localhost} ADDRESS=${ADDRESS:-localhost}
wget -q --spider http://$ADDRESS:$PORT/health || exit 1 curl -f http://$ADDRESS:$PORT/health || exit 1

View File

@ -1,4 +1,4 @@
package fberrors package errors
import ( import (
"errors" "errors"
@ -6,23 +6,22 @@ import (
) )
var ( var (
ErrEmptyKey = errors.New("empty key") ErrEmptyKey = errors.New("empty key")
ErrExist = errors.New("the resource already exists") ErrExist = errors.New("the resource already exists")
ErrNotExist = errors.New("the resource does not exist") ErrNotExist = errors.New("the resource does not exist")
ErrEmptyPassword = errors.New("password is empty") ErrEmptyPassword = errors.New("password is empty")
ErrEasyPassword = errors.New("password is too easy") ErrEasyPassword = errors.New("password is too easy")
ErrEmptyUsername = errors.New("username is empty") ErrEmptyUsername = errors.New("username is empty")
ErrEmptyRequest = errors.New("empty request") ErrEmptyRequest = errors.New("empty request")
ErrScopeIsRelative = errors.New("scope is a relative path") ErrScopeIsRelative = errors.New("scope is a relative path")
ErrInvalidDataType = errors.New("invalid data type") ErrInvalidDataType = errors.New("invalid data type")
ErrIsDirectory = errors.New("file is directory") ErrIsDirectory = errors.New("file is directory")
ErrInvalidOption = errors.New("invalid option") ErrInvalidOption = errors.New("invalid option")
ErrInvalidAuthMethod = errors.New("invalid auth method") ErrInvalidAuthMethod = errors.New("invalid auth method")
ErrPermissionDenied = errors.New("permission denied") ErrPermissionDenied = errors.New("permission denied")
ErrInvalidRequestParams = errors.New("invalid request params") ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent") ErrSourceIsParent = errors.New("source is parent")
ErrRootUserDeletion = errors.New("user with id 1 can't be deleted") ErrRootUserDeletion = errors.New("user with id 1 can't be deleted")
ErrCurrentPasswordIncorrect = errors.New("the current password is incorrect")
) )
type ErrShortPassword struct { type ErrShortPassword struct {

View File

@ -1,8 +1,8 @@
package files package files
import ( import (
"crypto/md5" "crypto/md5" //nolint:gosec
"crypto/sha1" "crypto/sha1" //nolint:gosec
"crypto/sha256" "crypto/sha256"
"crypto/sha512" "crypto/sha512"
"encoding/hex" "encoding/hex"
@ -23,10 +23,13 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
fberrors "github.com/filebrowser/filebrowser/v2/errors" fbErrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/rules" "github.com/filebrowser/filebrowser/v2/rules"
) )
const PermFile = 0640
const PermDir = 0750
var ( var (
reSubDirs = regexp.MustCompile("(?i)^sub(s|titles)$") reSubDirs = regexp.MustCompile("(?i)^sub(s|titles)$")
reSubExts = regexp.MustCompile("(?i)(.vtt|.srt|.ass|.ssa)$") reSubExts = regexp.MustCompile("(?i)(.vtt|.srt|.ass|.ssa)$")
@ -60,7 +63,6 @@ type FileOptions struct {
Modify bool Modify bool
Expand bool Expand bool
ReadHeader bool ReadHeader bool
CalcImgRes bool
Token string Token string
Checker rules.Checker Checker rules.Checker
Content bool Content bool
@ -91,13 +93,13 @@ func NewFileInfo(opts *FileOptions) (*FileInfo, error) {
if opts.Expand { if opts.Expand {
if file.IsDir { if file.IsDir {
if err := file.readListing(opts.Checker, opts.ReadHeader, opts.CalcImgRes); err != nil { if err := file.readListing(opts.Checker, opts.ReadHeader); err != nil { //nolint:govet
return nil, err return nil, err
} }
return file, nil return file, nil
} }
err = file.detectType(opts.Modify, opts.Content, true, opts.CalcImgRes) err = file.detectType(opts.Modify, opts.Content, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -169,7 +171,7 @@ func stat(opts *FileOptions) (*FileInfo, error) {
// algorithm. The checksums data is saved on File object. // algorithm. The checksums data is saved on File object.
func (i *FileInfo) Checksum(algo string) error { func (i *FileInfo) Checksum(algo string) error {
if i.IsDir { if i.IsDir {
return fberrors.ErrIsDirectory return fbErrors.ErrIsDirectory
} }
if i.Checksums == nil { if i.Checksums == nil {
@ -184,6 +186,7 @@ func (i *FileInfo) Checksum(algo string) error {
var h hash.Hash var h hash.Hash
//nolint:gosec
switch algo { switch algo {
case "md5": case "md5":
h = md5.New() h = md5.New()
@ -194,7 +197,7 @@ func (i *FileInfo) Checksum(algo string) error {
case "sha512": case "sha512":
h = sha512.New() h = sha512.New()
default: default:
return fberrors.ErrInvalidOption return fbErrors.ErrInvalidOption
} }
_, err = io.Copy(h, reader) _, err = io.Copy(h, reader)
@ -219,7 +222,7 @@ func (i *FileInfo) RealPath() string {
return i.Path return i.Path
} }
func (i *FileInfo) detectType(modify, saveContent, readHeader bool, calcImgRes bool) error { func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error {
if IsNamedPipe(i.Mode) { if IsNamedPipe(i.Mode) {
i.Type = "blob" i.Type = "blob"
return nil return nil
@ -250,13 +253,11 @@ func (i *FileInfo) detectType(modify, saveContent, readHeader bool, calcImgRes b
return nil return nil
case strings.HasPrefix(mimetype, "image"): case strings.HasPrefix(mimetype, "image"):
i.Type = "image" i.Type = "image"
if calcImgRes { resolution, err := calculateImageResolution(i.Fs, i.Path)
resolution, err := calculateImageResolution(i.Fs, i.Path) if err != nil {
if err != nil { log.Printf("Error calculating image resolution: %v", err)
log.Printf("Error calculating image resolution: %v", err) } else {
} else { i.Resolution = resolution
i.Resolution = resolution
}
} }
return nil return nil
case strings.HasSuffix(mimetype, "pdf"): case strings.HasSuffix(mimetype, "pdf"):
@ -390,7 +391,7 @@ func (i *FileInfo) addSubtitle(fPath string) {
i.Subtitles = append(i.Subtitles, fPath) i.Subtitles = append(i.Subtitles, fPath)
} }
func (i *FileInfo) readListing(checker rules.Checker, readHeader bool, calcImgRes bool) error { func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error {
afs := &afero.Afero{Fs: i.Fs} afs := &afero.Afero{Fs: i.Fs}
dir, err := afs.ReadDir(i.Path) dir, err := afs.ReadDir(i.Path)
if err != nil { if err != nil {
@ -437,7 +438,7 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool, calcImgRe
currentDir: dir, currentDir: dir,
} }
if !file.IsDir && strings.HasPrefix(mime.TypeByExtension(file.Extension), "image/") && calcImgRes { if !file.IsDir && strings.HasPrefix(mime.TypeByExtension(file.Extension), "image/") {
resolution, err := calculateImageResolution(file.Fs, file.Path) resolution, err := calculateImageResolution(file.Fs, file.Path)
if err != nil { if err != nil {
log.Printf("Error calculating resolution for image %s: %v", file.Path, err) log.Printf("Error calculating resolution for image %s: %v", file.Path, err)
@ -454,7 +455,7 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool, calcImgRe
if isInvalidLink { if isInvalidLink {
file.Type = "invalid_link" file.Type = "invalid_link"
} else { } else {
err := file.detectType(true, false, readHeader, calcImgRes) err := file.detectType(true, false, readHeader)
if err != nil { if err != nil {
return err return err
} }

View File

@ -600,6 +600,7 @@ var types = map[string]string{
".epub": "application/epub+zip", ".epub": "application/epub+zip",
} }
//nolint:gochecknoinits
func init() { func init() {
for ext, typ := range types { for ext, typ := range types {
// skip errors // skip errors

View File

@ -1,7 +1,6 @@
package fileutils package fileutils
import ( import (
"io/fs"
"os" "os"
"path" "path"
@ -9,7 +8,7 @@ import (
) )
// Copy copies a file or folder from one place to another. // Copy copies a file or folder from one place to another.
func Copy(afs afero.Fs, src, dst string, fileMode, dirMode fs.FileMode) error { func Copy(fs afero.Fs, src, dst string) error {
if src = path.Clean("/" + src); src == "" { if src = path.Clean("/" + src); src == "" {
return os.ErrNotExist return os.ErrNotExist
} }
@ -27,14 +26,14 @@ func Copy(afs afero.Fs, src, dst string, fileMode, dirMode fs.FileMode) error {
return os.ErrInvalid return os.ErrInvalid
} }
info, err := afs.Stat(src) info, err := fs.Stat(src)
if err != nil { if err != nil {
return err return err
} }
if info.IsDir() { if info.IsDir() {
return CopyDir(afs, src, dst, fileMode, dirMode) return CopyDir(fs, src, dst)
} }
return CopyFile(afs, src, dst, fileMode, dirMode) return CopyFile(fs, src, dst)
} }

View File

@ -2,7 +2,6 @@ package fileutils
import ( import (
"errors" "errors"
"io/fs"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -10,20 +9,20 @@ import (
// CopyDir copies a directory from source to dest and all // CopyDir copies a directory from source to dest and all
// of its sub-directories. It doesn't stop if it finds an error // of its sub-directories. It doesn't stop if it finds an error
// during the copy. Returns an error if any. // during the copy. Returns an error if any.
func CopyDir(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode) error { func CopyDir(fs afero.Fs, source, dest string) error {
// Get properties of source. // Get properties of source.
srcinfo, err := afs.Stat(source) srcinfo, err := fs.Stat(source)
if err != nil { if err != nil {
return err return err
} }
// Create the destination directory. // Create the destination directory.
err = afs.MkdirAll(dest, srcinfo.Mode()) err = fs.MkdirAll(dest, srcinfo.Mode())
if err != nil { if err != nil {
return err return err
} }
dir, _ := afs.Open(source) dir, _ := fs.Open(source)
obs, err := dir.Readdir(-1) obs, err := dir.Readdir(-1)
if err != nil { if err != nil {
return err return err
@ -37,13 +36,13 @@ func CopyDir(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode) e
if obj.IsDir() { if obj.IsDir() {
// Create sub-directories, recursively. // Create sub-directories, recursively.
err = CopyDir(afs, fsource, fdest, fileMode, dirMode) err = CopyDir(fs, fsource, fdest)
if err != nil { if err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
} else { } else {
// Perform the file copy. // Perform the file copy.
err = CopyFile(afs, fsource, fdest, fileMode, dirMode) err = CopyFile(fs, fsource, fdest)
if err != nil { if err != nil {
errs = append(errs, err) errs = append(errs, err)
} }

View File

@ -2,28 +2,29 @@ package fileutils
import ( import (
"io" "io"
"io/fs"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/files"
) )
// MoveFile moves file from src to dst. // MoveFile moves file from src to dst.
// By default the rename filesystem system call is used. If src and dst point to different volumes // 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 // the file copy is used as a fallback
func MoveFile(afs afero.Fs, src, dst string, fileMode, dirMode fs.FileMode) error { func MoveFile(fs afero.Fs, src, dst string) error {
if afs.Rename(src, dst) == nil { if fs.Rename(src, dst) == nil {
return nil return nil
} }
// fallback // fallback
err := Copy(afs, src, dst, fileMode, dirMode) err := Copy(fs, src, dst)
if err != nil { if err != nil {
_ = afs.Remove(dst) _ = fs.Remove(dst)
return err return err
} }
if err := afs.RemoveAll(src); err != nil { if err := fs.RemoveAll(src); err != nil {
return err return err
} }
return nil return nil
@ -31,9 +32,9 @@ func MoveFile(afs afero.Fs, src, dst string, fileMode, dirMode fs.FileMode) erro
// CopyFile copies a file from source to dest and returns // CopyFile copies a file from source to dest and returns
// an error if any. // an error if any.
func CopyFile(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode) error { func CopyFile(fs afero.Fs, source, dest string) error {
// Open the source file. // Open the source file.
src, err := afs.Open(source) src, err := fs.Open(source)
if err != nil { if err != nil {
return err return err
} }
@ -41,13 +42,13 @@ func CopyFile(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode)
// Makes the directory needed to create the dst // Makes the directory needed to create the dst
// file. // file.
err = afs.MkdirAll(filepath.Dir(dest), dirMode) err = fs.MkdirAll(filepath.Dir(dest), files.PermDir)
if err != nil { if err != nil {
return err return err
} }
// Create the destination file. // Create the destination file.
dst, err := afs.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode) dst, err := fs.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, files.PermFile)
if err != nil { if err != nil {
return err return err
} }
@ -60,11 +61,11 @@ func CopyFile(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode)
} }
// Copy the mode // Copy the mode
info, err := afs.Stat(source) info, err := fs.Stat(source)
if err != nil { if err != nil {
return err return err
} }
err = afs.Chmod(dest, info.Mode()) err = fs.Chmod(dest, info.Mode())
if err != nil { if err != nil {
return err return err
} }

View File

@ -1,4 +1,5 @@
//go:build !dev //go:build !dev
// +build !dev
package frontend package frontend

15
frontend/assets_dev.go Normal file
View File

@ -0,0 +1,15 @@
//go:build dev
// +build dev
package frontend
import (
"io/fs"
"os"
)
var assets fs.FS = os.DirFS("frontend")
func Assets() fs.FS {
return assets
}

View File

@ -1,25 +1,26 @@
import pluginVue from "eslint-plugin-vue"; import pluginVue from "eslint-plugin-vue";
import { import vueTsEslintConfig from "@vue/eslint-config-typescript";
defineConfigWithVueTs,
vueTsConfigs,
} from "@vue/eslint-config-typescript";
import prettierConfig from "@vue/eslint-config-prettier"; import prettierConfig from "@vue/eslint-config-prettier";
export default defineConfigWithVueTs( export default [
{ {
name: "app/files-to-lint", name: "app/files-to-lint",
files: ["**/*.{ts,mts,tsx,vue}"], files: ["**/*.{ts,mts,tsx,vue}"],
}, },
{ {
name: "app/files-to-ignore", name: "app/files-to-ignore",
ignores: ["**/dist/**", "**/dist-ssr/**", "**/coverage/**"], ignores: ["**/dist/**", "**/dist-ssr/**", "**/coverage/**"],
}, },
pluginVue.configs["flat/essential"],
vueTsConfigs.recommended, ...pluginVue.configs["flat/essential"],
...vueTsEslintConfig(),
prettierConfig, prettierConfig,
{ {
rules: { rules: {
// Note: you must disable the base rule as it can report incorrect errors // Note: you must disable the base rule as it can report incorrect errors
"no-unused-expressions": "off",
"@typescript-eslint/no-unused-expressions": "off", "@typescript-eslint/no-unused-expressions": "off",
// TODO: theres too many of these from before ts // TODO: theres too many of these from before ts
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
@ -33,5 +34,5 @@ export default defineConfigWithVueTs(
}, },
], ],
}, },
} },
); ];

View File

@ -10,14 +10,18 @@
<title>File Browser</title> <title>File Browser</title>
<link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg" />
<link rel="shortcut icon" href="/img/icons/favicon.ico" />
<link <link
rel="apple-touch-icon" rel="icon"
sizes="180x180" type="image/png"
href="/img/icons/apple-touch-icon.png" sizes="32x32"
href="/img/icons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/img/icons/favicon-16x16.png"
/> />
<meta name="apple-mobile-web-app-title" content="File Browser" />
<!-- Add to home screen for Android and modern mobile browsers --> <!-- Add to home screen for Android and modern mobile browsers -->
<link <link
@ -27,6 +31,19 @@
/> />
<meta name="theme-color" content="#2979ff" /> <meta name="theme-color" content="#2979ff" />
<!-- Add to home screen for Safari on iOS/iPadOS -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="assets" />
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png" />
<!-- Add to home screen for Windows -->
<meta
name="msapplication-TileImage"
content="/img/icons/mstile-144x144.png"
/>
<meta name="msapplication-TileColor" content="#2979ff" />
<!-- Inject Some Variables and generate the manifest json --> <!-- Inject Some Variables and generate the manifest json -->
<script> <script>
// We can assign JSON directly // We can assign JSON directly
@ -39,7 +56,6 @@
DisableUsedPercentage: false, DisableUsedPercentage: false,
EnableExec: true, EnableExec: true,
EnableThumbs: true, EnableThumbs: true,
LogoutPage: "",
LoginPage: true, LoginPage: true,
Name: "", Name: "",
NoAuth: false, NoAuth: false,

View File

@ -4,72 +4,75 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.0.0", "node": ">=22.0.0",
"pnpm": ">=10.0.0" "pnpm": ">=9.0.0"
}, },
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "pnpm run typecheck && vite build", "build": "pnpm run typecheck && vite build",
"clean": "find ./dist -maxdepth 1 -mindepth 1 ! -name '.gitkeep' -exec rm -r {} +", "clean": "find ./dist -maxdepth 1 -mindepth 1 ! -name '.gitkeep' -exec rm -r {} +",
"typecheck": "vue-tsc -p ./tsconfig.app.json --noEmit", "typecheck": "vue-tsc -p ./tsconfig.tsc.json --noEmit",
"lint": "eslint src/", "lint": "eslint src/",
"lint:fix": "eslint --fix src/", "lint:fix": "eslint --fix src/",
"format": "prettier --write ." "format": "prettier --write .",
"test": "playwright test"
}, },
"dependencies": { "dependencies": {
"@chenfengyuan/vue-number-input": "^2.0.1", "@chenfengyuan/vue-number-input": "^2.0.1",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^12.5.0",
"@vueuse/integrations": "^14.0.0", "@vueuse/integrations": "^12.5.0",
"ace-builds": "^1.43.2", "ace-builds": "^1.37.5",
"dayjs": "^1.11.13", "core-js": "^3.40.0",
"dayjs": "^1.11.10",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"epubjs": "^0.3.93", "epubjs": "^0.3.93",
"filesize": "^11.0.13", "filesize": "^10.1.1",
"js-base64": "^3.7.7", "js-base64": "^3.7.7",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^17.0.0", "marked": "^15.0.6",
"material-icons": "^1.13.14", "material-icons": "^1.13.13",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"pinia": "^3.0.4", "pinia": "^2.3.1",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^6.1.1",
"qrcode.vue": "^3.6.0", "qrcode.vue": "^3.4.1",
"tus-js-client": "^4.3.1", "tus-js-client": "^4.3.1",
"utif": "^3.1.0", "utif": "^3.1.0",
"video.js": "^8.23.3", "video.js": "^8.21.0",
"videojs-hotkeys": "^0.2.28", "videojs-hotkeys": "^0.2.28",
"videojs-mobile-ui": "^1.1.1", "videojs-mobile-ui": "^1.1.1",
"vue": "^3.5.17", "vue": "^3.4.21",
"vue-final-modal": "^4.5.5", "vue-final-modal": "^4.5.4",
"vue-i18n": "^11.1.10", "vue-i18n": "^11.1.2",
"vue-lazyload": "^3.0.0", "vue-lazyload": "^3.0.0",
"vue-reader": "^1.2.17", "vue-reader": "^1.2.17",
"vue-router": "^4.5.1", "vue-router": "^4.3.0",
"vue-toastification": "^2.0.0-rc.5" "vue-toastification": "^2.0.0-rc.5"
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^11.0.1", "@intlify/unplugin-vue-i18n": "^6.0.3",
"@tsconfig/node24": "^24.0.2", "@playwright/test": "^1.50.0",
"@tsconfig/node22": "^22.0.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1", "@types/node": "^22.10.10",
"@typescript-eslint/eslint-plugin": "^8.37.0", "@typescript-eslint/eslint-plugin": "^8.21.0",
"@vitejs/plugin-legacy": "^7.2.1", "@vitejs/plugin-legacy": "^6.0.0",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0", "@vue/eslint-config-typescript": "^14.3.0",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.19",
"eslint": "^9.31.0", "concurrently": "^9.1.2",
"eslint-config-prettier": "^10.1.5", "eslint": "^9.19.0",
"eslint-plugin-prettier": "^5.5.1", "eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-vue": "^10.5.1", "eslint-plugin-vue": "^9.24.0",
"postcss": "^8.5.6", "jsdom": "^26.0.0",
"prettier": "^3.6.2", "postcss": "^8.5.1",
"terser": "^5.43.1", "prettier": "^3.4.2",
"typescript": "^5.9.3", "terser": "^5.37.0",
"vite": "^7.2.2", "vite": "^6.1.6",
"vite-plugin-compression2": "^2.3.1", "vite-plugin-compression2": "^1.0.0",
"vue-tsc": "^3.1.3" "vue-tsc": "^2.2.0"
}, },
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48" "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
} }

View File

@ -0,0 +1,80 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1:5173",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
// {
// name: "webkit",
// use: { ...devices["Desktop Safari"] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: "npm run dev",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
});

4911
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#455a64</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,42 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M3245 6989 c-522 -39 -1042 -197 -1480 -449 -849 -488 -1459 -1308
-1673 -2250 -177 -776 -89 -1582 250 -2301 368 -778 1052 -1418 1857 -1739
903 -359 1927 -325 2812 92 778 368 1418 1052 1739 1857 359 903 325 1927 -92
2812 -296 627 -806 1175 -1423 1529 -587 338 -1308 500 -1990 449z m555 -580
c519 -51 1018 -245 1446 -565 788 -588 1229 -1526 1174 -2496 -16 -277 -58
-500 -145 -763 -144 -440 -378 -819 -710 -1150 -452 -452 -1005 -730 -1655
-832 -91 -14 -175 -18 -405 -18 -304 0 -369 6 -595 51 -1105 223 -1999 1092
-2259 2197 -52 221 -73 412 -73 667 0 397 64 732 204 1080 304 752 886 1334
1638 1638 431 174 895 238 1380 191z"/>
<path d="M2670 5215 c0 -13 -44 -15 -335 -15 -352 0 -383 -3 -399 -45 -3 -9
-6 -758 -6 -1663 0 -1168 -3 -1643 -11 -1632 -8 11 -9 8 -4 -15 3 -16 17 -41
31 -55 l24 -25 1530 0 1530 0 24 25 c14 14 26 36 27 50 1 14 1 711 1 1550 l-2
1526 -228 142 -229 142 -136 0 -137 0 0 -600 0 -600 -705 0 -705 0 0 615 0
615 -135 0 c-113 0 -135 -2 -135 -15z m-264 -190 c57 -29 89 -71 103 -137 35
-154 -98 -282 -258 -247 -55 12 -122 62 -148 113 -36 69 -12 186 49 243 62 58
170 70 254 28z m2316 -1702 c17 -15 18 -49 18 -670 l0 -653 -1245 0 -1245 0 0
654 c0 582 2 656 16 670 14 14 139 16 1226 16 1113 0 1213 -1 1230 -17z
m-2602 -1363 c40 -40 13 -100 -43 -100 -60 0 -88 59 -47 100 11 11 31 20 45
20 14 0 34 -9 45 -20z m2840 0 c41 -41 11 -100 -52 -100 -35 0 -58 24 -58 60
0 54 71 79 110 40z"/>
<path d="M2431 3091 c-7 -13 -7 -23 2 -35 11 -15 97 -16 1067 -14 l1055 3 0
30 0 30 -1057 3 c-1023 2 -1058 1 -1067 -17z"/>
<path d="M2436 2675 c-19 -19 -11 -41 17 -49 41 -11 2067 -7 2088 4 23 13 25
46 3 54 -9 3 -483 6 -1054 6 -919 0 -1040 -2 -1054 -15z"/>
<path d="M2447 2273 c-14 -4 -17 -13 -15 -36 l3 -32 1049 -3 c767 -1 1052 1
1062 9 20 16 17 47 -5 59 -20 10 -2055 13 -2094 3z"/>
<path d="M3822 5027 c-21 -23 -22 -30 -22 -293 0 -258 1 -271 20 -292 27 -29
56 -35 140 -30 56 3 75 8 93 26 22 22 22 26 22 298 l0 276 -24 19 c-19 16 -40
19 -115 19 -84 0 -95 -2 -114 -23z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1 +1,147 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="560" height="560" version="1.1" id="prefix__svg44" clip-rule="evenodd" fill-rule="evenodd" image-rendering="optimizeQuality" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"><defs id="prefix__defs4"><style type="text/css" id="style2">.prefix__fil1{fill:#fefefe}.prefix__fil6{fill:#006498}.prefix__fil5{fill:#bdeaff}</style></defs><g id="prefix__g85" transform="translate(-70 -70)"><path class="prefix__fil1" d="M350 71c154 0 279 125 279 279S504 629 350 629 71 504 71 350 196 71 350 71z" id="prefix__path9" fill="#fefefe"/><path d="M475 236l118 151c3 116-149 252-292 198l-76-99 114-156s138-95 136-94z" id="prefix__path11" fill="#332c2b" fill-opacity=".149"/><path d="M231 211h208l38 24v246c0 5-3 8-8 8H231c-5 0-8-3-8-8V219c0-5 3-8 8-8z" id="prefix__path13" fill="#2bbcff"/><path d="M231 211h208l38 24v2l-37-23H231c-4 0-7 3-7 7v263c-1-1-1-2-1-3V219c0-5 3-8 8-8z" id="prefix__path15" fill="#53c6fc"/><path class="prefix__fil5" id="prefix__polygon17" fill="#bdeaff" d="M305 212h113v98H305z"/><path class="prefix__fil5" d="M255 363h189c3 0 5 2 5 4v116H250V367c0-2 2-4 5-4z" id="prefix__path19" fill="#bdeaff"/><path class="prefix__fil6" id="prefix__polygon21" fill="#006498" d="M250 470h199v13H250z"/><path class="prefix__fil6" d="M380 226h10c3 0 6 2 6 5v40c0 3-3 6-6 6h-10c-3 0-6-3-6-6v-40c0-3 3-5 6-5z" id="prefix__path23" fill="#006498"/><path class="prefix__fil1" d="M254 226c10 0 17 7 17 17 0 9-7 16-17 16-9 0-17-7-17-16 0-10 8-17 17-17z" id="prefix__path25" fill="#fefefe"/><path class="prefix__fil6" d="M267 448h165c2 0 3 1 3 3 0 1-1 3-3 3H267c-2 0-3-2-3-3 0-2 1-3 3-3z" id="prefix__path27" fill="#006498"/><path class="prefix__fil6" d="M267 415h165c2 0 3 1 3 3 0 1-1 2-3 2H267c-2 0-3-1-3-2 0-2 1-3 3-3z" id="prefix__path29" fill="#006498"/><path class="prefix__fil6" d="M267 381h165c2 0 3 2 3 3 0 2-1 3-3 3H267c-2 0-3-1-3-3 0-1 1-3 3-3z" id="prefix__path31" fill="#006498"/><path class="prefix__fil1" d="M236 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z" id="prefix__path33" fill="#fefefe"/><path class="prefix__fil1" d="M463 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z" id="prefix__path35" fill="#fefefe"/><path class="prefix__fil6" id="prefix__polygon37" fill="#006498" d="M305 212h-21v98h21z"/><path d="M477 479v2c0 5-3 8-8 8H231c-5 0-8-3-8-8v-2c0 4 3 8 8 8h238c5 0 8-4 8-8z" id="prefix__path39" fill="#0ea5eb"/><path d="M350 70c155 0 280 125 280 280S505 630 350 630 70 505 70 350 195 70 350 70zm0 46c129 0 234 105 234 234S479 584 350 584 116 479 116 350s105-234 234-234z" id="prefix__path41" fill="#2979ff"/></g></svg> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xml:space="preserve"
width="560"
height="560"
version="1.1"
style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"
viewBox="0 0 560 560"
id="svg44"
sodipodi:docname="icon_raw.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)"
inkscape:export-filename="/home/umarcor/filebrowser/logo/icon_raw.svg.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"><metadata
id="metadata48"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1366"
inkscape:window-height="711"
id="namedview46"
showgrid="false"
inkscape:zoom="0.33714286"
inkscape:cx="-172.33051"
inkscape:cy="280"
inkscape:window-x="0"
inkscape:window-y="20"
inkscape:window-maximized="1"
inkscape:current-layer="svg44" />
<defs
id="defs4">
<style
type="text/css"
id="style2">
<![CDATA[
.fil1 {fill:#FEFEFE}
.fil6 {fill:#006498}
.fil7 {fill:#0EA5EB}
.fil8 {fill:#2979FF}
.fil3 {fill:#2BBCFF}
.fil0 {fill:#455A64}
.fil4 {fill:#53C6FC}
.fil5 {fill:#BDEAFF}
.fil2 {fill:#332C2B;fill-opacity:0.149020}
]]>
</style>
</defs>
<g
id="g85"
transform="translate(-70,-70)"><path
class="fil1"
d="M 350,71 C 504,71 629,196 629,350 629,504 504,629 350,629 196,629 71,504 71,350 71,196 196,71 350,71 Z"
id="path9"
inkscape:connector-curvature="0"
style="fill:#fefefe" /><path
class="fil2"
d="M 475,236 593,387 C 596,503 444,639 301,585 L 225,486 339,330 c 0,0 138,-95 136,-94 z"
id="path11"
inkscape:connector-curvature="0"
style="fill:#332c2b;fill-opacity:0.14902003" /><path
class="fil3"
d="m 231,211 h 208 l 38,24 v 246 c 0,5 -3,8 -8,8 H 231 c -5,0 -8,-3 -8,-8 V 219 c 0,-5 3,-8 8,-8 z"
id="path13"
inkscape:connector-curvature="0"
style="fill:#2bbcff" /><path
class="fil4"
d="m 231,211 h 208 l 38,24 v 2 L 440,214 H 231 c -4,0 -7,3 -7,7 v 263 c -1,-1 -1,-2 -1,-3 V 219 c 0,-5 3,-8 8,-8 z"
id="path15"
inkscape:connector-curvature="0"
style="fill:#53c6fc" /><polygon
class="fil5"
points="305,212 418,212 418,310 305,310 "
id="polygon17"
style="fill:#bdeaff" /><path
class="fil5"
d="m 255,363 h 189 c 3,0 5,2 5,4 V 483 H 250 V 367 c 0,-2 2,-4 5,-4 z"
id="path19"
inkscape:connector-curvature="0"
style="fill:#bdeaff" /><polygon
class="fil6"
points="250,470 449,470 449,483 250,483 "
id="polygon21"
style="fill:#006498" /><path
class="fil6"
d="m 380,226 h 10 c 3,0 6,2 6,5 v 40 c 0,3 -3,6 -6,6 h -10 c -3,0 -6,-3 -6,-6 v -40 c 0,-3 3,-5 6,-5 z"
id="path23"
inkscape:connector-curvature="0"
style="fill:#006498" /><path
class="fil1"
d="m 254,226 c 10,0 17,7 17,17 0,9 -7,16 -17,16 -9,0 -17,-7 -17,-16 0,-10 8,-17 17,-17 z"
id="path25"
inkscape:connector-curvature="0"
style="fill:#fefefe" /><path
class="fil6"
d="m 267,448 h 165 c 2,0 3,1 3,3 v 0 c 0,1 -1,3 -3,3 H 267 c -2,0 -3,-2 -3,-3 v 0 c 0,-2 1,-3 3,-3 z"
id="path27"
inkscape:connector-curvature="0"
style="fill:#006498" /><path
class="fil6"
d="m 267,415 h 165 c 2,0 3,1 3,3 v 0 c 0,1 -1,2 -3,2 H 267 c -2,0 -3,-1 -3,-2 v 0 c 0,-2 1,-3 3,-3 z"
id="path29"
inkscape:connector-curvature="0"
style="fill:#006498" /><path
class="fil6"
d="m 267,381 h 165 c 2,0 3,2 3,3 v 0 c 0,2 -1,3 -3,3 H 267 c -2,0 -3,-1 -3,-3 v 0 c 0,-1 1,-3 3,-3 z"
id="path31"
inkscape:connector-curvature="0"
style="fill:#006498" /><path
class="fil1"
d="m 236,472 c 3,0 5,2 5,5 0,2 -2,4 -5,4 -3,0 -5,-2 -5,-4 0,-3 2,-5 5,-5 z"
id="path33"
inkscape:connector-curvature="0"
style="fill:#fefefe" /><path
class="fil1"
d="m 463,472 c 3,0 5,2 5,5 0,2 -2,4 -5,4 -3,0 -5,-2 -5,-4 0,-3 2,-5 5,-5 z"
id="path35"
inkscape:connector-curvature="0"
style="fill:#fefefe" /><polygon
class="fil6"
points="305,212 284,212 284,310 305,310 "
id="polygon37"
style="fill:#006498" /><path
class="fil7"
d="m 477,479 v 2 c 0,5 -3,8 -8,8 H 231 c -5,0 -8,-3 -8,-8 v -2 c 0,4 3,8 8,8 h 238 c 5,0 8,-4 8,-8 z"
id="path39"
inkscape:connector-curvature="0"
style="fill:#0ea5eb" /><path
class="fil8"
d="M 350,70 C 505,70 630,195 630,350 630,505 505,630 350,630 195,630 70,505 70,350 70,195 195,70 350,70 Z m 0,46 C 479,116 584,221 584,350 584,479 479,584 350,584 221,584 116,479 116,350 116,221 221,116 350,116 Z"
id="path41"
inkscape:connector-curvature="0"
style="fill:#2979ff" /></g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -20,16 +20,16 @@
<link <link
rel="icon" rel="icon"
type="image/svg+xml" type="image/png"
href="[{[ .StaticURL ]}]/img/icons/favicon.svg" sizes="32x32"
href="[{[ .StaticURL ]}]/img/icons/favicon-32x32.png"
/> />
<link rel="shortcut icon" href="[{[ .StaticURL ]}]/img/icons/favicon.ico" />
<link <link
rel="apple-touch-icon" rel="icon"
sizes="180x180" type="image/png"
href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png" sizes="16x16"
href="[{[ .StaticURL ]}]/img/icons/favicon-16x16.png"
/> />
<meta name="apple-mobile-web-app-title" content="File Browser" />
<!-- Add to home screen for Android and modern mobile browsers --> <!-- Add to home screen for Android and modern mobile browsers -->
<link <link
@ -42,6 +42,25 @@
content="[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]" content="[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]"
/> />
<!-- Add to home screen for Safari on iOS/iPadOS -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="assets" />
<link
rel="apple-touch-icon"
href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png"
/>
<!-- Add to home screen for Windows -->
<meta
name="msapplication-TileImage"
content="[{[ .StaticURL ]}]/img/icons/mstile-144x144.png"
/>
<meta
name="msapplication-TileColor"
content="[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]"
/>
<!-- Inject Some Variables and generate the manifest json --> <!-- Inject Some Variables and generate the manifest json -->
<script> <script>
// We can assign JSON directly // We can assign JSON directly

View File

@ -41,12 +41,12 @@ export function download(
let url = `${baseURL}/api/public/dl/${hash}`; let url = `${baseURL}/api/public/dl/${hash}`;
if (files.length === 1) { if (files.length === 1) {
url += files[0] + "?"; url += encodeURIComponent(files[0]) + "?";
} else { } else {
let arg = ""; let arg = "";
for (const file of files) { for (const file of files) {
arg += file + ","; arg += encodeURIComponent(file) + ",";
} }
arg = arg.substring(0, arg.length - 1); arg = arg.substring(0, arg.length - 1);

View File

@ -1,12 +1,7 @@
import { fetchURL, removePrefix, StatusError } from "./utils"; import { fetchURL, removePrefix } from "./utils";
import url from "../utils/url"; import url from "../utils/url";
export default async function search( export default async function search(base: string, query: string) {
base: string,
query: string,
signal: AbortSignal,
callback: (item: ResourceItem) => void
) {
base = removePrefix(base); base = removePrefix(base);
query = encodeURIComponent(query); query = encodeURIComponent(query);
@ -14,60 +9,19 @@ export default async function search(
base += "/"; base += "/";
} }
const res = await fetchURL(`/api/search${base}?query=${query}`, { signal }); const res = await fetchURL(`/api/search${base}?query=${query}`, {});
if (!res.body) {
throw new StatusError("000 No connection", 0);
}
try {
// Try streaming approach first (modern browsers)
if (res.body && typeof res.body.pipeThrough === "function") {
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (value) {
buffer += value;
}
const lines = buffer.split(/\n/);
let lastLine = lines.pop();
// Save incomplete last line
if (!lastLine) {
lastLine = "";
}
buffer = lastLine;
for (const line of lines) { let data = await res.json();
if (line) {
const item = JSON.parse(line) as ResourceItem; data = data.map((item: UploadItem) => {
item.url = `/files${base}` + url.encodePath(item.path); item.url = `/files${base}` + url.encodePath(item.path);
if (item.isDir) {
item.url += "/"; if (item.dir) {
} item.url += "/";
callback(item);
}
}
if (done) break;
}
} else {
// Fallback for browsers without streaming support (e.g., Safari)
const text = await res.text();
const lines = text.split(/\n/);
for (const line of lines) {
if (line) {
const item = JSON.parse(line) as ResourceItem;
item.url = `/files${base}` + url.encodePath(item.path);
if (item.isDir) {
item.url += "/";
}
callback(item);
}
}
} }
} catch (e) {
// Check if the error is an intentional cancellation return item;
if (e instanceof Error && e.name === "AbortError") { });
throw new StatusError("000 No connection", 0, true);
} return data;
throw e;
}
} }

View File

@ -1,11 +1,18 @@
import * as tus from "tus-js-client"; import * as tus from "tus-js-client";
import { baseURL, tusEndpoint, tusSettings, origin } from "@/utils/constants"; import { baseURL, tusEndpoint, tusSettings } from "@/utils/constants";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useUploadStore } from "@/stores/upload";
import { removePrefix } from "@/api/utils"; import { removePrefix } from "@/api/utils";
import { fetchURL } from "./utils";
const RETRY_BASE_DELAY = 1000; const RETRY_BASE_DELAY = 1000;
const RETRY_MAX_DELAY = 20000; const RETRY_MAX_DELAY = 20000;
const CURRENT_UPLOAD_LIST: { [key: string]: tus.Upload } = {}; const SPEED_UPDATE_INTERVAL = 1000;
const ALPHA = 0.2;
const ONE_MINUS_ALPHA = 1 - ALPHA;
const RECENT_SPEEDS_LIMIT = 5;
const MB_DIVISOR = 1024 * 1024;
const CURRENT_UPLOAD_LIST: CurrentUploadList = {};
export async function upload( export async function upload(
filePath: string, filePath: string,
@ -21,6 +28,8 @@ export async function upload(
filePath = removePrefix(filePath); filePath = removePrefix(filePath);
const resourcePath = `${tusEndpoint}${filePath}?override=${overwrite}`; const resourcePath = `${tusEndpoint}${filePath}?override=${overwrite}`;
await createUpload(resourcePath);
const authStore = useAuthStore(); const authStore = useAuthStore();
// Exit early because of typescript, tus content can't be a string // Exit early because of typescript, tus content can't be a string
@ -29,7 +38,7 @@ export async function upload(
} }
return new Promise<void | string>((resolve, reject) => { return new Promise<void | string>((resolve, reject) => {
const upload = new tus.Upload(content, { const upload = new tus.Upload(content, {
endpoint: `${origin}${baseURL}${resourcePath}`, uploadUrl: `${baseURL}${resourcePath}`,
chunkSize: tusSettings.chunkSize, chunkSize: tusSettings.chunkSize,
retryDelays: computeRetryDelays(tusSettings), retryDelays: computeRetryDelays(tusSettings),
parallelUploads: 1, parallelUploads: 1,
@ -37,51 +46,63 @@ export async function upload(
headers: { headers: {
"X-Auth": authStore.jwt, "X-Auth": authStore.jwt,
}, },
onShouldRetry: function (err) { onError: function (error) {
const status = err.originalResponse if (CURRENT_UPLOAD_LIST[filePath].interval) {
? err.originalResponse.getStatus() clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
: 0;
// Do not retry for file conflict.
if (status === 409) {
return false;
} }
return true;
},
onError: function (error: Error | tus.DetailedError) {
delete CURRENT_UPLOAD_LIST[filePath]; delete CURRENT_UPLOAD_LIST[filePath];
reject(new Error(`Upload failed: ${error.message}`));
if (error.message === "Upload aborted") {
return reject(error);
}
const message =
error instanceof tus.DetailedError
? error.originalResponse === null
? "000 No connection"
: error.originalResponse.getBody()
: "Upload failed";
console.error(error);
reject(new Error(message));
}, },
onProgress: function (bytesUploaded) { onProgress: function (bytesUploaded) {
const fileData = CURRENT_UPLOAD_LIST[filePath];
fileData.currentBytesUploaded = bytesUploaded;
if (!fileData.hasStarted) {
fileData.hasStarted = true;
fileData.lastProgressTimestamp = Date.now();
fileData.interval = window.setInterval(() => {
calcProgress(filePath);
}, SPEED_UPDATE_INTERVAL);
}
if (typeof onupload === "function") { if (typeof onupload === "function") {
onupload({ loaded: bytesUploaded }); onupload({ loaded: bytesUploaded });
} }
}, },
onSuccess: function () { onSuccess: function () {
if (CURRENT_UPLOAD_LIST[filePath].interval) {
clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
}
delete CURRENT_UPLOAD_LIST[filePath]; delete CURRENT_UPLOAD_LIST[filePath];
resolve(); resolve();
}, },
}); });
CURRENT_UPLOAD_LIST[filePath] = upload; CURRENT_UPLOAD_LIST[filePath] = {
upload: upload,
recentSpeeds: [],
initialBytesUploaded: 0,
currentBytesUploaded: 0,
currentAverageSpeed: 0,
lastProgressTimestamp: null,
sumOfRecentSpeeds: 0,
hasStarted: false,
interval: undefined,
};
upload.start(); upload.start();
}); });
} }
async function createUpload(resourcePath: string) {
const headResp = await fetchURL(resourcePath, {
method: "POST",
});
if (headResp.status !== 201) {
throw new Error(
`Failed to create an upload: ${headResp.status} ${headResp.statusText}`
);
}
}
function computeRetryDelays(tusSettings: TusSettings): number[] | undefined { function computeRetryDelays(tusSettings: TusSettings): number[] | undefined {
if (!tusSettings.retryCount || tusSettings.retryCount < 1) { if (!tusSettings.retryCount || tusSettings.retryCount < 1) {
// Disable retries altogether // Disable retries altogether
@ -109,13 +130,83 @@ function isTusSupported() {
return tus.isSupported === true; return tus.isSupported === true;
} }
function computeETA(state: ETAState, speed?: number) {
if (state.speedMbyte === 0) {
return Infinity;
}
const totalSize = state.sizes.reduce(
(acc: number, size: number) => acc + size,
0
);
const uploadedSize = state.progress.reduce(
(acc: number, progress: Progress) => {
if (typeof progress === "number") {
return acc + progress;
}
return acc;
},
0
);
const remainingSize = totalSize - uploadedSize;
const speedBytesPerSecond = (speed ?? state.speedMbyte) * 1024 * 1024;
return remainingSize / speedBytesPerSecond;
}
function computeGlobalSpeedAndETA() {
const uploadStore = useUploadStore();
let totalSpeed = 0;
let totalCount = 0;
for (const filePath in CURRENT_UPLOAD_LIST) {
totalSpeed += CURRENT_UPLOAD_LIST[filePath].currentAverageSpeed;
totalCount++;
}
if (totalCount === 0) return { speed: 0, eta: Infinity };
const averageSpeed = totalSpeed / totalCount;
const averageETA = computeETA(uploadStore, averageSpeed);
return { speed: averageSpeed, eta: averageETA };
}
function calcProgress(filePath: string) {
const uploadStore = useUploadStore();
const fileData = CURRENT_UPLOAD_LIST[filePath];
const elapsedTime =
(Date.now() - (fileData.lastProgressTimestamp ?? 0)) / 1000;
const bytesSinceLastUpdate =
fileData.currentBytesUploaded - fileData.initialBytesUploaded;
const currentSpeed = bytesSinceLastUpdate / MB_DIVISOR / elapsedTime;
if (fileData.recentSpeeds.length >= RECENT_SPEEDS_LIMIT) {
fileData.sumOfRecentSpeeds -= fileData.recentSpeeds.shift() ?? 0;
}
fileData.recentSpeeds.push(currentSpeed);
fileData.sumOfRecentSpeeds += currentSpeed;
const avgRecentSpeed =
fileData.sumOfRecentSpeeds / fileData.recentSpeeds.length;
fileData.currentAverageSpeed =
ALPHA * avgRecentSpeed + ONE_MINUS_ALPHA * fileData.currentAverageSpeed;
const { speed, eta } = computeGlobalSpeedAndETA();
uploadStore.setUploadSpeed(speed);
uploadStore.setETA(eta);
fileData.initialBytesUploaded = fileData.currentBytesUploaded;
fileData.lastProgressTimestamp = Date.now();
}
export function abortAllUploads() { export function abortAllUploads() {
for (const filePath in CURRENT_UPLOAD_LIST) { for (const filePath in CURRENT_UPLOAD_LIST) {
if (CURRENT_UPLOAD_LIST[filePath]) { if (CURRENT_UPLOAD_LIST[filePath].interval) {
CURRENT_UPLOAD_LIST[filePath].abort(true); clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
CURRENT_UPLOAD_LIST[filePath].options!.onError!( }
new Error("Upload aborted") if (CURRENT_UPLOAD_LIST[filePath].upload) {
); CURRENT_UPLOAD_LIST[filePath].upload.abort(true);
} }
delete CURRENT_UPLOAD_LIST[filePath]; delete CURRENT_UPLOAD_LIST[filePath];
} }

View File

@ -8,13 +8,12 @@ export async function get(id: number) {
return fetchJSON<IUser>(`/api/users/${id}`, {}); return fetchJSON<IUser>(`/api/users/${id}`, {});
} }
export async function create(user: IUser, currentPassword: string) { export async function create(user: IUser) {
const res = await fetchURL(`/api/users`, { const res = await fetchURL(`/api/users`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
what: "user", what: "user",
which: [], which: [],
current_password: currentPassword,
data: user, data: user,
}), }),
}); });
@ -26,17 +25,12 @@ export async function create(user: IUser, currentPassword: string) {
throw new StatusError(await res.text(), res.status); throw new StatusError(await res.text(), res.status);
} }
export async function update( export async function update(user: Partial<IUser>, which = ["all"]) {
user: Partial<IUser>,
which = ["all"],
currentPassword: string | null = null
) {
await fetchURL(`/api/users/${user.id}`, { await fetchURL(`/api/users/${user.id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
what: "user", what: "user",
which: which, which: which,
...(currentPassword != null ? { current_password: currentPassword } : {}),
data: user, data: user,
}), }),
}); });

View File

@ -91,21 +91,3 @@ export function createURL(endpoint: string, searchParams = {}): string {
return url.toString(); return url.toString();
} }
export function setSafeTimeout(callback: () => void, delay: number): number {
const MAX_DELAY = 86_400_000;
let remaining = delay;
function scheduleNext(): number {
if (remaining <= MAX_DELAY) {
return window.setTimeout(callback, remaining);
} else {
return window.setTimeout(() => {
remaining -= MAX_DELAY;
scheduleNext();
}, MAX_DELAY);
}
}
return scheduleNext();
}

View File

@ -1,47 +0,0 @@
<template>
<div
class="context-menu"
ref="contextMenu"
v-show="show"
:style="{
top: `${props.pos.y}px`,
left: `${left}px`,
}"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, onUnmounted } from "vue";
const emit = defineEmits(["hide"]);
const props = defineProps<{ show: boolean; pos: { x: number; y: number } }>();
const contextMenu = ref<HTMLElement | null>(null);
const left = computed(() => {
return Math.min(
props.pos.x,
window.innerWidth - (contextMenu.value?.clientWidth ?? 0)
);
});
const hideContextMenu = () => {
emit("hide");
};
watch(
() => props.show,
(val) => {
if (val) {
document.addEventListener("click", hideContextMenu);
} else {
document.removeEventListener("click", hideContextMenu);
}
}
);
onUnmounted(() => {
document.removeEventListener("click", hideContextMenu);
});
</script>

View File

@ -192,8 +192,7 @@ export default {
style["position"] = "absolute"; style["position"] = "absolute";
style["top"] = "0"; style["top"] = "0";
style["height"] = "100%"; style["height"] = "100%";
((style["min-height"] = this.size_px + "px"), (style["min-height"] = this.size_px + "px"), (style["z-index"] = "-1");
(style["z-index"] = "-1"));
} }
return style; return style;

View File

@ -5,11 +5,10 @@
v-if="active" v-if="active"
class="action" class="action"
@click="close" @click="close"
:aria-label="closeButtonTitle" :aria-label="$t('buttons.close')"
:title="closeButtonTitle" :title="$t('buttons.close')"
> >
<i v-if="ongoing" class="material-icons">stop_circle</i> <i class="material-icons">arrow_back</i>
<i v-else class="material-icons">arrow_back</i>
</button> </button>
<i v-else class="material-icons">search</i> <i v-else class="material-icons">search</i>
<input <input
@ -22,15 +21,6 @@
:aria-label="$t('search.search')" :aria-label="$t('search.search')"
:placeholder="$t('search.search')" :placeholder="$t('search.search')"
/> />
<i
v-show="ongoing"
class="material-icons spin"
style="display: inline-block"
>autorenew
</i>
<span style="margin-top: 5px" v-show="results.length > 0">
{{ results.length }}
</span>
</div> </div>
<div id="result" ref="result"> <div id="result" ref="result">
@ -67,6 +57,9 @@
</li> </li>
</ul> </ul>
</div> </div>
<p id="renew">
<i class="material-icons spin">autorenew</i>
</p>
</div> </div>
</div> </div>
</template> </template>
@ -77,11 +70,10 @@ import { useLayoutStore } from "@/stores/layout";
import url from "@/utils/url"; import url from "@/utils/url";
import { search } from "@/api"; import { search } from "@/api";
import { computed, inject, onMounted, ref, watch, onUnmounted } from "vue"; import { computed, inject, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { StatusError } from "@/api/utils";
const boxes = { const boxes = {
image: { label: "images", icon: "insert_photo" }, image: { label: "images", icon: "insert_photo" },
@ -92,7 +84,6 @@ const boxes = {
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const fileStore = useFileStore(); const fileStore = useFileStore();
let searchAbortController = new AbortController();
const { currentPromptName } = storeToRefs(layoutStore); const { currentPromptName } = storeToRefs(layoutStore);
@ -133,7 +124,9 @@ watch(currentPromptName, (newVal, oldVal) => {
}); });
watch(prompt, () => { watch(prompt, () => {
reset(); if (results.value.length) {
reset();
}
}); });
// ...mapState(useFileStore, ["isListing"]), // ...mapState(useFileStore, ["isListing"]),
@ -156,10 +149,6 @@ const filteredResults = computed(() => {
return results.value.slice(0, resultsCount.value); return results.value.slice(0, resultsCount.value);
}); });
const closeButtonTitle = computed(() => {
return ongoing.value ? t("buttons.stopSearch") : t("buttons.close");
});
onMounted(() => { onMounted(() => {
if (result.value === null) { if (result.value === null) {
return; return;
@ -175,23 +164,14 @@ onMounted(() => {
}); });
}); });
onUnmounted(() => {
abortLastSearch();
});
const open = () => { const open = () => {
!active.value && layoutStore.showHover("search"); !active.value && layoutStore.showHover("search");
}; };
const close = (event: Event) => { const close = (event: Event) => {
if (ongoing.value) { event.stopPropagation();
abortLastSearch(); event.preventDefault();
ongoing.value = false; layoutStore.closeHovers();
} else {
event.stopPropagation();
event.preventDefault();
layoutStore.closeHovers();
}
}; };
const keyup = (event: KeyboardEvent) => { const keyup = (event: KeyboardEvent) => {
@ -208,16 +188,11 @@ const init = (string: string) => {
}; };
const reset = () => { const reset = () => {
abortLastSearch();
ongoing.value = false; ongoing.value = false;
resultsCount.value = 50; resultsCount.value = 50;
results.value = []; results.value = [];
}; };
const abortLastSearch = () => {
searchAbortController.abort();
};
const submit = async (event: Event) => { const submit = async (event: Event) => {
event.preventDefault(); event.preventDefault();
@ -233,16 +208,8 @@ const submit = async (event: Event) => {
ongoing.value = true; ongoing.value = true;
try { try {
abortLastSearch(); results.value = await search(path, prompt.value);
searchAbortController = new AbortController();
results.value = [];
await search(path, prompt.value, searchAbortController.signal, (item) =>
results.value.push(item)
);
} catch (error: any) { } catch (error: any) {
if (error instanceof StatusError && error.is_canceled) {
return;
}
$showError(error); $showError(error);
} }

View File

@ -2,10 +2,6 @@
<div v-show="active" @click="closeHovers" class="overlay"></div> <div v-show="active" @click="closeHovers" class="overlay"></div>
<nav :class="{ active }"> <nav :class="{ active }">
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<button @click="toAccountSettings" class="action">
<i class="material-icons">person</i>
<span>{{ user.username }}</span>
</button>
<button <button
class="action" class="action"
@click="toRoot" @click="toRoot"
@ -38,32 +34,32 @@
</button> </button>
</div> </div>
<div v-if="user.perm.admin"> <div>
<button <button
class="action" class="action"
@click="toGlobalSettings" @click="toSettings"
:aria-label="$t('sidebar.settings')" :aria-label="$t('sidebar.settings')"
:title="$t('sidebar.settings')" :title="$t('sidebar.settings')"
> >
<i class="material-icons">settings_applications</i> <i class="material-icons">settings_applications</i>
<span>{{ $t("sidebar.settings") }}</span> <span>{{ $t("sidebar.settings") }}</span>
</button> </button>
<button
v-if="canLogout"
@click="logout"
class="action"
id="logout"
:aria-label="$t('sidebar.logout')"
:title="$t('sidebar.logout')"
>
<i class="material-icons">exit_to_app</i>
<span>{{ $t("sidebar.logout") }}</span>
</button>
</div> </div>
<button
v-if="canLogout"
@click="logout"
class="action"
id="logout"
:aria-label="$t('sidebar.logout')"
:title="$t('sidebar.logout')"
>
<i class="material-icons">exit_to_app</i>
<span>{{ $t("sidebar.logout") }}</span>
</button>
</template> </template>
<template v-else> <template v-else>
<router-link <router-link
v-if="!hideLoginButton"
class="action" class="action"
to="/login" to="/login"
:aria-label="$t('sidebar.login')" :aria-label="$t('sidebar.login')"
@ -125,16 +121,15 @@ import * as auth from "@/utils/auth";
import { import {
version, version,
signup, signup,
hideLoginButton,
disableExternal, disableExternal,
disableUsedPercentage, disableUsedPercentage,
noAuth, noAuth,
logoutPage,
loginPage, loginPage,
} from "@/utils/constants"; } from "@/utils/constants";
import { files as api } from "@/api"; import { files as api } from "@/api";
import ProgressBar from "@/components/ProgressBar.vue"; import ProgressBar from "@/components/ProgressBar.vue";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { StatusError } from "@/api/utils.js";
const USAGE_DEFAULT = { used: "0 B", total: "0 B", usedPercentage: 0 }; const USAGE_DEFAULT = { used: "0 B", total: "0 B", usedPercentage: 0 };
@ -156,11 +151,10 @@ export default {
return this.currentPromptName === "sidebar"; return this.currentPromptName === "sidebar";
}, },
signup: () => signup, signup: () => signup,
hideLoginButton: () => hideLoginButton,
version: () => version, version: () => version,
disableExternal: () => disableExternal, disableExternal: () => disableExternal,
disableUsedPercentage: () => disableUsedPercentage, disableUsedPercentage: () => disableUsedPercentage,
canLogout: () => !noAuth && (loginPage || logoutPage !== "/login"), canLogout: () => !noAuth && loginPage,
}, },
methods: { methods: {
...mapActions(useLayoutStore, ["closeHovers", "showHover"]), ...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
@ -184,20 +178,20 @@ export default {
total: prettyBytes(usage.total, { binary: true }), total: prettyBytes(usage.total, { binary: true }),
usedPercentage: Math.round((usage.used / usage.total) * 100), usedPercentage: Math.round((usage.used / usage.total) * 100),
}; };
} finally { } catch (error) {
return Object.assign(this.usage, usageStats); if (error instanceof StatusError && error.is_canceled) {
return;
}
this.$showError(error);
} }
return Object.assign(this.usage, usageStats);
}, },
toRoot() { toRoot() {
this.$router.push({ path: "/files" }); this.$router.push({ path: "/files" });
this.closeHovers(); this.closeHovers();
}, },
toAccountSettings() { toSettings() {
this.$router.push({ path: "/settings/profile" }); this.$router.push({ path: "/settings" });
this.closeHovers();
},
toGlobalSettings() {
this.$router.push({ path: "/settings/global" });
this.closeHovers(); this.closeHovers();
}, },
help() { help() {

View File

@ -1,252 +0,0 @@
<template>
<div class="csv-viewer">
<div v-if="displayError" class="csv-error">
<i class="material-icons">error</i>
<p>{{ displayError }}</p>
</div>
<div v-else-if="data.headers.length === 0" class="csv-empty">
<i class="material-icons">description</i>
<p>{{ $t("files.lonely") }}</p>
</div>
<div v-else class="csv-table-container" @wheel.stop @touchmove.stop>
<table class="csv-table">
<thead>
<tr>
<th v-for="(header, index) in data.headers" :key="index">
{{ header || `Column ${index + 1}` }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in data.rows" :key="rowIndex">
<td v-for="(cell, cellIndex) in row" :key="cellIndex">
{{ cell }}
</td>
</tr>
</tbody>
</table>
<div class="csv-footer">
<div class="csv-info" v-if="data.rows.length > 100">
<i class="material-icons">info</i>
<span>
{{ $t("files.showingRows", { count: data.rows.length }) }}</span
>
</div>
<div class="column-separator">
<label for="columnSeparator">{{ $t("files.columnSeparator") }}</label>
<select
id="columnSeparator"
class="input input--block"
v-model="columnSeparator"
>
<option :value="[',']">
{{ $t("files.csvSeparators.comma") }}
</option>
<option :value="[';']">
{{ $t("files.csvSeparators.semicolon") }}
</option>
<option :value="[',', ';']">
{{ $t("files.csvSeparators.both") }}
</option>
</select>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { parseCSV, type CsvData } from "@/utils/csv";
import { computed, ref } from "vue";
interface Props {
content: string;
error?: string;
}
const props = withDefaults(defineProps<Props>(), {
error: "",
});
const columnSeparator = ref([","]);
const data = computed<CsvData>(() => {
try {
return parseCSV(props.content, columnSeparator.value);
} catch (e) {
console.error("Failed to parse CSV:", e);
return { headers: [], rows: [] };
}
});
const displayError = computed(() => {
// External error takes priority (e.g., file too large)
if (props.error) {
return props.error;
}
// Check for parse errors
if (
props.content &&
props.content.trim().length > 0 &&
data.value.headers.length === 0
) {
return "Failed to parse CSV file";
}
return null;
});
</script>
<style scoped>
.csv-viewer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--surfacePrimary);
color: var(--textSecondary);
padding: 1rem;
padding-top: 4em;
box-sizing: border-box;
}
.csv-error,
.csv-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
color: var(--textPrimary);
}
.csv-error i,
.csv-empty i {
font-size: 4rem;
opacity: 0.5;
}
.csv-error p,
.csv-empty p {
font-size: 1.1rem;
margin: 0;
}
.csv-table-container {
flex: 1;
overflow: auto;
background-color: var(--surfacePrimary);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* Scrollbar styling for better visibility */
.csv-table-container::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.csv-table-container::-webkit-scrollbar-track {
background: var(--background);
border-radius: 4px;
}
.csv-table-container::-webkit-scrollbar-thumb {
background: var(--borderSecondary);
border-radius: 4px;
}
.csv-table-container::-webkit-scrollbar-thumb:hover {
background: var(--textPrimary);
}
.csv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
background-color: var(--surfacePrimary);
}
.csv-table thead {
position: sticky;
top: 0;
z-index: 10;
background-color: var(--surfaceSecondary);
}
.csv-table th {
padding: 0.875rem 1rem;
text-align: left;
font-weight: 600;
border-bottom: 2px solid var(--borderSecondary);
background-color: var(--surfaceSecondary);
white-space: nowrap;
color: var(--textSecondary);
font-size: 0.875rem;
}
.csv-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--borderPrimary);
white-space: nowrap;
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--textSecondary);
}
.csv-table tbody tr:nth-child(even) {
background-color: var(--background);
}
.csv-table tbody tr:hover {
background-color: var(--hover);
transition: background-color 0.15s ease;
}
.csv-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.5rem;
}
.csv-footer > :only-child {
margin-left: auto;
}
.csv-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
background-color: var(--surfaceSecondary);
border-radius: 4px;
border-left: 3px solid var(--blue);
color: var(--textSecondary);
font-size: 0.875rem;
}
.column-separator {
display: flex;
align-items: center;
gap: 0.5rem;
}
.column-separator > label {
font-size: small;
text-align: end;
}
.column-separator > select {
margin-bottom: 0;
}
.csv-info i {
font-size: 1.2rem;
color: var(--blue);
}
</style>

Some files were not shown because too many files have changed in this diff Show More