Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d76695bf | |||
| 8993c8031a | |||
| 41ecbc5f73 | |||
| b59d30784f | |||
| b91b7c431c | |||
|
|
9c62bc990b | ||
|
|
b7e69dc2a3 | ||
|
|
4692ac2748 | ||
|
|
85c64667c1 | ||
|
|
a58f930d58 | ||
|
|
69b54f1ae8 | ||
|
|
ef70de2676 | ||
|
|
1053aace8c | ||
|
|
004488c15b | ||
|
|
3032a1fade | ||
|
|
87f1d00fd7 | ||
|
|
f0f2f1ff06 | ||
|
|
08c8ede587 | ||
|
|
1154b49974 | ||
|
|
785b7abb7b | ||
|
|
550a73b6ba | ||
|
|
e7ea1ad27d | ||
|
|
24781badd4 | ||
|
|
4094fb359b | ||
|
|
cfa6c5864e | ||
|
|
59ca0c340a | ||
|
|
037e29a708 | ||
|
|
fda8a99292 | ||
|
|
208535a8cc | ||
|
|
464b581953 | ||
|
|
20312ff18e | ||
|
|
219582c0b0 | ||
|
|
4fd18a382c | ||
|
|
94ec786d34 | ||
|
|
25e9b85259 | ||
|
|
ffa893e9ac | ||
|
|
b8151a038a | ||
|
|
943e5340d0 | ||
|
|
88aa56f53b | ||
|
|
a12a612970 | ||
|
|
a2d80c62c1 | ||
|
|
1faa21844f | ||
|
|
7a93b2a062 | ||
|
|
032d6c7520 | ||
|
|
9595f3939c | ||
|
|
60b1ee8bb9 | ||
|
|
79d1aa9229 | ||
|
|
20bfd131c6 | ||
|
|
f89975603e | ||
|
|
2d3b64ecf6 | ||
|
|
579756dbb5 | ||
|
|
c11c986b73 | ||
|
|
3fdca6dfd9 | ||
|
|
cf966578d8 | ||
|
|
6d4c867672 | ||
|
|
169e97e6f9 | ||
|
|
7fa3432f25 | ||
|
|
e9ce7fa5aa | ||
|
|
849f5ad443 | ||
|
|
c1715992bd | ||
|
|
e4f2503298 | ||
|
|
152f8302f7 | ||
|
|
4cbb4b73af | ||
|
|
58cc874828 | ||
|
|
124def5cd7 | ||
|
|
2d88c06761 | ||
|
|
204a3f0eea | ||
|
|
f029c3005e | ||
|
|
a6934e40ff | ||
|
|
98662ac5ec | ||
|
|
5cf8ce8db5 | ||
|
|
062dc414f8 | ||
|
|
63582b644c | ||
|
|
4302ece49b | ||
|
|
e1ee14d827 | ||
|
|
84ca722261 | ||
|
|
b9ac45d5da | ||
|
|
701522a060 | ||
|
|
78e0395960 | ||
|
|
f0680cf0f5 | ||
|
|
982405ec94 | ||
|
|
a78aaed214 | ||
|
|
df11a7dd0e | ||
|
|
79980bcf52 | ||
|
|
3be134f23d | ||
|
|
279a5ccd1e | ||
|
|
87f73ac982 | ||
|
|
85cde140ba | ||
|
|
119609c834 | ||
|
|
d48f5665d6 | ||
|
|
54306bdc87 | ||
|
|
33deedf559 | ||
|
|
88d1eecc4e | ||
|
|
43db19f8c8 | ||
|
|
a360f26979 | ||
|
|
ab367a2740 | ||
|
|
5df5508a85 | ||
|
|
6d5aa355e4 | ||
|
|
a3b5584505 | ||
|
|
8db2411cd4 | ||
|
|
c284de9d2c | ||
|
|
984ea7b569 | ||
|
|
fd7b70cf38 | ||
|
|
13e3b46718 | ||
|
|
d759ab0bd8 | ||
|
|
00323a8f37 | ||
|
|
420adea7e6 | ||
|
|
f576d38a7e | ||
|
|
9bdc67c207 | ||
|
|
f41585f039 | ||
|
|
89be0b1873 | ||
|
|
8c5dc7641e | ||
|
|
0a0cb8046f | ||
|
|
f89435c068 | ||
|
|
fb8d41eb9a | ||
|
|
0fadaccaa2 | ||
|
|
e24e1f1aba | ||
|
|
5de4099cba | ||
|
|
d01493106d | ||
|
|
2d9689dd6a | ||
|
|
c4c1cea230 | ||
|
|
ceb5e723f3 | ||
|
|
ebc7d2303d | ||
|
|
23c4e4565b | ||
|
|
17f1e08a58 | ||
|
|
ffc850454e | ||
|
|
13814e1119 | ||
|
|
4e9e312984 | ||
|
|
ce3b407c51 | ||
|
|
fb5d099f85 | ||
|
|
1ace579a55 | ||
|
|
ac7b49c148 | ||
|
|
9d44932dba | ||
|
|
0d973d3aad | ||
|
|
cacc0999e9 | ||
|
|
42d1b6f3ae | ||
|
|
bb10c3dfa9 | ||
|
|
ce76aa23a6 | ||
|
|
94b635daf8 | ||
|
|
31871aaa4b | ||
|
|
9d465663db | ||
|
|
70081f2647 | ||
|
|
fa9d2f266f | ||
|
|
8fcfb502ca | ||
|
|
0bab2aba9e | ||
|
|
38951d950f | ||
|
|
dda8fdbcb2 | ||
|
|
bf3ba65782 | ||
|
|
f35b7c9d9d | ||
|
|
e9506c3eae | ||
|
|
bb4465548b | ||
|
|
cf8b5ca768 | ||
|
|
f93d760b1b | ||
|
|
1495ee8dd8 | ||
|
|
8a7279e3ee | ||
|
|
fdff7a38f4 | ||
|
|
f26a68587d | ||
|
|
1c62038344 | ||
|
|
6eb7b4b8ec | ||
|
|
f11fc37409 | ||
|
|
d12a3dc8a8 | ||
|
|
0cfab8770a | ||
|
|
3876ae8fe8 | ||
|
|
77644e4425 | ||
|
|
6592782dc0 | ||
|
|
d6dc250ed4 | ||
|
|
9579f14c34 | ||
|
|
c5acbffe3f | ||
|
|
63142042bc | ||
|
|
1ac0305ed0 | ||
|
|
7860013aa9 | ||
|
|
7a5b964611 | ||
|
|
6950c2e4d2 | ||
|
|
291223b3ce | ||
|
|
99aeb766c3 | ||
|
|
93fe31cc55 | ||
|
|
b9a03fabd9 | ||
|
|
d00b3ea8f8 | ||
|
|
c18afcddc4 | ||
|
|
57db25d08a | ||
|
|
b8f64a1c1b | ||
|
|
de35dee1c5 | ||
|
|
dd883985bb | ||
|
|
97b8911ba8 | ||
|
|
a397e7305d | ||
|
|
d0039afbb7 | ||
|
|
878cdfbc52 | ||
|
|
1165f00bd4 | ||
|
|
949ddffef2 | ||
|
|
c4725428e0 | ||
|
|
d29ad356d1 | ||
|
|
692ca5eaf0 | ||
|
|
b9787c78f3 | ||
|
|
dec7a02737 | ||
|
|
0eade717ce | ||
|
|
e6c674b3c6 | ||
|
|
4ff247e134 | ||
|
|
2f0c1f5fa2 | ||
|
|
07692653ff | ||
|
|
82dc57ad43 | ||
|
|
84e8632b98 | ||
|
|
571ce6cb0d | ||
|
|
783503aece | ||
|
|
b482a9bf0d | ||
|
|
36c6cc203e | ||
|
|
8950585141 | ||
|
|
950028abeb | ||
|
|
280fa562a6 | ||
|
|
6b1fa87ad3 | ||
|
|
cacfb2bc08 | ||
|
|
3107ae4147 | ||
|
|
c182114883 | ||
|
|
342b239ac6 |
6
.github/CODEOWNERS
vendored
@@ -1,5 +1 @@
|
|||||||
# These owners will be the default owners for everything in the repo.
|
* @filebrowser/maintainers
|
||||||
# Unless a later match takes precedence, @o1egl will be requested for
|
|
||||||
# review when someone opens a pull request.
|
|
||||||
|
|
||||||
* @o1egl @hacdias
|
|
||||||
|
|||||||
115
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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 }}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
52
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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
|
||||||
@@ -13,10 +13,10 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
main:
|
main:
|
||||||
name: Validate PR title
|
name: Validate Title
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
- uses: amannn/action-semantic-pull-request@v6
|
||||||
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
|
||||||
105
.github/workflows/main.yaml
vendored
@@ -1,105 +0,0 @@
|
|||||||
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.24'
|
|
||||||
- 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.24'
|
|
||||||
- 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'
|
|
||||||
- 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 }}
|
|
||||||
20
.github/workflows/site-pr.yml
vendored
@@ -1,20 +0,0 @@
|
|||||||
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
@@ -1,32 +0,0 @@
|
|||||||
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 }}
|
|
||||||
14
.gitignore
vendored
@@ -35,10 +35,14 @@ 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
|
||||||
|
filebrowser.log
|
||||||
|
filebrowser.pid
|
||||||
|
frontend-dev.log
|
||||||
|
frontend.pid
|
||||||
|
SWAG.mp3
|
||||||
|
test/M5.flac
|
||||||
|
test/MALIBU.flac
|
||||||
|
test/SWAG.mp3
|
||||||
|
test
|
||||||
|
|||||||
122
.golangci.yml
@@ -1,132 +1,14 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
# inverted configuration with `default: all` and `disable` is not scalable during updates of golangci-lint
|
default: standard
|
||||||
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:
|
||||||
- comments
|
|
||||||
- common-false-positives
|
|
||||||
- legacy
|
|
||||||
- std-error-handling
|
- std-error-handling
|
||||||
rules:
|
- comments
|
||||||
- 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/
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ builds:
|
|||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
- freebsd
|
- freebsd
|
||||||
|
- openbsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- "386"
|
- "386"
|
||||||
@@ -30,6 +31,12 @@ builds:
|
|||||||
ignore:
|
ignore:
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
goarch: "386"
|
goarch: "386"
|
||||||
|
# Experimental, may not work properly
|
||||||
|
- goos: openbsd
|
||||||
|
goarch: riscv64
|
||||||
|
# Broken as of Go 1.24, deprecated as of Go 1.26
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
|
||||||
|
|||||||
383
CHANGELOG.md
@@ -1,6 +1,387 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
||||||
|
|
||||||
|
## [2.57.0] (2026-01-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add "Modify metadata" action and modal for MP3/FLAC with batch edit support
|
||||||
|
* Expose audio tags in API (GET /api/resources?metadata=1) and implement metadata updates (PATCH action=metadata)
|
||||||
|
* Update i18n (EN/FR) with metadata labels and modal texts
|
||||||
|
* Include ffmpeg in s6 Docker image to enable metadata writes in containers
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Avoid 400/409 errors on metadata PATCH by skipping parent/stat checks when destination is empty
|
||||||
|
* Preserve existing tags on update using ffmpeg `-map_metadata 0`; apply only non-empty fields; no-op when no changes
|
||||||
|
* Use temp file with original extension for ffmpeg; capture stderr to improve error messages
|
||||||
|
* Exclude raw APIC frames from returned tags to prevent large payloads; keep preview-friendly picture key
|
||||||
|
* Modal polish: fix column alignment jitter and title capitalization
|
||||||
|
|
||||||
|
## [2.56.0](https://github.com/filebrowser/filebrowser/compare/v2.55.0...v2.56.0) (2026-01-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Updates for project File Browser ([#5698](https://github.com/filebrowser/filebrowser/issues/5698)) ([f0f2f1f](https://github.com/filebrowser/filebrowser/commit/f0f2f1ff069aae566d8bf25ec275da59f29a96bc))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* adjust columns of the table from the "users ls" command ([#5716](https://github.com/filebrowser/filebrowser/issues/5716)) ([3032a1f](https://github.com/filebrowser/filebrowser/commit/3032a1fade43737c51c49b5ccda34f336394c2ed))
|
||||||
|
* avoid clearing selection when clicking elements outside the empty area ([#5715](https://github.com/filebrowser/filebrowser/issues/5715)) ([004488c](https://github.com/filebrowser/filebrowser/commit/004488c15b3c30784e1ea564b3ca9feec7bcad08))
|
||||||
|
|
||||||
|
## [2.55.0](https://github.com/filebrowser/filebrowser/compare/v2.54.0...v2.55.0) (2026-01-18)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* added cut, copy, paste and show command palette functions in header ([#5648](https://github.com/filebrowser/filebrowser/issues/5648)) ([785b7ab](https://github.com/filebrowser/filebrowser/commit/785b7abb7ba7a86cc0deae1052c319ff714c222c))
|
||||||
|
* update translations ([#5677](https://github.com/filebrowser/filebrowser/issues/5677)) ([e7ea1ad](https://github.com/filebrowser/filebrowser/commit/e7ea1ad27d3d17e249489d3338be40bfea15e2a1))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* prevent context menu clicks from clearing file selection ([#5681](https://github.com/filebrowser/filebrowser/issues/5681)) ([59ca0c3](https://github.com/filebrowser/filebrowser/commit/59ca0c340afc7774747c70ede9a5a5a3c9349d6b))
|
||||||
|
* request current password when deleting users ([#5667](https://github.com/filebrowser/filebrowser/issues/5667)) ([cfa6c58](https://github.com/filebrowser/filebrowser/commit/cfa6c5864e5e7673aa9f3180e4964e0db92cc4da))
|
||||||
|
* retain file selection when closing the editor ([#5693](https://github.com/filebrowser/filebrowser/issues/5693)) ([4094fb3](https://github.com/filebrowser/filebrowser/commit/4094fb359babac70e88d0ed4bfe3bd100744aad6))
|
||||||
|
|
||||||
|
## [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)
|
### [2.42.3](https://github.com/filebrowser/filebrowser/compare/v2.42.2...v2.42.3) (2025-08-09)
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,23 @@ 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 are using [Node.js](https://nodejs.org/en/) on the frontend to manage the build process. The steps to build it are:
|
We use [Node.js](https://nodejs.org/en/) on the frontend to manage the build process. Prepare the frontend environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the root of the repo, go to frontend/
|
# From the root of the repo, go to frontend/
|
||||||
@@ -27,37 +39,62 @@ cd frontend
|
|||||||
|
|
||||||
# Install the dependencies
|
# Install the dependencies
|
||||||
pnpm install
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
# Build the frontend
|
If you just want to develop the backend, you can create a static build of the frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
pnpm run build
|
pnpm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
If you want to develop the frontend, start a development server which watches 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 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:
|
First prepare the backend environment by downloading all required dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
You can now build or run File Browser as any other Go project:
|
||||||
|
|
||||||
To build File Browser is just like any other Go program:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Build
|
||||||
go build
|
go build
|
||||||
|
|
||||||
|
# Run
|
||||||
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
## Documentation
|
||||||
|
|
||||||
|
We rely on Docker to abstract all the dependencies required for building the documentation.
|
||||||
|
|
||||||
|
To build the documentation to `www/public`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -tags dev
|
task docs
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
## Multistage build: First stage fetches dependencies
|
## Multistage build: First stage fetches dependencies
|
||||||
FROM alpine:3.22 AS fetcher
|
FROM alpine:3.23 AS fetcher
|
||||||
|
|
||||||
# install and copy ca-certificates, mailcap, and tini-static; download JSON.sh
|
# install and copy ca-certificates, mailcap, and tini-static; download JSON.sh
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
|
|||||||
86
Dockerfile.full
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Multi-stage build: compile frontend + backend, then package
|
||||||
|
# This is a full build Dockerfile for filebrowser from source
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Stage 1: Build frontend (Node.js / pnpm)
|
||||||
|
##############################################################################
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Stage 2: Build backend (Go)
|
||||||
|
##############################################################################
|
||||||
|
FROM golang:1.25-alpine AS backend-builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||||
|
|
||||||
|
RUN go mod tidy && go mod download
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 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 .
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Stage 3: Fetch runtime dependencies
|
||||||
|
##############################################################################
|
||||||
|
FROM alpine:3.23 AS fetcher
|
||||||
|
|
||||||
|
RUN apk update && \
|
||||||
|
apk --no-cache add ca-certificates mailcap tini-static && \
|
||||||
|
wget -O /JSON.sh https://raw.githubusercontent.com/dominictarr/JSON.sh/0d5e5c77365f63809bf6e77ef44a1f34b0e05840/JSON.sh
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Stage 4: Final runtime image (Alpine with ffmpeg)
|
||||||
|
##############################################################################
|
||||||
|
FROM alpine:3.23
|
||||||
|
|
||||||
|
ENV UID=1000
|
||||||
|
ENV GID=1000
|
||||||
|
|
||||||
|
RUN addgroup -g $GID user && \
|
||||||
|
adduser -D -u $UID -G user user && \
|
||||||
|
apk add --no-cache ffmpeg flac
|
||||||
|
|
||||||
|
# Copy compiled binary from backend-builder
|
||||||
|
COPY --from=backend-builder --chown=user:user /app/filebrowser /bin/filebrowser
|
||||||
|
|
||||||
|
# Copy scripts and configs from repo
|
||||||
|
COPY --chown=user:user docker/common/ /
|
||||||
|
COPY --chown=user:user docker/alpine/ /
|
||||||
|
|
||||||
|
# Copy runtime deps from fetcher
|
||||||
|
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
|
||||||
|
|
||||||
|
RUN mkdir -p /config /database /srv && \
|
||||||
|
chown -R user:user /config /database /srv && \
|
||||||
|
chmod +x /healthcheck.sh
|
||||||
|
|
||||||
|
HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
VOLUME /srv /config /database
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
ENTRYPOINT [ "tini", "--", "/init.sh" ]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM ghcr.io/linuxserver/baseimage-alpine:3.22
|
FROM ghcr.io/linuxserver/baseimage-alpine:3.23
|
||||||
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk --no-cache add ca-certificates mailcap jq
|
apk --no-cache add ca-certificates mailcap jq libcap ffmpeg
|
||||||
|
|
||||||
# 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,7 +12,8 @@ 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
|
||||||
|
|||||||
88
Makefile
@@ -1,88 +0,0 @@
|
|||||||
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)
|
|
||||||
25
README.md
@@ -1,12 +1,10 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://raw.githubusercontent.com/filebrowser/logo/master/banner.png" width="550"/>
|
<img src="https://raw.githubusercontent.com/filebrowser/filebrowser/master/branding/banner.png" width="550"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://github.com/filebrowser/filebrowser/actions/workflows/main.yaml)
|
[](https://github.com/filebrowser/filebrowser/actions/workflows/ci.yaml)
|
||||||
[](https://goreportcard.com/report/github.com/filebrowser/filebrowser)
|
[](https://goreportcard.com/report/github.com/filebrowser/filebrowser/v2)
|
||||||
[](http://godoc.org/github.com/filebrowser/filebrowser)
|
|
||||||
[](https://github.com/filebrowser/filebrowser/releases/latest)
|
[](https://github.com/filebrowser/filebrowser/releases/latest)
|
||||||
[](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.
|
||||||
|
|
||||||
@@ -16,18 +14,12 @@ Documentation on how to install, configure, and contribute to this project is ho
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
> [!WARNING]
|
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:
|
||||||
>
|
|
||||||
> 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.
|
|
||||||
|
|
||||||
[issues]: https://github.com/filebrowser/filebrowser/issues
|
- It can take a while until someone gets back to you. Please be patient.
|
||||||
[discussions]: https://github.com/filebrowser/filebrowser/discussions
|
- [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).
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -36,3 +28,4 @@ Contributions are always welcome. To start contributing to this project, read ou
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
[Apache License 2.0](LICENSE) © File Browser Contributors
|
[Apache License 2.0](LICENSE) © File Browser Contributors
|
||||||
|
# Work in progress: metadata modifier
|
||||||
|
|||||||
83
Taskfile.yml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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
|
||||||
54
auth/hook.go
@@ -8,9 +8,10 @@ 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"
|
||||||
@@ -102,7 +103,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:]...) //nolint:gosec
|
cmd := exec.Command(command[0], command[1:]...)
|
||||||
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()
|
||||||
@@ -123,7 +124,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.Split(s, "\n") {
|
for val := range strings.Lines(s) {
|
||||||
v := strings.SplitN(val, "=", 2)
|
v := strings.SplitN(val, "=", 2)
|
||||||
|
|
||||||
// skips non key and value format
|
// skips non key and value format
|
||||||
@@ -145,7 +146,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,16 +158,17 @@ 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,
|
||||||
Sorting: a.Settings.Defaults.Sorting,
|
RedirectAfterCopyMove: a.Settings.Defaults.RedirectAfterCopyMove,
|
||||||
Perm: a.Settings.Defaults.Perm,
|
Sorting: a.Settings.Defaults.Sorting,
|
||||||
Commands: a.Settings.Defaults.Commands,
|
Perm: a.Settings.Defaults.Perm,
|
||||||
HideDotfiles: a.Settings.Defaults.HideDotfiles,
|
Commands: a.Settings.Defaults.Commands,
|
||||||
|
HideDotfiles: a.Settings.Defaults.HideDotfiles,
|
||||||
}
|
}
|
||||||
u = a.GetUser(d)
|
u = a.GetUser(d)
|
||||||
|
|
||||||
@@ -218,13 +220,14 @@ 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),
|
||||||
@@ -250,6 +253,7 @@ 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",
|
||||||
@@ -266,13 +270,7 @@ 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 {
|
||||||
for _, val := range validHookFields {
|
return slices.Contains(validHookFields, field)
|
||||||
if field == val {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetString returns the string value or provided default
|
// GetString returns the string value or provided default
|
||||||
|
|||||||
18
auth/json.go
@@ -14,6 +14,10 @@ import (
|
|||||||
// MethodJSONAuth is used to identify json auth.
|
// MethodJSONAuth is used to identify json auth.
|
||||||
const MethodJSONAuth settings.AuthMethod = "json"
|
const MethodJSONAuth settings.AuthMethod = "json"
|
||||||
|
|
||||||
|
// dummyHash is used to prevent user enumeration timing attacks.
|
||||||
|
// It MUST be a valid bcrypt hash.
|
||||||
|
const dummyHash = "$2a$10$O4mEMeOL/nit6zqe.WQXauLRbRlzb3IgLHsa26Pf0N/GiU9b.wK1m"
|
||||||
|
|
||||||
type jsonCred struct {
|
type jsonCred struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -40,7 +44,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) //nolint:govet
|
ok, err := a.ReCaptcha.Ok(cred.ReCaptcha)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -52,7 +56,17 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store, _ *settings.Settings, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
u, err := usr.Get(srv.Root, cred.Username)
|
u, err := usr.Get(srv.Root, cred.Username)
|
||||||
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
|
|
||||||
|
hash := dummyHash
|
||||||
|
if err == nil {
|
||||||
|
hash = u.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
if !users.CheckPwd(cred.Password, hash) {
|
||||||
|
return nil, os.ErrPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
return nil, os.ErrPermission
|
return nil, os.ErrPermission
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
BIN
branding/banner.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
1
branding/banner.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
branding/icon.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
1
branding/icon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
BIN
branding/logo.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
1
branding/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
35
cmd/cmd_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,18 +15,18 @@ 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: python(func(_ *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
|
||||||
s, err := d.store.Settings.Get()
|
s, err := st.Settings.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 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 = d.store.Settings.Save(s)
|
err = st.Settings.Save(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printEvents(s.Commands)
|
printEvents(s.Commands)
|
||||||
return nil
|
return nil
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ 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: python(func(cmd *cobra.Command, _ []string, d *pythonData) error {
|
RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error {
|
||||||
s, err := d.store.Settings.Get()
|
s, err := st.Settings.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
evt, err := getString(cmd.Flags(), "event")
|
|
||||||
|
evt, err := cmd.Flags().GetString("event")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ var cmdsLsCmd = &cobra.Command{
|
|||||||
show["after_"+evt] = s.Commands["after_"+evt]
|
show["after_"+evt] = s.Commands["after_"+evt]
|
||||||
printEvents(show)
|
printEvents(show)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ including 'index_end'.`,
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: python(func(_ *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
|
||||||
s, err := d.store.Settings.Get()
|
s, err := st.Settings.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -55,11 +55,11 @@ including 'index_end'.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 = d.store.Settings.Save(s)
|
err = st.Settings.Save(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printEvents(s.Commands)
|
printEvents(s.Commands)
|
||||||
return nil
|
return nil
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
187
cmd/config.go
@@ -2,7 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
nerrors "errors"
|
"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"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,14 +30,22 @@ 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("create-user-dir", false, "generate user's home directory automatically")
|
flags.Bool("hideLoginButton", false, "hide login button from public pages")
|
||||||
flags.Uint("minimum-password-length", settings.DefaultMinimumPasswordLength, "minimum password length for new users")
|
flags.Bool("createUserDir", false, "generate user's home directory automatically")
|
||||||
|
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")
|
||||||
@@ -49,14 +57,13 @@ 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")
|
||||||
// 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.Uint64("tus.chunkSize", settings.DefaultTusChunkSize, "the tus chunk size")
|
||||||
flags.String("file-mode", fmt.Sprintf("%O", settings.DefaultFileMode), "Mode bits that new files are created with")
|
flags.Uint16("tus.retryCount", settings.DefaultTusRetryCount, "the tus retry count")
|
||||||
flags.String("dir-mode", fmt.Sprintf("%O", settings.DefaultDirMode), "Mode bits that new directories are created with")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAuthMethod(flags *pflag.FlagSet, defaults ...interface{}) (settings.AuthMethod, map[string]interface{}, error) {
|
func getAuthMethod(flags *pflag.FlagSet, defaults ...interface{}) (settings.AuthMethod, map[string]interface{}, error) {
|
||||||
methodStr, err := getString(flags, "auth.method")
|
methodStr, err := flags.GetString("auth.method")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
@@ -87,17 +94,17 @@ func getAuthMethod(flags *pflag.FlagSet, defaults ...interface{}) (settings.Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getProxyAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
|
func getProxyAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
|
||||||
header, err := getString(flags, "auth.header")
|
header, err := flags.GetString("auth.header")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if header == "" {
|
if header == "" && defaultAuther != nil {
|
||||||
header = defaultAuther["header"].(string)
|
header = defaultAuther["header"].(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
if header == "" {
|
if header == "" {
|
||||||
return nil, nerrors.New("you must set the flag 'auth.header' for method 'proxy'")
|
return nil, errors.New("you must set the flag 'auth.header' for method 'proxy'")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &auth.ProxyAuth{Header: header}, nil
|
return &auth.ProxyAuth{Header: header}, nil
|
||||||
@@ -109,15 +116,17 @@ func getNoAuth() auth.Auther {
|
|||||||
|
|
||||||
func getJSONAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
|
func getJSONAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
|
||||||
jsonAuth := &auth.JSONAuth{}
|
jsonAuth := &auth.JSONAuth{}
|
||||||
host, err := getString(flags, "recaptcha.host")
|
host, err := flags.GetString("recaptcha.host")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
key, err := getString(flags, "recaptcha.key")
|
|
||||||
|
key, err := flags.GetString("recaptcha.key")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
secret, err := getString(flags, "recaptcha.secret")
|
|
||||||
|
secret, err := flags.GetString("recaptcha.secret")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -145,17 +154,16 @@ func getJSONAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (au
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getHookAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
|
func getHookAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
|
||||||
command, err := getString(flags, "auth.command")
|
command, err := flags.GetString("auth.command")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if command == "" {
|
if command == "" {
|
||||||
command = defaultAuther["command"].(string)
|
command = defaultAuther["command"].(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
if command == "" {
|
if command == "" {
|
||||||
return nil, nerrors.New("you must set the flag 'auth.command' for method 'hook'")
|
return nil, errors.New("you must set the flag 'auth.command' for method 'hook'")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &auth.HookAuth{Command: command}, nil
|
return &auth.HookAuth{Command: command}, nil
|
||||||
@@ -178,7 +186,7 @@ func getAuthentication(flags *pflag.FlagSet, defaults ...interface{}) (settings.
|
|||||||
case auth.MethodHookAuth:
|
case auth.MethodHookAuth:
|
||||||
auther, err = getHookAuth(flags, defaultAuther)
|
auther, err = getHookAuth(flags, defaultAuther)
|
||||||
default:
|
default:
|
||||||
return "", nil, errors.ErrInvalidAuthMethod
|
return "", nil, fberrors.ErrInvalidAuthMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -192,10 +200,13 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
|
|||||||
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)
|
||||||
@@ -203,6 +214,7 @@ 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)
|
||||||
@@ -212,18 +224,32 @@ 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, "\tFile Creation Mode:\t%O\n", set.FileMode)
|
||||||
fmt.Fprintf(w, "\tDirectory Creation Mode:\t%O\n", set.DirMode)
|
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)
|
||||||
@@ -233,6 +259,7 @@ 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, "", " ")
|
||||||
@@ -242,3 +269,123 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
|
|||||||
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ 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: python(func(_ *cobra.Command, _ []string, d *pythonData) error {
|
RunE: withStore(func(_ *cobra.Command, _ []string, st *store) error {
|
||||||
set, err := d.store.Settings.Get()
|
set, err := st.Settings.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ser, err := d.store.Settings.GetServer()
|
ser, err := st.Settings.GetServer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
auther, err := d.store.Auth.Get(set.AuthMethod)
|
auther, err := st.Auth.Get(set.AuthMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return printSettings(ser, set, auther)
|
return printSettings(ser, set, auther)
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ 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: python(func(_ *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
|
||||||
settings, err := d.store.Settings.Get()
|
settings, err := st.Settings.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := d.store.Settings.GetServer()
|
server, err := st.Settings.GetServer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
auther, err := d.store.Auth.Get(settings.AuthMethod)
|
auther, err := st.Auth.Get(settings.AuthMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -42,5 +42,5 @@ and imported again with 'config import' command.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ 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: python(func(_ *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
|
||||||
var key []byte
|
var key []byte
|
||||||
var err error
|
var err error
|
||||||
if d.hadDB {
|
if st.databaseExisted {
|
||||||
settings, settingErr := d.store.Settings.Get()
|
settings, settingErr := st.Settings.Get()
|
||||||
if settingErr != nil {
|
if settingErr != nil {
|
||||||
return settingErr
|
return settingErr
|
||||||
}
|
}
|
||||||
@@ -53,13 +53,17 @@ The path must be for a json or yaml file.`,
|
|||||||
return 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 = d.store.Settings.Save(file.Settings)
|
err = st.Settings.Save(file.Settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = d.store.Settings.SaveServer(file.Server)
|
err = st.Settings.SaveServer(file.Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -98,13 +102,13 @@ The path must be for a json or yaml file.`,
|
|||||||
return autherErr
|
return autherErr
|
||||||
}
|
}
|
||||||
|
|
||||||
err = d.store.Auth.Save(auther)
|
err = st.Auth.Save(auther)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return printSettings(file.Server, file.Settings, auther)
|
return printSettings(file.Server, file.Settings, auther)
|
||||||
}, pythonConfig{allowNoDB: true}),
|
}, storeOptions{allowsNoDatabase: true}),
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAuther(sample auth.Auther, data interface{}) (interface{}, error) {
|
func getAuther(sample auth.Auther, data interface{}) (interface{}, error) {
|
||||||
|
|||||||
@@ -22,152 +22,31 @@ 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: python(func(cmd *cobra.Command, _ []string, d *pythonData) error {
|
RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error {
|
||||||
defaults := settings.UserDefaults{}
|
|
||||||
flags := cmd.Flags()
|
flags := cmd.Flags()
|
||||||
err := getUserDefaults(flags, &defaults, true)
|
|
||||||
if err != nil {
|
// Initialize config
|
||||||
return err
|
s := &settings.Settings{Key: generateKey()}
|
||||||
}
|
ser := &settings.Server{}
|
||||||
authMethod, auther, err := getAuthentication(flags)
|
|
||||||
|
// Fill config with options
|
||||||
|
auther, err := getSettings(flags, s, ser, nil, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
key := generateKey()
|
// Save updated config
|
||||||
|
err = st.Settings.Save(s)
|
||||||
signup, err := getBool(flags, "signup")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
createUserDir, err := getBool(flags, "create-user-dir")
|
err = st.Settings.SaveServer(ser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
minLength, err := getUint(flags, "minimum-password-length")
|
err = st.Auth.Save(auther)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
shell, err := getString(flags, "shell")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
brandingName, err := getString(flags, "branding.name")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
brandingDisableExternal, err := getBool(flags, "branding.disableExternal")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
brandingDisableUsedPercentage, err := getBool(flags, "branding.disableUsedPercentage")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
brandingTheme, err := getString(flags, "branding.theme")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
brandingFiles, err := getString(flags, "branding.files")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &settings.Settings{
|
|
||||||
Key: key,
|
|
||||||
Signup: signup,
|
|
||||||
CreateUserDir: createUserDir,
|
|
||||||
MinimumPasswordLength: minLength,
|
|
||||||
Shell: convertCmdStrToCmdArray(shell),
|
|
||||||
AuthMethod: authMethod,
|
|
||||||
Defaults: defaults,
|
|
||||||
Branding: settings.Branding{
|
|
||||||
Name: brandingName,
|
|
||||||
DisableExternal: brandingDisableExternal,
|
|
||||||
DisableUsedPercentage: brandingDisableUsedPercentage,
|
|
||||||
Theme: brandingTheme,
|
|
||||||
Files: brandingFiles,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
s.FileMode, err = getMode(flags, "file-mode")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.DirMode, err = getMode(flags, "dir-mode")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
address, err := getString(flags, "address")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
socket, err := getString(flags, "socket")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
root, err := getString(flags, "root")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
baseURL, err := getString(flags, "baseurl")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
tlsKey, err := getString(flags, "key")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cert, err := getString(flags, "cert")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
port, err := getString(flags, "port")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log, err := getString(flags, "log")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ser := &settings.Server{
|
|
||||||
Address: address,
|
|
||||||
Socket: socket,
|
|
||||||
Root: root,
|
|
||||||
BaseURL: baseURL,
|
|
||||||
TLSKey: tlsKey,
|
|
||||||
TLSCert: cert,
|
|
||||||
Port: port,
|
|
||||||
Log: log,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = d.store.Settings.Save(s)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = d.store.Settings.SaveServer(ser)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = d.store.Auth.Save(auther)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -178,5 +57,5 @@ 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)
|
return printSettings(ser, s, auther)
|
||||||
}, pythonConfig{noDB: true}),
|
}, storeOptions{expectsNoDatabase: true}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/pflag"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -16,105 +15,47 @@ 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: python(func(cmd *cobra.Command, _ []string, d *pythonData) error {
|
RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error {
|
||||||
flags := cmd.Flags()
|
flags := cmd.Flags()
|
||||||
set, err := d.store.Settings.Get()
|
|
||||||
|
// Read existing config
|
||||||
|
set, err := st.Settings.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ser, err := d.store.Settings.GetServer()
|
ser, err := st.Settings.GetServer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAuth := false
|
auther, err := st.Auth.Get(set.AuthMethod)
|
||||||
flags.Visit(func(flag *pflag.Flag) {
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch flag.Name {
|
|
||||||
case "baseurl":
|
|
||||||
ser.BaseURL, err = getString(flags, flag.Name)
|
|
||||||
case "root":
|
|
||||||
ser.Root, err = getString(flags, flag.Name)
|
|
||||||
case "socket":
|
|
||||||
ser.Socket, err = getString(flags, flag.Name)
|
|
||||||
case "cert":
|
|
||||||
ser.TLSCert, err = getString(flags, flag.Name)
|
|
||||||
case "key":
|
|
||||||
ser.TLSKey, err = getString(flags, flag.Name)
|
|
||||||
case "address":
|
|
||||||
ser.Address, err = getString(flags, flag.Name)
|
|
||||||
case "port":
|
|
||||||
ser.Port, err = getString(flags, flag.Name)
|
|
||||||
case "log":
|
|
||||||
ser.Log, err = getString(flags, flag.Name)
|
|
||||||
case "signup":
|
|
||||||
set.Signup, err = getBool(flags, flag.Name)
|
|
||||||
case "auth.method":
|
|
||||||
hasAuth = true
|
|
||||||
case "shell":
|
|
||||||
var shell string
|
|
||||||
shell, err = getString(flags, flag.Name)
|
|
||||||
set.Shell = convertCmdStrToCmdArray(shell)
|
|
||||||
case "create-user-dir":
|
|
||||||
set.CreateUserDir, err = getBool(flags, flag.Name)
|
|
||||||
case "minimum-password-length":
|
|
||||||
set.MinimumPasswordLength, err = getUint(flags, flag.Name)
|
|
||||||
case "branding.name":
|
|
||||||
set.Branding.Name, err = getString(flags, flag.Name)
|
|
||||||
case "branding.color":
|
|
||||||
set.Branding.Color, err = getString(flags, flag.Name)
|
|
||||||
case "branding.theme":
|
|
||||||
set.Branding.Theme, err = getString(flags, flag.Name)
|
|
||||||
case "branding.disableExternal":
|
|
||||||
set.Branding.DisableExternal, err = getBool(flags, flag.Name)
|
|
||||||
case "branding.disableUsedPercentage":
|
|
||||||
set.Branding.DisableUsedPercentage, err = getBool(flags, flag.Name)
|
|
||||||
case "branding.files":
|
|
||||||
set.Branding.Files, err = getString(flags, flag.Name)
|
|
||||||
case "file-mode":
|
|
||||||
set.FileMode, err = getMode(flags, flag.Name)
|
|
||||||
case "dir-mode":
|
|
||||||
set.DirMode, err = getMode(flags, flag.Name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = getUserDefaults(flags, &set.Defaults, false)
|
// Get updated config
|
||||||
|
auther, err = getSettings(flags, set, ser, auther, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// read the defaults
|
// Save updated config
|
||||||
auther, err := d.store.Auth.Get(set.AuthMethod)
|
err = st.Auth.Save(auther)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if there are new flags for existing auth method
|
err = st.Settings.Save(set)
|
||||||
set.AuthMethod, auther, err = getAuthentication(flags, hasAuth, set, auther)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = d.store.Auth.Save(auther)
|
err = st.Settings.SaveServer(ser)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = d.store.Settings.Save(set)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = d.store.Settings.SaveServer(ser)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return printSettings(ser, set, auther)
|
return printSettings(ser, set, auther)
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
158
cmd/docs.go
@@ -3,36 +3,18 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path"
|
||||||
"sort"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/cobra/doc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(docsCmd)
|
rootCmd.AddCommand(docsCmd)
|
||||||
docsCmd.Flags().StringP("path", "p", "./docs", "path to save the docs")
|
docsCmd.Flags().String("out", "www/docs/cli", "directory to write the docs to")
|
||||||
}
|
|
||||||
|
|
||||||
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{
|
||||||
@@ -40,115 +22,61 @@ var docsCmd = &cobra.Command{
|
|||||||
Hidden: true,
|
Hidden: true,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
dir, err := getString(cmd.Flags(), "path")
|
outputDir, err := cmd.Flags().GetString("out")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = generateDocs(rootCmd, dir)
|
tempDir, err := os.MkdirTemp(os.TempDir(), "filebrowser-docs-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
names := []string{}
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
err = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
rootCmd.Root().DisableAutoGenTag = true
|
||||||
if err != nil || info.IsDir() {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(info.Name(), "filebrowser") {
|
err = doc.GenMarkdownTreeCustom(cmd.Root(), tempDir, func(_ string) string {
|
||||||
return nil
|
return ""
|
||||||
}
|
}, func(s string) string {
|
||||||
|
return s
|
||||||
names = append(names, info.Name())
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
printToc(names)
|
entries, err := os.ReadDir(tempDir)
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateDocs(cmd *cobra.Command, dir string) error {
|
|
||||||
for _, c := range cmd.Commands() {
|
|
||||||
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
err := generateDocs(c, dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
basename := strings.Replace(cmd.CommandPath(), " ", "-", -1) + ".md"
|
headerRegex := regexp.MustCompile(`(?m)^(##)(.*)$`)
|
||||||
filename := filepath.Join(dir, basename)
|
linkRegex := regexp.MustCompile(`\(filebrowser(.*)\.md\)`)
|
||||||
f, err := os.Create(filename)
|
|
||||||
if err != nil {
|
fmt.Println("Generated Documents:")
|
||||||
return err
|
|
||||||
}
|
for _, entry := range entries {
|
||||||
defer f.Close()
|
srcPath := path.Join(tempDir, entry.Name())
|
||||||
return generateMarkdown(cmd, f)
|
dstPath := path.Join(outputDir, strings.ReplaceAll(entry.Name(), "_", "-"))
|
||||||
}
|
|
||||||
|
data, err := os.ReadFile(srcPath)
|
||||||
func generateMarkdown(cmd *cobra.Command, w io.Writer) error {
|
if err != nil {
|
||||||
cmd.InitDefaultHelpCmd()
|
return err
|
||||||
cmd.InitDefaultHelpFlag()
|
}
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
data = headerRegex.ReplaceAll(data, []byte("#$2"))
|
||||||
name := cmd.CommandPath()
|
data = linkRegex.ReplaceAllFunc(data, func(b []byte) []byte {
|
||||||
|
return bytes.ReplaceAll(b, []byte("_"), []byte("-"))
|
||||||
short := cmd.Short
|
})
|
||||||
long := cmd.Long
|
data = bytes.ReplaceAll(data, []byte("## SEE ALSO"), []byte("## See Also"))
|
||||||
if long == "" {
|
|
||||||
long = short
|
err = os.WriteFile(dstPath, data, 0666)
|
||||||
}
|
if err != nil {
|
||||||
|
return err
|
||||||
buf.WriteString("---\ndescription: " + short + "\n---\n\n")
|
}
|
||||||
buf.WriteString("# " + name + "\n\n")
|
|
||||||
buf.WriteString("## Synopsis\n\n")
|
fmt.Println("- " + dstPath)
|
||||||
buf.WriteString(long + "\n\n")
|
}
|
||||||
|
|
||||||
if cmd.Runnable() {
|
return nil
|
||||||
_, _ = 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)
|
|
||||||
return 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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
395
cmd/root.go
@@ -13,20 +13,17 @@ 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"
|
||||||
v "github.com/spf13/viper"
|
"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"
|
||||||
"github.com/filebrowser/filebrowser/v2/diskcache"
|
"github.com/filebrowser/filebrowser/v2/diskcache"
|
||||||
fbErrors "github.com/filebrowser/filebrowser/v2/errors"
|
|
||||||
"github.com/filebrowser/filebrowser/v2/frontend"
|
"github.com/filebrowser/filebrowser/v2/frontend"
|
||||||
fbhttp "github.com/filebrowser/filebrowser/v2/http"
|
fbhttp "github.com/filebrowser/filebrowser/v2/http"
|
||||||
"github.com/filebrowser/filebrowser/v2/img"
|
"github.com/filebrowser/filebrowser/v2/img"
|
||||||
@@ -36,28 +33,67 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfgFile string
|
flagNamesMigrations = map[string]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() {
|
||||||
cobra.OnInitialize(initConfig)
|
|
||||||
rootCmd.SilenceUsage = true
|
rootCmd.SilenceUsage = true
|
||||||
|
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 := rootCmd.Flags()
|
// Flags available across the whole program
|
||||||
persistent := rootCmd.PersistentFlags()
|
persistent := rootCmd.PersistentFlags()
|
||||||
|
persistent.StringP("config", "c", "", "config file path")
|
||||||
persistent.StringVarP(&cfgFile, "config", "c", "", "config file path")
|
|
||||||
persistent.StringP("database", "d", "./filebrowser.db", "database path")
|
persistent.StringP("database", "d", "./filebrowser.db", "database path")
|
||||||
flags.Bool("noauth", false, "use the noauth auther 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 config")
|
|
||||||
|
|
||||||
|
// Runtime flags for the root command
|
||||||
|
flags := rootCmd.Flags()
|
||||||
|
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("password", "", "hashed password for the first user when using quick setup")
|
||||||
|
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")
|
||||||
@@ -66,15 +102,13 @@ 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.Uint32("socket-perm", 0666, "unix socket file permissions")
|
flags.StringP("baseURL", "b", "", "base url")
|
||||||
flags.StringP("baseurl", "b", "", "base url")
|
flags.String("tokenExpirationTime", "2h", "user session timeout")
|
||||||
flags.String("cache-dir", "", "file cache directory (disabled if empty)")
|
flags.Bool("disableThumbnails", false, "disable image thumbnails")
|
||||||
flags.String("token-expiration-time", "2h", "user session timeout")
|
flags.Bool("disablePreviewResize", false, "disable resize of image previews")
|
||||||
flags.Int("img-processors", 4, "image processors count") //nolint:mnd
|
flags.Bool("disableExec", true, "disables Command Runner feature")
|
||||||
flags.Bool("disable-thumbnails", false, "disable image thumbnails")
|
flags.Bool("disableTypeDetectionByHeader", false, "disables type detection by reading file headers")
|
||||||
flags.Bool("disable-preview-resize", false, "disable resize of image previews")
|
flags.Bool("disableImageResolutionCalc", false, "disables image resolution calculation by reading image files")
|
||||||
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{
|
||||||
@@ -89,65 +123,60 @@ 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 specific command, all the flags you have available (except
|
For this command, all flags are available as environmental variables,
|
||||||
"config" for the configuration file), can be given either through
|
except for "--config", which specifies the configuration file to use.
|
||||||
environment variables or configuration files.
|
The environment variables are prefixed by "FB_" followed by the flag name in
|
||||||
|
UPPER_SNAKE_CASE. For example, the flag "--disablePreviewResize" is available
|
||||||
|
as FB_DISABLE_PREVIEW_RESIZE.
|
||||||
|
|
||||||
If you don't set "config", it will look for a configuration file called
|
If "--config" is not specified, File Browser will look for a configuration
|
||||||
.filebrowser.{json, toml, yaml, yml} in the following directories:
|
file named .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: python(func(cmd *cobra.Command, _ []string, d *pythonData) error {
|
RunE: withViperAndStore(func(_ *cobra.Command, _ []string, v *viper.Viper, st *store) error {
|
||||||
log.Println(cfgFile)
|
if !st.databaseExisted {
|
||||||
|
err := quickSetup(v, st.Storage)
|
||||||
if !d.hadDB {
|
|
||||||
err := quickSetup(cmd.Flags(), *d)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// build img service
|
// build img service
|
||||||
workersCount, err := cmd.Flags().GetInt("img-processors")
|
imgWorkersCount := v.GetInt("imageProcessors")
|
||||||
if err != nil {
|
if imgWorkersCount < 1 {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if workersCount < 1 {
|
|
||||||
return errors.New("image resize workers count could not be < 1")
|
return errors.New("image resize workers count could not be < 1")
|
||||||
}
|
}
|
||||||
imgSvc := img.New(workersCount)
|
imageService := img.New(imgWorkersCount)
|
||||||
|
|
||||||
var fileCache diskcache.Interface = diskcache.NewNoOp()
|
var fileCache diskcache.Interface = diskcache.NewNoOp()
|
||||||
cacheDir, err := cmd.Flags().GetString("cache-dir")
|
cacheDir := v.GetString("cacheDir")
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if cacheDir != "" {
|
if cacheDir != "" {
|
||||||
if err := os.MkdirAll(cacheDir, 0700); err != nil { //nolint:govet
|
if err := os.MkdirAll(cacheDir, 0700); err != nil {
|
||||||
return fmt.Errorf("can't make directory %s: %w", cacheDir, err)
|
return fmt.Errorf("can't make directory %s: %w", cacheDir, err)
|
||||||
}
|
}
|
||||||
fileCache = diskcache.New(afero.NewOsFs(), cacheDir)
|
fileCache = diskcache.New(afero.NewOsFs(), cacheDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := getRunParams(cmd.Flags(), d.store)
|
server, err := getServerSettings(v, st.Storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -169,16 +198,13 @@ user created with the credentials from options "username" and "password".`,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
socketPerm, err := cmd.Flags().GetUint32("socket-perm") //nolint:govet
|
socketPerm := v.GetUint32("socketPerm")
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = os.Chmod(server.Socket, os.FileMode(socketPerm))
|
err = os.Chmod(server.Socket, os.FileMode(socketPerm))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case server.TLSKey != "" && server.TLSCert != "":
|
case server.TLSKey != "" && server.TLSCert != "":
|
||||||
cer, err := tls.LoadX509KeyPair(server.TLSCert, server.TLSKey) //nolint:govet
|
cer, err := tls.LoadX509KeyPair(server.TLSCert, server.TLSKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -201,7 +227,7 @@ user created with the credentials from options "username" and "password".`,
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler, err := fbhttp.NewHandler(imgSvc, fileCache, d.store, server, assetsFs)
|
handler, err := fbhttp.NewHandler(imageService, fileCache, st.Storage, server, assetsFs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -233,7 +259,7 @@ user created with the credentials from options "username" and "password".`,
|
|||||||
sig := <-sigc
|
sig := <-sigc
|
||||||
log.Println("Got signal:", sig)
|
log.Println("Got signal:", sig)
|
||||||
|
|
||||||
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) //nolint:mnd
|
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer shutdownRelease()
|
defer shutdownRelease()
|
||||||
|
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
@@ -241,66 +267,82 @@ user created with the credentials from options "username" and "password".`,
|
|||||||
}
|
}
|
||||||
log.Println("Graceful shutdown complete.")
|
log.Println("Graceful shutdown complete.")
|
||||||
|
|
||||||
switch sig {
|
return nil
|
||||||
case syscall.SIGHUP:
|
}, storeOptions{allowsNoDatabase: true}),
|
||||||
d.err = fbErrors.ErrSighup
|
|
||||||
case syscall.SIGINT:
|
|
||||||
d.err = fbErrors.ErrSigint
|
|
||||||
case syscall.SIGQUIT:
|
|
||||||
d.err = fbErrors.ErrSigquit
|
|
||||||
case syscall.SIGTERM:
|
|
||||||
d.err = fbErrors.ErrSigTerm
|
|
||||||
}
|
|
||||||
|
|
||||||
return d.err
|
|
||||||
}, pythonConfig{allowNoDB: true}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:gocyclo
|
func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, error) {
|
||||||
func getRunParams(flags *pflag.FlagSet, st *storage.Storage) (*settings.Server, error) {
|
|
||||||
server, err := st.Settings.GetServer()
|
server, err := st.Settings.GetServer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 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 val, set := getStringParamB(flags, "address"); set {
|
if v.IsSet("address") {
|
||||||
server.Address = val
|
server.Address = v.GetString("address")
|
||||||
isAddrSet = isAddrSet || set
|
isAddrSet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if val, set := getStringParamB(flags, "port"); set {
|
if v.IsSet("log") {
|
||||||
server.Port = val
|
server.Log = v.GetString("log")
|
||||||
isAddrSet = isAddrSet || set
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if val, set := getStringParamB(flags, "key"); set {
|
if v.IsSet("port") {
|
||||||
server.TLSKey = val
|
server.Port = v.GetString("port")
|
||||||
isAddrSet = isAddrSet || set
|
isAddrSet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if val, set := getStringParamB(flags, "cert"); set {
|
if v.IsSet("cert") {
|
||||||
server.TLSCert = val
|
server.TLSCert = v.GetString("cert")
|
||||||
isAddrSet = isAddrSet || set
|
isAddrSet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if val, set := getStringParamB(flags, "socket"); set {
|
if v.IsSet("key") {
|
||||||
server.Socket = val
|
server.TLSKey = v.GetString("key")
|
||||||
isSocketSet = isSocketSet || set
|
isAddrSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -312,18 +354,6 @@ func getRunParams(flags *pflag.FlagSet, st *storage.Storage) (*settings.Server,
|
|||||||
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")
|
||||||
@@ -331,71 +361,9 @@ func getRunParams(flags *pflag.FlagSet, st *storage.Storage) (*settings.Server,
|
|||||||
log.Println("WARNING: read https://github.com/filebrowser/filebrowser/issues/5199")
|
log.Println("WARNING: read https://github.com/filebrowser/filebrowser/issues/5199")
|
||||||
}
|
}
|
||||||
|
|
||||||
if val, set := getStringParamB(flags, "token-expiration-time"); set {
|
|
||||||
server.TokenExpirationTime = val
|
|
||||||
}
|
|
||||||
|
|
||||||
return server, nil
|
return server, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
switch logMethod {
|
switch logMethod {
|
||||||
case "stdout":
|
case "stdout":
|
||||||
@@ -414,19 +382,22 @@ func setupLog(logMethod string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func quickSetup(flags *pflag.FlagSet, d pythonData) error {
|
func quickSetup(v *viper.Viper, s *storage.Storage) error {
|
||||||
log.Println("Performing quick setup")
|
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,
|
||||||
@@ -450,39 +421,45 @@ func quickSetup(flags *pflag.FlagSet, d pythonData) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if _, noauth := getStringParamB(flags, "noauth"); noauth {
|
if v.GetBool("noauth") {
|
||||||
set.AuthMethod = auth.MethodNoAuth
|
set.AuthMethod = auth.MethodNoAuth
|
||||||
err = d.store.Auth.Save(&auth.NoAuth{})
|
err = s.Auth.Save(&auth.NoAuth{})
|
||||||
} else {
|
} else {
|
||||||
set.AuthMethod = auth.MethodJSONAuth
|
set.AuthMethod = auth.MethodJSONAuth
|
||||||
err = d.store.Auth.Save(&auth.JSONAuth{})
|
err = s.Auth.Save(&auth.JSONAuth{})
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = d.store.Settings.Save(set)
|
err = s.Settings.Save(set)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ser := &settings.Server{
|
ser := &settings.Server{
|
||||||
BaseURL: getStringParam(flags, "baseurl"),
|
BaseURL: v.GetString("baseURL"),
|
||||||
Port: getStringParam(flags, "port"),
|
Port: v.GetString("port"),
|
||||||
Log: getStringParam(flags, "log"),
|
Log: v.GetString("log"),
|
||||||
TLSKey: getStringParam(flags, "key"),
|
TLSKey: v.GetString("key"),
|
||||||
TLSCert: getStringParam(flags, "cert"),
|
TLSCert: v.GetString("cert"),
|
||||||
Address: getStringParam(flags, "address"),
|
Address: v.GetString("address"),
|
||||||
Root: getStringParam(flags, "root"),
|
Root: v.GetString("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 = d.store.Settings.SaveServer(ser)
|
err = s.Settings.SaveServer(ser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
username := getStringParam(flags, "username")
|
username := v.GetString("username")
|
||||||
password := getStringParam(flags, "password")
|
password := v.GetString("password")
|
||||||
|
|
||||||
if password == "" {
|
if password == "" {
|
||||||
var pwd string
|
var pwd string
|
||||||
@@ -513,35 +490,5 @@ func quickSetup(flags *pflag.FlagSet, d pythonData) error {
|
|||||||
set.Defaults.Apply(user)
|
set.Defaults.Apply(user)
|
||||||
user.Perm.Admin = true
|
user.Perm.Admin = true
|
||||||
|
|
||||||
return d.store.Users.Save(user)
|
return s.Users.Save(user)
|
||||||
}
|
|
||||||
|
|
||||||
func initConfig() {
|
|
||||||
if cfgFile == "" {
|
|
||||||
home, err := homedir.Dir()
|
|
||||||
if err != nil {
|
|
||||||
panic(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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ including 'index_end'.`,
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: python(func(cmd *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error {
|
||||||
i, err := strconv.Atoi(args[0])
|
i, err := strconv.Atoi(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -55,14 +55,14 @@ including 'index_end'.`,
|
|||||||
|
|
||||||
user := func(u *users.User) error {
|
user := func(u *users.User) error {
|
||||||
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
|
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
|
||||||
return d.store.Users.Save(u)
|
return st.Users.Save(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
global := func(s *settings.Settings) error {
|
global := func(s *settings.Settings) error {
|
||||||
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
|
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
|
||||||
return d.store.Settings.Save(s)
|
return st.Settings.Save(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return runRules(d.store, cmd, user, global)
|
return runRules(st.Storage, cmd, user, global)
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,11 +69,12 @@ func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getUserIdentifier(flags *pflag.FlagSet) (interface{}, error) {
|
func getUserIdentifier(flags *pflag.FlagSet) (interface{}, error) {
|
||||||
id, err := getUint(flags, "id")
|
id, err := flags.GetUint("id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
username, err := getString(flags, "username")
|
|
||||||
|
username, err := flags.GetString("username")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,19 @@ 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: python(func(cmd *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error {
|
||||||
allow, err := getBool(cmd.Flags(), "allow")
|
flags := cmd.Flags()
|
||||||
|
|
||||||
|
allow, err := flags.GetBool("allow")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
regex, err := getBool(cmd.Flags(), "regex")
|
|
||||||
|
regex, err := flags.GetBool("regex")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
exp := args[0]
|
exp := args[0]
|
||||||
|
|
||||||
if regex {
|
if regex {
|
||||||
@@ -49,14 +53,14 @@ var rulesAddCmd = &cobra.Command{
|
|||||||
|
|
||||||
user := func(u *users.User) error {
|
user := func(u *users.User) error {
|
||||||
u.Rules = append(u.Rules, rule)
|
u.Rules = append(u.Rules, rule)
|
||||||
return d.store.Users.Save(u)
|
return st.Users.Save(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
global := func(s *settings.Settings) error {
|
global := func(s *settings.Settings) error {
|
||||||
s.Rules = append(s.Rules, rule)
|
s.Rules = append(s.Rules, rule)
|
||||||
return d.store.Settings.Save(s)
|
return st.Settings.Save(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return runRules(d.store, cmd, user, global)
|
return runRules(st.Storage, cmd, user, global)
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: python(func(cmd *cobra.Command, _ []string, d *pythonData) error {
|
RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error {
|
||||||
return runRules(d.store, cmd, nil, nil)
|
return runRules(st.Storage, cmd, nil, nil)
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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,
|
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
||||||
flags := cmd.Flags()
|
|
||||||
oldDB, err := getString(flags, "old.database")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
oldConf, err := getString(flags, "old.config")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
db, err := getString(flags, "database")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return importer.Import(oldDB, oldConf, db)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
60
cmd/users.go
@@ -27,16 +27,17 @@ var usersCmd = &cobra.Command{
|
|||||||
|
|
||||||
func printUsers(usrs []*users.User) {
|
func printUsers(usrs []*users.User) {
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
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\tRed. After C/M\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\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%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,
|
||||||
@@ -77,64 +78,72 @@ 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("dateFormat", false, "use date format (true for absolute time, false for relative)")
|
||||||
flags.Bool("hideDotfiles", false, "hide dotfiles")
|
flags.Bool("hideDotfiles", false, "hide dotfiles")
|
||||||
|
flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users")
|
||||||
}
|
}
|
||||||
|
|
||||||
func getViewMode(flags *pflag.FlagSet) (users.ViewMode, error) {
|
func getAndParseViewMode(flags *pflag.FlagSet) (users.ViewMode, error) {
|
||||||
viewModeStr, err := getString(flags, "viewMode")
|
viewModeStr, err := flags.GetString("viewMode")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
viewMode := users.ViewMode(viewModeStr)
|
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) + "\"")
|
return "", errors.New("view mode must be \"" + string(users.ListViewMode) + "\" or \"" + string(users.MosaicViewMode) + "\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
return viewMode, nil
|
return viewMode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:gocyclo
|
|
||||||
func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all bool) error {
|
func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all bool) error {
|
||||||
var visitErr error
|
errs := []error{}
|
||||||
|
|
||||||
visit := func(flag *pflag.Flag) {
|
visit := func(flag *pflag.Flag) {
|
||||||
if visitErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var err error
|
var err error
|
||||||
switch flag.Name {
|
switch flag.Name {
|
||||||
case "scope":
|
case "scope":
|
||||||
defaults.Scope, err = getString(flags, flag.Name)
|
defaults.Scope, err = flags.GetString(flag.Name)
|
||||||
case "locale":
|
case "locale":
|
||||||
defaults.Locale, err = getString(flags, flag.Name)
|
defaults.Locale, err = flags.GetString(flag.Name)
|
||||||
case "viewMode":
|
case "viewMode":
|
||||||
defaults.ViewMode, err = getViewMode(flags)
|
defaults.ViewMode, err = getAndParseViewMode(flags)
|
||||||
case "singleClick":
|
case "singleClick":
|
||||||
defaults.SingleClick, err = getBool(flags, flag.Name)
|
defaults.SingleClick, err = flags.GetBool(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 = getBool(flags, flag.Name)
|
defaults.Perm.Admin, err = flags.GetBool(flag.Name)
|
||||||
case "perm.execute":
|
case "perm.execute":
|
||||||
defaults.Perm.Execute, err = getBool(flags, flag.Name)
|
defaults.Perm.Execute, err = flags.GetBool(flag.Name)
|
||||||
case "perm.create":
|
case "perm.create":
|
||||||
defaults.Perm.Create, err = getBool(flags, flag.Name)
|
defaults.Perm.Create, err = flags.GetBool(flag.Name)
|
||||||
case "perm.rename":
|
case "perm.rename":
|
||||||
defaults.Perm.Rename, err = getBool(flags, flag.Name)
|
defaults.Perm.Rename, err = flags.GetBool(flag.Name)
|
||||||
case "perm.modify":
|
case "perm.modify":
|
||||||
defaults.Perm.Modify, err = getBool(flags, flag.Name)
|
defaults.Perm.Modify, err = flags.GetBool(flag.Name)
|
||||||
case "perm.delete":
|
case "perm.delete":
|
||||||
defaults.Perm.Delete, err = getBool(flags, flag.Name)
|
defaults.Perm.Delete, err = flags.GetBool(flag.Name)
|
||||||
case "perm.share":
|
case "perm.share":
|
||||||
defaults.Perm.Share, err = getBool(flags, flag.Name)
|
defaults.Perm.Share, err = flags.GetBool(flag.Name)
|
||||||
case "perm.download":
|
case "perm.download":
|
||||||
defaults.Perm.Download, err = getBool(flags, flag.Name)
|
defaults.Perm.Download, err = flags.GetBool(flag.Name)
|
||||||
case "commands":
|
case "commands":
|
||||||
defaults.Commands, err = flags.GetStringSlice(flag.Name)
|
defaults.Commands, err = flags.GetStringSlice(flag.Name)
|
||||||
case "sorting.by":
|
case "sorting.by":
|
||||||
defaults.Sorting.By, err = getString(flags, flag.Name)
|
defaults.Sorting.By, err = flags.GetString(flag.Name)
|
||||||
case "sorting.asc":
|
case "sorting.asc":
|
||||||
defaults.Sorting.Asc, err = getBool(flags, flag.Name)
|
defaults.Sorting.Asc, err = flags.GetBool(flag.Name)
|
||||||
|
case "hideDotfiles":
|
||||||
|
defaults.HideDotfiles, err = flags.GetBool(flag.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
visitErr = err
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,5 +152,6 @@ func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all
|
|||||||
} else {
|
} else {
|
||||||
flags.Visit(visit)
|
flags.Visit(visit)
|
||||||
}
|
}
|
||||||
return visitErr
|
|
||||||
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ 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: python(func(cmd *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error {
|
||||||
s, err := d.store.Settings.Get()
|
flags := cmd.Flags()
|
||||||
|
s, err := st.Settings.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = getUserDefaults(cmd.Flags(), &s.Defaults, false)
|
err = getUserDefaults(flags, &s.Defaults, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -31,39 +32,36 @@ var usersAddCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
lockPassword, err := getBool(cmd.Flags(), "lockPassword")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dateFormat, err := getBool(cmd.Flags(), "dateFormat")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
hideDotfiles, err := getBool(cmd.Flags(), "hideDotfiles")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &users.User{
|
user := &users.User{
|
||||||
Username: args[0],
|
Username: args[0],
|
||||||
Password: password,
|
Password: password,
|
||||||
LockPassword: lockPassword,
|
}
|
||||||
DateFormat: dateFormat,
|
|
||||||
HideDotfiles: hideDotfiles,
|
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 := d.store.Settings.GetServer()
|
servSettings, err := st.Settings.GetServer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 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 := d.store.Settings.Get()
|
s2, err := st.Settings.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -74,11 +72,11 @@ var usersAddCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
user.Scope = userHome
|
user.Scope = userHome
|
||||||
|
|
||||||
err = d.store.Users.Save(user)
|
err = st.Users.Save(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printUsers([]*users.User{user})
|
printUsers([]*users.User{user})
|
||||||
return nil
|
return nil
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ 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: python(func(_ *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
|
||||||
list, err := d.store.Users.Gets("")
|
list, err := st.Users.Gets("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -25,5 +25,5 @@ path to the file where you want to write the users.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ var usersLsCmd = &cobra.Command{
|
|||||||
RunE: findUsers,
|
RunE: findUsers,
|
||||||
}
|
}
|
||||||
|
|
||||||
var findUsers = python(func(_ *cobra.Command, args []string, d *pythonData) error {
|
var findUsers = withStore(func(_ *cobra.Command, args []string, st *store) error {
|
||||||
var (
|
var (
|
||||||
list []*users.User
|
list []*users.User
|
||||||
user *users.User
|
user *users.User
|
||||||
@@ -36,14 +36,14 @@ var findUsers = python(func(_ *cobra.Command, args []string, d *pythonData) erro
|
|||||||
if len(args) == 1 {
|
if len(args) == 1 {
|
||||||
username, id := parseUsernameOrID(args[0])
|
username, id := parseUsernameOrID(args[0])
|
||||||
if username != "" {
|
if username != "" {
|
||||||
user, err = d.store.Users.Get("", username)
|
user, err = st.Users.Get("", username)
|
||||||
} else {
|
} else {
|
||||||
user, err = d.store.Users.Get("", id)
|
user, err = st.Users.Get("", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
list = []*users.User{user}
|
list = []*users.User{user}
|
||||||
} else {
|
} else {
|
||||||
list, err = d.store.Users.Gets("")
|
list, err = st.Users.Gets("")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,4 +51,4 @@ var findUsers = python(func(_ *cobra.Command, args []string, d *pythonData) erro
|
|||||||
}
|
}
|
||||||
printUsers(list)
|
printUsers(list)
|
||||||
return nil
|
return nil
|
||||||
}, pythonConfig{})
|
}, storeOptions{})
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ 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: python(func(cmd *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error {
|
||||||
|
flags := cmd.Flags()
|
||||||
fd, err := os.Open(args[0])
|
fd, err := os.Open(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -45,13 +46,13 @@ list or set it to 0.`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
replace, err := getBool(cmd.Flags(), "replace")
|
replace, err := flags.GetBool("replace")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if replace {
|
if replace {
|
||||||
oldUsers, userImportErr := d.store.Users.Gets("")
|
oldUsers, userImportErr := st.Users.Gets("")
|
||||||
if userImportErr != nil {
|
if userImportErr != nil {
|
||||||
return userImportErr
|
return userImportErr
|
||||||
}
|
}
|
||||||
@@ -62,20 +63,20 @@ list or set it to 0.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, user := range oldUsers {
|
for _, user := range oldUsers {
|
||||||
err = d.store.Users.Delete(user.ID)
|
err = st.Users.Delete(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
overwrite, err := getBool(cmd.Flags(), "overwrite")
|
overwrite, err := flags.GetBool("overwrite")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, user := range list {
|
for _, user := range list {
|
||||||
onDB, err := d.store.Users.Get("", user.ID)
|
onDB, err := st.Users.Get("", user.ID)
|
||||||
|
|
||||||
// User exists in DB.
|
// User exists in DB.
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -87,7 +88,7 @@ list or set it to 0.`,
|
|||||||
// 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 := d.store.Users.Get("", user.Username); err == nil { //nolint:govet
|
if conflictuous, err := st.Users.Get("", user.Username); err == nil {
|
||||||
return usernameConflictError(user.Username, conflictuous.ID, user.ID)
|
return usernameConflictError(user.Username, conflictuous.ID, user.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,13 +98,13 @@ list or set it to 0.`,
|
|||||||
user.ID = 0
|
user.ID = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
err = d.store.Users.Save(user)
|
err = st.Users.Save(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
func usernameConflictError(username string, originalID, newID uint) error {
|
func usernameConflictError(username string, originalID, newID uint) error {
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ 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: python(func(_ *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
|
||||||
username, id := parseUsernameOrID(args[0])
|
username, id := parseUsernameOrID(args[0])
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if username != "" {
|
if username != "" {
|
||||||
err = d.store.Users.Delete(username)
|
err = st.Users.Delete(username)
|
||||||
} else {
|
} else {
|
||||||
err = d.store.Users.Delete(id)
|
err = st.Users.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,5 +30,5 @@ var usersRmCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
fmt.Println("user deleted successfully")
|
fmt.Println("user deleted successfully")
|
||||||
return nil
|
return nil
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,19 +21,20 @@ 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: python(func(cmd *cobra.Command, args []string, d *pythonData) error {
|
RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error {
|
||||||
username, id := parseUsernameOrID(args[0])
|
|
||||||
flags := cmd.Flags()
|
flags := cmd.Flags()
|
||||||
password, err := getString(flags, "password")
|
username, id := parseUsernameOrID(args[0])
|
||||||
if err != nil {
|
password, err := flags.GetString("password")
|
||||||
return err
|
|
||||||
}
|
|
||||||
newUsername, err := getString(flags, "username")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := d.store.Settings.Get()
|
newUsername, err := flags.GetString("username")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := st.Settings.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -41,46 +42,50 @@ options you want to change.`,
|
|||||||
var (
|
var (
|
||||||
user *users.User
|
user *users.User
|
||||||
)
|
)
|
||||||
|
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
user, err = d.store.Users.Get("", id)
|
user, err = st.Users.Get("", id)
|
||||||
} else {
|
} else {
|
||||||
user, err = d.store.Users.Get("", username)
|
user, err = st.Users.Get("", username)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 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,
|
||||||
Perm: user.Perm,
|
RedirectAfterCopyMove: user.RedirectAfterCopyMove,
|
||||||
Sorting: user.Sorting,
|
Perm: user.Perm,
|
||||||
Commands: user.Commands,
|
Sorting: user.Sorting,
|
||||||
|
Commands: user.Commands,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = getUserDefaults(flags, &defaults, false)
|
err = getUserDefaults(flags, &defaults, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 = getBool(flags, "lockPassword")
|
user.LockPassword, err = flags.GetBool("lockPassword")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
user.DateFormat, err = getBool(flags, "dateFormat")
|
|
||||||
|
user.DateFormat, err = flags.GetBool("dateFormat")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
user.HideDotfiles, err = getBool(flags, "hideDotfiles")
|
|
||||||
|
user.HideDotfiles, err = flags.GetBool("hideDotfiles")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -96,11 +101,11 @@ options you want to change.`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = d.store.Users.Update(user)
|
err = st.Users.Update(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printUsers([]*users.User{user})
|
printUsers([]*users.User{user})
|
||||||
return nil
|
return nil
|
||||||
}, pythonConfig{}),
|
}, storeOptions{}),
|
||||||
}
|
}
|
||||||
|
|||||||
188
cmd/utils.go
@@ -12,51 +12,34 @@ import (
|
|||||||
"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"
|
||||||
yaml "gopkg.in/yaml.v2"
|
"github.com/spf13/viper"
|
||||||
|
yaml "gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"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 dbPerms = 0640
|
const databasePermissions = 0640
|
||||||
|
|
||||||
func returnErr(err error) error {
|
func getAndParseFileMode(flags *pflag.FlagSet, name string) (fs.FileMode, error) {
|
||||||
if err != nil {
|
mode, err := flags.GetString(name)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getString(flags *pflag.FlagSet, flag string) (string, error) {
|
|
||||||
s, err := flags.GetString(flag)
|
|
||||||
return s, returnErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMode(flags *pflag.FlagSet, flag string) (fs.FileMode, error) {
|
|
||||||
s, err := getString(flags, flag)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
b, err := strconv.ParseUint(s, 0, 32)
|
|
||||||
|
b, err := strconv.ParseUint(mode, 0, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return fs.FileMode(b), nil
|
return fs.FileMode(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBool(flags *pflag.FlagSet, flag string) (bool, error) {
|
|
||||||
b, err := flags.GetBool(flag)
|
|
||||||
return b, returnErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUint(flags *pflag.FlagSet, flag string) (uint, error) {
|
|
||||||
b, err := flags.GetUint(flag)
|
|
||||||
return b, returnErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateKey() []byte {
|
func generateKey() []byte {
|
||||||
k, err := settings.GenerateKey()
|
k, err := settings.GenerateKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,20 +48,6 @@ func generateKey() []byte {
|
|||||||
return k
|
return k
|
||||||
}
|
}
|
||||||
|
|
||||||
type cobraFunc func(cmd *cobra.Command, args []string) error
|
|
||||||
type pythonFunc func(cmd *cobra.Command, args []string, data *pythonData) error
|
|
||||||
|
|
||||||
type pythonConfig struct {
|
|
||||||
noDB bool
|
|
||||||
allowNoDB bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type pythonData struct {
|
|
||||||
hadDB bool
|
|
||||||
store *storage.Storage
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -89,7 +58,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 { //nolint:govet
|
if err := os.MkdirAll(d, 0700); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -99,42 +68,137 @@ func dbExists(path string) (bool, error) {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func python(fn pythonFunc, cfg pythonConfig) cobraFunc {
|
// Generate the replacements for all environment variables. This allows to
|
||||||
|
// use FB_BRANDING_DISABLE_EXTERNAL environment variables, even when the
|
||||||
|
// option name is branding.disableExternal.
|
||||||
|
func generateEnvKeyReplacements(cmd *cobra.Command) []string {
|
||||||
|
replacements := []string{}
|
||||||
|
|
||||||
|
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||||
|
oldName := strings.ToUpper(f.Name)
|
||||||
|
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 {
|
||||||
|
return nil, 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 {
|
return func(cmd *cobra.Command, args []string) error {
|
||||||
data := &pythonData{hadDB: true}
|
v, err := initViper(cmd)
|
||||||
|
|
||||||
path := getStringParam(cmd.Flags(), "database")
|
|
||||||
absPath, err := filepath.Abs(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path, err := filepath.Abs(v.GetString("database"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
exists, err := dbExists(path)
|
exists, err := dbExists(path)
|
||||||
|
switch {
|
||||||
if err != nil {
|
case err != nil:
|
||||||
panic(err)
|
return err
|
||||||
} else if exists && cfg.noDB {
|
case exists && options.expectsNoDatabase:
|
||||||
log.Fatal(absPath + " already exists")
|
log.Fatal(path + " already exists")
|
||||||
} else if !exists && !cfg.noDB && !cfg.allowNoDB {
|
case !exists && !options.expectsNoDatabase && !options.allowsNoDatabase:
|
||||||
log.Fatal(absPath + " does not exist. Please run 'filebrowser config init' first.")
|
log.Fatal(path + " does not exist. Please run 'filebrowser config init' first.")
|
||||||
} else if !exists && !cfg.noDB {
|
case !exists && !options.expectsNoDatabase:
|
||||||
log.Println("Warning: filebrowser.db can't be found. Initialing in " + strings.TrimSuffix(absPath, "filebrowser.db"))
|
log.Println("WARNING: filebrowser.db can't be found. Initialing in " + strings.TrimSuffix(path, "filebrowser.db"))
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Using database: " + absPath)
|
log.Println("Using database: " + path)
|
||||||
data.hadDB = exists
|
|
||||||
db, err := storm.Open(path, storm.BoltOptions(dbPerms, nil))
|
db, err := storm.Open(path, storm.BoltOptions(databasePermissions, nil))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
data.store, err = bolt.NewStorage(db)
|
|
||||||
|
storage, err := bolt.NewStorage(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return fn(cmd, args, data)
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -2,7 +2,7 @@ package diskcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha1" //nolint:gosec
|
"crypto/sha1"
|
||||||
"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() //nolint:gosec
|
hasher := sha1.New()
|
||||||
_, _ = 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)
|
||||||
|
|||||||
@@ -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(t, ctx, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, value)
|
checkValue(ctx, t, 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(t, ctx, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, newValue)
|
checkValue(ctx, t, 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(t *testing.T, ctx context.Context, fs afero.Fs, fileFullPath string, cache *FileCache, key, wantValue string) { //nolint:revive
|
func checkValue(ctx context.Context, t *testing.T, fs afero.Fs, fileFullPath string, cache *FileCache, key, wantValue string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
// check actual file content
|
// check actual file content
|
||||||
b, err := afero.ReadFile(fs, fileFullPath)
|
b, err := afero.ReadFile(fs, fileFullPath)
|
||||||
|
|||||||
@@ -5,4 +5,4 @@
|
|||||||
"log": "stdout",
|
"log": "stdout",
|
||||||
"database": "/database/filebrowser.db",
|
"database": "/database/filebrowser.db",
|
||||||
"root": "/srv"
|
"root": "/srv"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,28 @@
|
|||||||
package errors
|
package fberrors
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"syscall"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ExitCodeSigTerm = 128 + int(syscall.SIGTERM)
|
|
||||||
ExitCodeSighup = 128 + int(syscall.SIGHUP)
|
|
||||||
ExitCodeSigint = 128 + int(syscall.SIGINT)
|
|
||||||
ExitCodeSigquit = 128 + int(syscall.SIGQUIT)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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")
|
||||||
ErrSigTerm = errors.New("exit on signal: sigterm")
|
ErrCurrentPasswordIncorrect = errors.New("the current password is incorrect")
|
||||||
ErrSighup = errors.New("exit on signal: sighup")
|
|
||||||
ErrSigint = errors.New("exit on signal: sigint")
|
|
||||||
ErrSigquit = errors.New("exit on signal: sigquit")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ErrShortPassword struct {
|
type ErrShortPassword struct {
|
||||||
@@ -44,44 +32,3 @@ type ErrShortPassword struct {
|
|||||||
func (e ErrShortPassword) Error() string {
|
func (e ErrShortPassword) Error() string {
|
||||||
return fmt.Sprintf("password is too short, minimum length is %d", e.MinimumLength)
|
return fmt.Sprintf("password is too short, minimum length is %d", e.MinimumLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExitCode returns the exit code for a given error.
|
|
||||||
func GetExitCode(err error) int {
|
|
||||||
if err == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
exitCodeMap := map[error]int{
|
|
||||||
ErrSigTerm: ExitCodeSigTerm,
|
|
||||||
ErrSighup: ExitCodeSighup,
|
|
||||||
ErrSigint: ExitCodeSigint,
|
|
||||||
ErrSigquit: ExitCodeSigquit,
|
|
||||||
}
|
|
||||||
|
|
||||||
for e, code := range exitCodeMap {
|
|
||||||
if errors.Is(err, e) {
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if exitErr, ok := err.(interface{ ExitCode() int }); ok {
|
|
||||||
return exitErr.ExitCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
var pathErr *os.PathError
|
|
||||||
if errors.As(err, &pathErr) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
var syscallErr *os.SyscallError
|
|
||||||
if errors.As(err, &syscallErr) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
var errno syscall.Errno
|
|
||||||
if errors.As(err, &errno) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package files
|
package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5" //nolint:gosec
|
"crypto/md5"
|
||||||
"crypto/sha1" //nolint:gosec
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/sha512"
|
"crypto/sha512"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -23,7 +23,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,22 +35,24 @@ var (
|
|||||||
// FileInfo describes a file.
|
// FileInfo describes a file.
|
||||||
type FileInfo struct {
|
type FileInfo struct {
|
||||||
*Listing
|
*Listing
|
||||||
Fs afero.Fs `json:"-"`
|
Fs afero.Fs `json:"-"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Extension string `json:"extension"`
|
Extension string `json:"extension"`
|
||||||
ModTime time.Time `json:"modified"`
|
ModTime time.Time `json:"modified"`
|
||||||
Mode os.FileMode `json:"mode"`
|
Mode os.FileMode `json:"mode"`
|
||||||
IsDir bool `json:"isDir"`
|
IsDir bool `json:"isDir"`
|
||||||
IsSymlink bool `json:"isSymlink"`
|
IsSymlink bool `json:"isSymlink"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Subtitles []string `json:"subtitles,omitempty"`
|
Subtitles []string `json:"subtitles,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Checksums map[string]string `json:"checksums,omitempty"`
|
Checksums map[string]string `json:"checksums,omitempty"`
|
||||||
Token string `json:"token,omitempty"`
|
// Tags holds audio metadata (ID3, VorbisComment, MP4 tags, ...)
|
||||||
currentDir []os.FileInfo `json:"-"`
|
Tags map[string]interface{} `json:"tags,omitempty"`
|
||||||
Resolution *ImageResolution `json:"resolution,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
|
currentDir []os.FileInfo `json:"-"`
|
||||||
|
Resolution *ImageResolution `json:"resolution,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileOptions are the options when getting a file info.
|
// FileOptions are the options when getting a file info.
|
||||||
@@ -60,6 +62,7 @@ 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
|
||||||
@@ -90,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); err != nil { //nolint:govet
|
if err := file.readListing(opts.Checker, opts.ReadHeader, opts.CalcImgRes); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err = file.detectType(opts.Modify, opts.Content, true)
|
err = file.detectType(opts.Modify, opts.Content, true, opts.CalcImgRes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -168,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 {
|
||||||
@@ -183,7 +186,6 @@ 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 +196,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 +221,7 @@ func (i *FileInfo) RealPath() string {
|
|||||||
return i.Path
|
return i.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error {
|
func (i *FileInfo) detectType(modify, saveContent, readHeader bool, calcImgRes bool) error {
|
||||||
if IsNamedPipe(i.Mode) {
|
if IsNamedPipe(i.Mode) {
|
||||||
i.Type = "blob"
|
i.Type = "blob"
|
||||||
return nil
|
return nil
|
||||||
@@ -250,11 +252,13 @@ func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error {
|
|||||||
return nil
|
return nil
|
||||||
case strings.HasPrefix(mimetype, "image"):
|
case strings.HasPrefix(mimetype, "image"):
|
||||||
i.Type = "image"
|
i.Type = "image"
|
||||||
resolution, err := calculateImageResolution(i.Fs, i.Path)
|
if calcImgRes {
|
||||||
if err != nil {
|
resolution, err := calculateImageResolution(i.Fs, i.Path)
|
||||||
log.Printf("Error calculating image resolution: %v", err)
|
if err != nil {
|
||||||
} else {
|
log.Printf("Error calculating image resolution: %v", err)
|
||||||
i.Resolution = resolution
|
} else {
|
||||||
|
i.Resolution = resolution
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case strings.HasSuffix(mimetype, "pdf"):
|
case strings.HasSuffix(mimetype, "pdf"):
|
||||||
@@ -388,7 +392,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) error {
|
func (i *FileInfo) readListing(checker rules.Checker, readHeader bool, calcImgRes 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 {
|
||||||
@@ -435,7 +439,7 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error {
|
|||||||
currentDir: dir,
|
currentDir: dir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !file.IsDir && strings.HasPrefix(mime.TypeByExtension(file.Extension), "image/") {
|
if !file.IsDir && strings.HasPrefix(mime.TypeByExtension(file.Extension), "image/") && calcImgRes {
|
||||||
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)
|
||||||
@@ -452,7 +456,7 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error {
|
|||||||
if isInvalidLink {
|
if isInvalidLink {
|
||||||
file.Type = "invalid_link"
|
file.Type = "invalid_link"
|
||||||
} else {
|
} else {
|
||||||
err := file.detectType(true, false, readHeader)
|
err := file.detectType(true, false, readHeader, calcImgRes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -600,7 +600,6 @@ 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
|
||||||
|
|||||||
221
files/tags.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dhowden/tag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReadAudioTags extracts common audio metadata from the file and stores it in
|
||||||
|
// the FileInfo.Tags map. It attempts to extract common fields and includes
|
||||||
|
// embedded picture(s) as base64 where present.
|
||||||
|
// Multi-valued tags like ARTISTS and ALBUMARTISTS are extracted as arrays.
|
||||||
|
func (i *FileInfo) ReadAudioTags() error {
|
||||||
|
if i.IsDir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := i.Fs.Open(i.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
m, err := tag.ReadFrom(f)
|
||||||
|
if err != nil {
|
||||||
|
// don't fail hard; log and return nil so callers still get file info
|
||||||
|
log.Printf("ReadAudioTags: failed to read tags for %s: %v", i.Path, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := map[string]interface{}{}
|
||||||
|
|
||||||
|
if v := m.Title(); v != "" {
|
||||||
|
tags["title"] = v
|
||||||
|
}
|
||||||
|
if v := m.Album(); v != "" {
|
||||||
|
tags["album"] = v
|
||||||
|
}
|
||||||
|
if v := m.Artist(); v != "" {
|
||||||
|
tags["artist"] = v
|
||||||
|
}
|
||||||
|
if v := m.AlbumArtist(); v != "" {
|
||||||
|
tags["albumartist"] = v
|
||||||
|
}
|
||||||
|
if v := m.Composer(); v != "" {
|
||||||
|
tags["composer"] = v
|
||||||
|
}
|
||||||
|
if v := m.Year(); v != 0 {
|
||||||
|
tags["year"] = v
|
||||||
|
}
|
||||||
|
if t, _ := m.Track(); t > 0 {
|
||||||
|
tags["track"] = t
|
||||||
|
}
|
||||||
|
if dnum, _ := m.Disc(); dnum > 0 {
|
||||||
|
tags["disc"] = dnum
|
||||||
|
}
|
||||||
|
if v := m.Genre(); v != "" {
|
||||||
|
tags["genre"] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pictures: encode as data URI (base64) so frontend can preview if needed.
|
||||||
|
if pic := m.Picture(); pic != nil {
|
||||||
|
// safe encode image bytes as base64
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := io.Copy(&buf, bytes.NewReader(pic.Data)); err == nil {
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
dataURI := "data:" + pic.MIMEType + ";base64," + b64
|
||||||
|
tags["picture"] = map[string]interface{}{
|
||||||
|
"mime": pic.MIMEType,
|
||||||
|
"data": dataURI,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep raw metadata map if available (some formats expose additional fields)
|
||||||
|
// Also extract multi-valued tags like ARTISTS and ALBUMARTISTS
|
||||||
|
if raw := m.Raw(); raw != nil {
|
||||||
|
// First pass: collect multi-valued tags (ARTISTS, ALBUMARTISTS)
|
||||||
|
artists := extractMultiValuedTag(raw, "ARTISTS", "artists")
|
||||||
|
albumArtists := extractMultiValuedTag(raw, "ALBUMARTISTS", "albumartists")
|
||||||
|
|
||||||
|
// Store multi-valued tags as arrays if present
|
||||||
|
if len(artists) > 0 {
|
||||||
|
tags["artists"] = artists
|
||||||
|
}
|
||||||
|
if len(albumArtists) > 0 {
|
||||||
|
tags["albumartists"] = albumArtists
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range raw {
|
||||||
|
// Skip raw APIC entries (attached picture frame) to avoid
|
||||||
|
// exposing large binary blobs. We already expose a friendly
|
||||||
|
// `picture` key above when a picture exists.
|
||||||
|
if strings.EqualFold(k, "APIC") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip multi-valued tags we already handled
|
||||||
|
kLower := strings.ToLower(k)
|
||||||
|
if kLower == "artists" || kLower == "albumartists" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// avoid overwriting already set common fields
|
||||||
|
if _, ok := tags[k]; !ok {
|
||||||
|
tags[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For FLAC files, use metaflac to read multi-valued tags properly
|
||||||
|
// since dhowden/tag doesn't handle them correctly
|
||||||
|
if realPath := i.RealPath(); realPath != "" && strings.EqualFold(filepath.Ext(realPath), ".flac") {
|
||||||
|
if multiTags := readMultiValuedTagsWithMetaflac(realPath); multiTags != nil {
|
||||||
|
if artists, ok := multiTags["ARTISTS"]; ok && len(artists) > 0 {
|
||||||
|
tags["artists"] = artists
|
||||||
|
}
|
||||||
|
if albumArtists, ok := multiTags["ALBUMARTISTS"]; ok && len(albumArtists) > 0 {
|
||||||
|
tags["albumartists"] = albumArtists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach tags map
|
||||||
|
if len(tags) > 0 {
|
||||||
|
i.Tags = tags
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMultiValuedTag extracts values for a multi-valued tag from raw metadata.
|
||||||
|
// It handles both Vorbis-style (multiple entries with same key) and ID3-style
|
||||||
|
// (single string that may need splitting) formats.
|
||||||
|
func extractMultiValuedTag(raw map[string]interface{}, keys ...string) []string {
|
||||||
|
var result []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
for rawKey, rawVal := range raw {
|
||||||
|
if !strings.EqualFold(rawKey, key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Handle different value types returned by the tag library
|
||||||
|
switch v := rawVal.(type) {
|
||||||
|
case string:
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v != "" && !seen[v] {
|
||||||
|
result = append(result, v)
|
||||||
|
seen[v] = true
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
for _, s := range v {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s != "" && !seen[s] {
|
||||||
|
result = append(result, s)
|
||||||
|
seen[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range v {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s != "" && !seen[s] {
|
||||||
|
result = append(result, s)
|
||||||
|
seen[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMultiValuedTagsWithMetaflac uses metaflac to read Vorbis comments from FLAC files.
|
||||||
|
// This properly handles multi-valued tags (multiple entries with same key).
|
||||||
|
func readMultiValuedTagsWithMetaflac(filepath string) map[string][]string {
|
||||||
|
if _, err := exec.LookPath("metaflac"); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use metaflac to export all tags
|
||||||
|
cmd := exec.Command("metaflac", "--export-tags-to=-", filepath)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("metaflac export failed for %s: %v", filepath, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse output: each line is TAG=VALUE
|
||||||
|
result := make(map[string][]string)
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.Index(line, "=")
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToUpper(strings.TrimSpace(line[:idx]))
|
||||||
|
value := strings.TrimSpace(line[idx+1:])
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Collect multi-valued artist tags (ARTISTS, ARTIST, ALBUMARTISTS)
|
||||||
|
// Normalize key variations
|
||||||
|
if key == "ARTISTS" || key == "ARTIST" {
|
||||||
|
result["ARTISTS"] = append(result["ARTISTS"], value)
|
||||||
|
} else if key == "ALBUMARTISTS" || key == "ALBUMARTIST" || key == "ALBUM ARTIST" || key == "ALBUM_ARTIST" {
|
||||||
|
result["ALBUMARTISTS"] = append(result["ALBUMARTISTS"], value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
7
files/write_mp3.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
// This file is a placeholder to avoid accidental module resolution for
|
||||||
|
// non-implemented MP3 metadata writers. The actual implementation is not
|
||||||
|
// required at runtime, and we should not import external packages here.
|
||||||
|
|
||||||
|
// Intentionally left blank.
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build !dev
|
//go:build !dev
|
||||||
// +build !dev
|
|
||||||
|
|
||||||
package frontend
|
package frontend
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
//go:build dev
|
|
||||||
// +build dev
|
|
||||||
|
|
||||||
package frontend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
var assets fs.FS = os.DirFS("frontend")
|
|
||||||
|
|
||||||
func Assets() fs.FS {
|
|
||||||
return assets
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,11 @@
|
|||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg" />
|
||||||
<link rel="shortcut icon" href="/img/icons/favicon.ico" />
|
<link rel="shortcut icon" href="/img/icons/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/icons/apple-touch-icon.png" />
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/img/icons/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
<meta name="apple-mobile-web-app-title" content="File Browser" />
|
<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 -->
|
||||||
@@ -35,6 +39,7 @@
|
|||||||
DisableUsedPercentage: false,
|
DisableUsedPercentage: false,
|
||||||
EnableExec: true,
|
EnableExec: true,
|
||||||
EnableThumbs: true,
|
EnableThumbs: true,
|
||||||
|
LogoutPage: "",
|
||||||
LoginPage: true,
|
LoginPage: true,
|
||||||
Name: "",
|
Name: "",
|
||||||
NoAuth: false,
|
NoAuth: false,
|
||||||
|
|||||||
7924
frontend/package-lock.json
generated
Normal file
@@ -4,37 +4,35 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0",
|
"node": ">=24.0.0",
|
||||||
"pnpm": ">=9.0.0"
|
"pnpm": ">=10.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.tsc.json --noEmit",
|
"typecheck": "vue-tsc -p ./tsconfig.app.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": "^12.5.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
"@vueuse/integrations": "^12.5.0",
|
"@vueuse/integrations": "^14.0.0",
|
||||||
"ace-builds": "^1.43.2",
|
"ace-builds": "^1.43.2",
|
||||||
"core-js": "^3.44.0",
|
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"epubjs": "^0.3.93",
|
"epubjs": "^0.3.93",
|
||||||
"filesize": "^10.1.1",
|
"filesize": "^11.0.13",
|
||||||
"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": "^15.0.6",
|
"marked": "^17.0.0",
|
||||||
"material-icons": "^1.13.14",
|
"material-icons": "^1.13.14",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^3.0.4",
|
||||||
"pretty-bytes": "^6.1.1",
|
"pretty-bytes": "^7.1.0",
|
||||||
"qrcode.vue": "^3.6.0",
|
"qrcode.vue": "^3.6.0",
|
||||||
"tus-js-client": "^4.3.1",
|
"tus-js-client": "^4.3.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
@@ -50,30 +48,28 @@
|
|||||||
"vue-toastification": "^2.0.0-rc.5"
|
"vue-toastification": "^2.0.0-rc.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
"@intlify/unplugin-vue-i18n": "^11.0.1",
|
||||||
"@playwright/test": "^1.54.1",
|
"@tsconfig/node24": "^24.0.2",
|
||||||
"@tsconfig/node22": "^22.0.2",
|
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^22.10.10",
|
"@types/node": "^24.10.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||||
"@vitejs/plugin-legacy": "^6.0.0",
|
"@vitejs/plugin-legacy": "^7.2.1",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@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.6.0",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"concurrently": "^9.2.0",
|
|
||||||
"eslint": "^9.31.0",
|
"eslint": "^9.31.0",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"eslint-plugin-prettier": "^5.5.1",
|
"eslint-plugin-prettier": "^5.5.1",
|
||||||
"eslint-plugin-vue": "^9.24.0",
|
"eslint-plugin-vue": "^10.5.1",
|
||||||
"jsdom": "^26.1.0",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"terser": "^5.43.1",
|
"terser": "^5.43.1",
|
||||||
"vite": "^6.1.6",
|
"typescript": "^5.9.3",
|
||||||
"vite-plugin-compression2": "^1.0.0",
|
"vite": "^7.2.2",
|
||||||
"vue-tsc": "^2.2.0"
|
"vite-plugin-compression2": "^2.3.1",
|
||||||
|
"vue-tsc": "^3.1.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
|
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
4767
frontend/pnpm-lock.yaml
generated
@@ -1,147 +1 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<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>
|
||||||
<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: 5.4 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -18,9 +18,17 @@
|
|||||||
|
|
||||||
<meta name="robots" content="noindex,nofollow" />
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="[{[ .StaticURL ]}]/img/icons/favicon.svg" />
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="[{[ .StaticURL ]}]/img/icons/favicon.svg"
|
||||||
|
/>
|
||||||
<link rel="shortcut icon" href="[{[ .StaticURL ]}]/img/icons/favicon.ico" />
|
<link rel="shortcut icon" href="[{[ .StaticURL ]}]/img/icons/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png" />
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
<meta name="apple-mobile-web-app-title" content="File Browser" />
|
<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 -->
|
||||||
|
|||||||
@@ -188,6 +188,38 @@ export async function checksum(url: string, algo: ChecksumAlg) {
|
|||||||
return (await data.json()).checksums[algo];
|
return (await data.json()).checksums[algo];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function metadata(url: string) {
|
||||||
|
// Fetch metadata for a resource. Backend support is required for this to work.
|
||||||
|
const res = await resourceAction(`${url}?metadata=1`, "GET");
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
// backend returns a FileInfo object; prefer returning the audio tags map when present
|
||||||
|
if (data && typeof data === "object" && data.tags) {
|
||||||
|
return data.tags;
|
||||||
|
}
|
||||||
|
// fallback: if server returned just the tags map or other structure
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { normalizeChanges, normalizeMixedChanges } from "@/utils/metadata";
|
||||||
|
|
||||||
|
export async function updateMetadata(url: string, content: any) {
|
||||||
|
// Update metadata for a resource. Backend must support PATCH with action=metadata.
|
||||||
|
// Normalize outgoing changes to canonical keys so backend gets stable names, preserving arrays for multi-valued tags.
|
||||||
|
if (content && content.__clear__) {
|
||||||
|
const clear = content.__clear__;
|
||||||
|
const { __clear__, ...rest } = content;
|
||||||
|
content = normalizeMixedChanges(rest || {});
|
||||||
|
content.__clear__ = clear;
|
||||||
|
} else {
|
||||||
|
content = normalizeMixedChanges(content || {});
|
||||||
|
}
|
||||||
|
return resourceAction(`${url}?action=metadata`, "PATCH", JSON.stringify(content));
|
||||||
|
}
|
||||||
|
|
||||||
export function getDownloadURL(file: ResourceItem, inline: any) {
|
export function getDownloadURL(file: ResourceItem, inline: any) {
|
||||||
const params = {
|
const params = {
|
||||||
...(inline && { inline: "true" }),
|
...(inline && { inline: "true" }),
|
||||||
|
|||||||
@@ -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 += encodeURIComponent(files[0]) + "?";
|
url += files[0] + "?";
|
||||||
} else {
|
} else {
|
||||||
let arg = "";
|
let arg = "";
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
arg += encodeURIComponent(file) + ",";
|
arg += file + ",";
|
||||||
}
|
}
|
||||||
|
|
||||||
arg = arg.substring(0, arg.length - 1);
|
arg = arg.substring(0, arg.length - 1);
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { fetchURL, removePrefix } from "./utils";
|
import { fetchURL, removePrefix, StatusError } from "./utils";
|
||||||
import url from "../utils/url";
|
import url from "../utils/url";
|
||||||
|
|
||||||
export default async function search(base: string, query: string) {
|
export default async function search(
|
||||||
|
base: string,
|
||||||
|
query: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
callback: (item: ResourceItem) => void
|
||||||
|
) {
|
||||||
base = removePrefix(base);
|
base = removePrefix(base);
|
||||||
query = encodeURIComponent(query);
|
query = encodeURIComponent(query);
|
||||||
|
|
||||||
@@ -9,19 +14,60 @@ export default async function search(base: string, query: string) {
|
|||||||
base += "/";
|
base += "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetchURL(`/api/search${base}?query=${query}`, {});
|
const res = await fetchURL(`/api/search${base}?query=${query}`, { signal });
|
||||||
|
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;
|
||||||
|
|
||||||
let data = await res.json();
|
for (const line of lines) {
|
||||||
|
if (line) {
|
||||||
data = data.map((item: ResourceItem & { dir: boolean }) => {
|
const item = JSON.parse(line) as ResourceItem;
|
||||||
item.url = `/files${base}` + url.encodePath(item.path);
|
item.url = `/files${base}` + url.encodePath(item.path);
|
||||||
|
if (item.isDir) {
|
||||||
if (item.dir) {
|
item.url += "/";
|
||||||
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) {
|
||||||
return item;
|
// Check if the error is an intentional cancellation
|
||||||
});
|
if (e instanceof Error && e.name === "AbortError") {
|
||||||
|
throw new StatusError("000 No connection", 0, true);
|
||||||
return data;
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ export async function get(id: number) {
|
|||||||
return fetchJSON<IUser>(`/api/users/${id}`, {});
|
return fetchJSON<IUser>(`/api/users/${id}`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(user: IUser) {
|
export async function create(user: IUser, currentPassword: string) {
|
||||||
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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -25,19 +26,30 @@ export async function create(user: IUser) {
|
|||||||
throw new StatusError(await res.text(), res.status);
|
throw new StatusError(await res.text(), res.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(user: Partial<IUser>, which = ["all"]) {
|
export async function update(
|
||||||
|
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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(id: number) {
|
export async function remove(
|
||||||
|
id: number,
|
||||||
|
currentPassword: string | null = null
|
||||||
|
) {
|
||||||
await fetchURL(`/api/users/${id}`, {
|
await fetchURL(`/api/users/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({
|
||||||
|
...(currentPassword != null ? { current_password: currentPassword } : {}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,3 +91,21 @@ 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();
|
||||||
|
}
|
||||||
|
|||||||
47
frontend/src/components/ContextMenu.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<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>
|
||||||
@@ -5,10 +5,11 @@
|
|||||||
v-if="active"
|
v-if="active"
|
||||||
class="action"
|
class="action"
|
||||||
@click="close"
|
@click="close"
|
||||||
:aria-label="$t('buttons.close')"
|
:aria-label="closeButtonTitle"
|
||||||
:title="$t('buttons.close')"
|
:title="closeButtonTitle"
|
||||||
>
|
>
|
||||||
<i class="material-icons">arrow_back</i>
|
<i v-if="ongoing" class="material-icons">stop_circle</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
|
||||||
@@ -21,6 +22,15 @@
|
|||||||
: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">
|
||||||
@@ -57,9 +67,6 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p id="renew">
|
|
||||||
<i class="material-icons spin">autorenew</i>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,10 +77,11 @@ 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 } from "vue";
|
import { computed, inject, onMounted, ref, watch, onUnmounted } 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" },
|
||||||
@@ -84,6 +92,7 @@ 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);
|
||||||
|
|
||||||
@@ -124,9 +133,7 @@ watch(currentPromptName, (newVal, oldVal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(prompt, () => {
|
watch(prompt, () => {
|
||||||
if (results.value.length) {
|
reset();
|
||||||
reset();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ...mapState(useFileStore, ["isListing"]),
|
// ...mapState(useFileStore, ["isListing"]),
|
||||||
@@ -149,6 +156,10 @@ 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;
|
||||||
@@ -164,14 +175,23 @@ 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) => {
|
||||||
event.stopPropagation();
|
if (ongoing.value) {
|
||||||
event.preventDefault();
|
abortLastSearch();
|
||||||
layoutStore.closeHovers();
|
ongoing.value = false;
|
||||||
|
} else {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
layoutStore.closeHovers();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const keyup = (event: KeyboardEvent) => {
|
const keyup = (event: KeyboardEvent) => {
|
||||||
@@ -188,11 +208,16 @@ 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();
|
||||||
|
|
||||||
@@ -208,8 +233,16 @@ const submit = async (event: Event) => {
|
|||||||
ongoing.value = true;
|
ongoing.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
results.value = await search(path, prompt.value);
|
abortLastSearch();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
</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')"
|
||||||
@@ -124,9 +125,11 @@ 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";
|
||||||
@@ -153,10 +156,11 @@ 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,
|
canLogout: () => !noAuth && (loginPage || logoutPage !== "/login"),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
|
...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
|
||||||
|
|||||||
252
frontend/src/components/files/CsvViewer.vue
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<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>
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
:aria-label="name"
|
:aria-label="name"
|
||||||
:aria-selected="isSelected"
|
:aria-selected="isSelected"
|
||||||
:data-ext="getExtension(name).toLowerCase()"
|
:data-ext="getExtension(name).toLowerCase()"
|
||||||
|
@contextmenu="contextMenu"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<img
|
<img
|
||||||
@@ -239,6 +240,17 @@ const itemClick = (event: Event | KeyboardEvent) => {
|
|||||||
else click(event);
|
else click(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const contextMenu = (event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (
|
||||||
|
fileStore.selected.length === 0 ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
fileStore.selected.indexOf(props.index) === -1
|
||||||
|
) {
|
||||||
|
click(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const click = (event: Event | KeyboardEvent) => {
|
const click = (event: Event | KeyboardEvent) => {
|
||||||
if (!singleClick.value && fileStore.selectedCount !== 0)
|
if (!singleClick.value && fileStore.selectedCount !== 0)
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -253,7 +265,15 @@ const click = (event: Event | KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fileStore.selected.indexOf(props.index) !== -1) {
|
if (fileStore.selected.indexOf(props.index) !== -1) {
|
||||||
fileStore.removeSelected(props.index);
|
if (
|
||||||
|
(event as KeyboardEvent).ctrlKey ||
|
||||||
|
(event as KeyboardEvent).metaKey ||
|
||||||
|
fileStore.multiple
|
||||||
|
) {
|
||||||
|
fileStore.removeSelected(props.index);
|
||||||
|
} else {
|
||||||
|
fileStore.selected = [props.index];
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +299,6 @@ const click = (event: Event | KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!singleClick.value &&
|
|
||||||
!(event as KeyboardEvent).ctrlKey &&
|
!(event as KeyboardEvent).ctrlKey &&
|
||||||
!(event as KeyboardEvent).metaKey &&
|
!(event as KeyboardEvent).metaKey &&
|
||||||
!fileStore.multiple
|
!fileStore.multiple
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$router.push({ path: this.dest });
|
if (this.user.redirectAfterCopyMove)
|
||||||
|
this.$router.push({ path: this.dest });
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
buttons.done("copy");
|
buttons.done("copy");
|
||||||
|
|||||||
86
frontend/src/components/prompts/CreateFilePath.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="path-container" ref="container">
|
||||||
|
<template v-for="(item, index) in path" :key="index">
|
||||||
|
/
|
||||||
|
<span class="path-item">
|
||||||
|
<span
|
||||||
|
v-if="isDir === true || index < path.length - 1"
|
||||||
|
class="material-icons"
|
||||||
|
>folder
|
||||||
|
</span>
|
||||||
|
<span v-else class="material-icons">insert_drive_file</span>
|
||||||
|
{{ item }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { useFileStore } from "@/stores/file";
|
||||||
|
import url from "@/utils/url";
|
||||||
|
|
||||||
|
const fileStore = useFileStore();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isDir: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const path = computed(() => {
|
||||||
|
let basePath = fileStore.isFiles ? route.path : url.removeLastDir(route.path);
|
||||||
|
if (!basePath.endsWith("/")) {
|
||||||
|
basePath += "/";
|
||||||
|
}
|
||||||
|
basePath += props.name;
|
||||||
|
return basePath.split("/").filter(Boolean).splice(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(path, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
const lastItem = container.value?.lastElementChild;
|
||||||
|
lastItem?.scrollIntoView({ behavior: "auto", inline: "end" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.path-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.2em 0;
|
||||||
|
gap: 0.25em;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
scrollbar-width: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-container::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.2em 0;
|
||||||
|
gap: 0.25em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-item > span {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,17 +11,26 @@
|
|||||||
@click="closeHovers"
|
@click="closeHovers"
|
||||||
:aria-label="$t('buttons.cancel')"
|
:aria-label="$t('buttons.cancel')"
|
||||||
:title="$t('buttons.cancel')"
|
:title="$t('buttons.cancel')"
|
||||||
tabindex="2"
|
tabindex="3"
|
||||||
>
|
>
|
||||||
{{ $t("buttons.cancel") }}
|
{{ $t("buttons.cancel") }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="button button--flat button--blue"
|
||||||
|
@click="currentPrompt.saveAction"
|
||||||
|
:aria-label="$t('buttons.saveChanges')"
|
||||||
|
:title="$t('buttons.saveChanges')"
|
||||||
|
tabindex="1"
|
||||||
|
>
|
||||||
|
{{ $t("buttons.saveChanges") }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
id="focus-prompt"
|
id="focus-prompt"
|
||||||
@click="currentPrompt.confirm"
|
@click="currentPrompt.confirm"
|
||||||
class="button button--flat button--red"
|
class="button button--flat button--red"
|
||||||
:aria-label="$t('buttons.discardChanges')"
|
:aria-label="$t('buttons.discardChanges')"
|
||||||
:title="$t('buttons.discardChanges')"
|
:title="$t('buttons.discardChanges')"
|
||||||
tabindex="1"
|
tabindex="2"
|
||||||
>
|
>
|
||||||
{{ $t("buttons.discardChanges") }}
|
{{ $t("buttons.discardChanges") }}
|
||||||
</button>
|
</button>
|
||||||
@@ -30,8 +39,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapState, mapActions } from "pinia";
|
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { mapActions, mapState } from "pinia";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "discardEditorChanges",
|
name: "discardEditorChanges",
|
||||||
|
|||||||
@@ -25,9 +25,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapState } from "pinia";
|
import { mapState, mapActions } from "pinia";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useFileStore } from "@/stores/file";
|
import { useFileStore } from "@/stores/file";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
|
||||||
import url from "@/utils/url";
|
import url from "@/utils/url";
|
||||||
import { files } from "@/api";
|
import { files } from "@/api";
|
||||||
@@ -68,6 +69,7 @@ export default {
|
|||||||
this.abortOngoingNext();
|
this.abortOngoingNext();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["showHover"]),
|
||||||
abortOngoingNext() {
|
abortOngoingNext() {
|
||||||
this.nextAbortController.abort();
|
this.nextAbortController.abort();
|
||||||
},
|
},
|
||||||
@@ -163,7 +165,7 @@ export default {
|
|||||||
this.$emit("update:selected", this.selected);
|
this.$emit("update:selected", this.selected);
|
||||||
},
|
},
|
||||||
createDir: async function () {
|
createDir: async function () {
|
||||||
this.$store.commit("showHover", {
|
this.showHover({
|
||||||
prompt: "newDir",
|
prompt: "newDir",
|
||||||
action: null,
|
action: null,
|
||||||
confirm: null,
|
confirm: null,
|
||||||
|
|||||||
834
frontend/src/components/prompts/ModifyMetadata.vue
Normal file
@@ -0,0 +1,834 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card floating metadata-card">
|
||||||
|
<div class="card-title title-with-actions">
|
||||||
|
<h2>{{ trans('files.metadata','Metadata') }}</h2>
|
||||||
|
<div class="title-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--flat"
|
||||||
|
@click="toggleAddField"
|
||||||
|
:aria-label="trans('buttons.addField','Add field')"
|
||||||
|
:title="trans('buttons.addField','Add field')"
|
||||||
|
>
|
||||||
|
{{ trans('buttons.addField','Add field') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--primary"
|
||||||
|
@click.prevent="applyAll"
|
||||||
|
:aria-label="trans('buttons.apply','Apply')"
|
||||||
|
:title="trans('buttons.apply','Apply')"
|
||||||
|
>
|
||||||
|
{{ trans('buttons.apply','Apply') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<p v-if="selectedCount > 1">
|
||||||
|
{{ $t('prompts.filesSelected', { count: selectedCount }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="selectedCount === 0">
|
||||||
|
<p>{{ $t('prompts.noFileSelected') }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="addFieldVisible" class="add-field-row">
|
||||||
|
<div class="add-field-controls">
|
||||||
|
<input
|
||||||
|
v-model="addFieldKey"
|
||||||
|
type="text"
|
||||||
|
:placeholder="trans('prompts.newFieldName','Field name')"
|
||||||
|
list="tag-suggestions"
|
||||||
|
/>
|
||||||
|
<datalist id="tag-suggestions">
|
||||||
|
<option v-for="s in tagSuggestions" :key="s" :value="s">{{ s }}</option>
|
||||||
|
</datalist>
|
||||||
|
<input
|
||||||
|
v-model="addFieldValue"
|
||||||
|
type="text"
|
||||||
|
:placeholder="trans('prompts.newFieldValue','Value')"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--primary"
|
||||||
|
@click="confirmAddField"
|
||||||
|
:aria-label="trans('buttons.add','Add')"
|
||||||
|
:title="trans('buttons.add','Add')"
|
||||||
|
>
|
||||||
|
{{ trans('buttons.add','Add') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--flat"
|
||||||
|
@click="cancelAddField"
|
||||||
|
>
|
||||||
|
{{ trans('buttons.cancel','Cancel') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-table-wrapper">
|
||||||
|
<table class="metadata-table">
|
||||||
|
<colgroup>
|
||||||
|
<col class="col-label" />
|
||||||
|
<col class="col-current" />
|
||||||
|
<col class="col-new" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="hdr-field">{{ trans('prompts.field','Field') }}</th>
|
||||||
|
<th class="hdr-current">{{ trans('prompts.current','Current') }}</th>
|
||||||
|
<th class="hdr-new">{{ trans('prompts.new','New') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="field in fields" :key="field" class="metadata-row">
|
||||||
|
<td class="metadata-title">
|
||||||
|
<strong>{{ fieldLabel(field) }}</strong>
|
||||||
|
<span v-if="isMultiValued(field)" class="multi-value-hint">{{ trans('prompts.multiValueHint','(multi-valued)') }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="metadata-current">
|
||||||
|
<template v-if="isPicture(field)">
|
||||||
|
<img :src="pictureSrc(field)" alt="cover" class="cover-preview" v-if="pictureSrc(field)" />
|
||||||
|
<span v-else class="metadata-muted">{{ trans('prompts.noCover','No cover') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isMultiValued(field)">
|
||||||
|
<div class="multi-value-display">
|
||||||
|
<div v-for="(val, idx) in displayCurrentArray(field)" :key="idx" class="multi-value-item">
|
||||||
|
{{ val }}
|
||||||
|
</div>
|
||||||
|
<span v-if="displayCurrentArray(field).length === 0" class="metadata-muted">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ displayCurrent(field) }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td class="metadata-edit-cell">
|
||||||
|
<div class="edit-with-actions">
|
||||||
|
<template v-if="isMultiValued(field)">
|
||||||
|
<div class="multi-value-editor">
|
||||||
|
<div v-for="(val, idx) in getMultiValueEntries(field)" :key="idx" class="multi-value-entry">
|
||||||
|
<input
|
||||||
|
:value="val"
|
||||||
|
@input="updateMultiValueEntry(field, idx, $event.target.value)"
|
||||||
|
:placeholder="trans('prompts.artistName','Artist name')"
|
||||||
|
type="text"
|
||||||
|
class="multi-value-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--flat button--red icon-button mini-button"
|
||||||
|
:aria-label="trans('buttons.removeEntry','Remove')"
|
||||||
|
:title="trans('buttons.removeEntry','Remove')"
|
||||||
|
@click="removeMultiValueEntry(field, idx)"
|
||||||
|
>
|
||||||
|
<i class="material-icons">close</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--flat button--small add-entry-btn"
|
||||||
|
@click="addMultiValueEntry(field)"
|
||||||
|
>
|
||||||
|
<i class="material-icons">add</i>
|
||||||
|
{{ trans('buttons.addEntry','Add entry') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-model="newValues[field]"
|
||||||
|
:placeholder="placeholders[field] || ''"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--flat button--red icon-button"
|
||||||
|
:aria-label="trans('buttons.remove','Remove field')"
|
||||||
|
:title="trans('buttons.remove','Remove field')"
|
||||||
|
@click="removeField(field)"
|
||||||
|
>
|
||||||
|
<i class="material-icons">delete</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-action">
|
||||||
|
<button
|
||||||
|
id="focus-prompt"
|
||||||
|
type="button"
|
||||||
|
@click="closeHovers"
|
||||||
|
class="button button--flat"
|
||||||
|
:aria-label="trans('buttons.ok','OK')"
|
||||||
|
:title="trans('buttons.ok','OK')"
|
||||||
|
>
|
||||||
|
{{ trans('buttons.ok','OK') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="apply-metadata"
|
||||||
|
type="button"
|
||||||
|
@click.prevent="applyAll"
|
||||||
|
class="button button--primary"
|
||||||
|
:aria-label="trans('buttons.apply','Apply')"
|
||||||
|
:title="trans('buttons.apply','Apply')"
|
||||||
|
>
|
||||||
|
{{ trans('buttons.apply','Apply') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapActions, mapState } from "pinia";
|
||||||
|
import { useFileStore } from "@/stores/file";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { metadata as apiMetadata, updateMetadata } from "@/api/files";
|
||||||
|
import { fieldLabelFor, normalizeChanges, canonicalizeKeys, canonicalizeKey, navidromeTags, isMultiValuedTag, isMultiValuedKey, splitMultiValues, normalizeMixedChanges, formatMultiValueDisplay, parseMultiValueInput } from "@/utils/metadata";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "modifyMetadata",
|
||||||
|
inject: ["$showError"],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// fields will be derived dynamically from metadata across selected files
|
||||||
|
fields: [],
|
||||||
|
fieldLabels: {},
|
||||||
|
placeholders: {},
|
||||||
|
metadataList: [],
|
||||||
|
newValues: {},
|
||||||
|
toClear: new Set(),
|
||||||
|
// add field UI state
|
||||||
|
addFieldVisible: false,
|
||||||
|
addFieldKey: "",
|
||||||
|
addFieldValue: "",
|
||||||
|
tagSuggestions: navidromeTags,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(useFileStore, ["req", "selected", "selectedCount"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
|
fieldLabel(key) {
|
||||||
|
return fieldLabelFor(key);
|
||||||
|
},
|
||||||
|
async mountedFetch() {
|
||||||
|
if (!this.req) return;
|
||||||
|
|
||||||
|
const files = this.selected.map((i) => this.req.items[i].url);
|
||||||
|
|
||||||
|
// Fetch metadata for each selected file if possible
|
||||||
|
try {
|
||||||
|
const promises = files.map((u) => apiMetadata(u));
|
||||||
|
this.metadataList = (await Promise.all(promises)).map((m) => m || {});
|
||||||
|
} catch (e) {
|
||||||
|
this.metadataList = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build union of fields
|
||||||
|
const keys = new Set();
|
||||||
|
this.metadataList.forEach((m) => {
|
||||||
|
Object.keys(m).forEach((k) => keys.add(k));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure stable ordering: put common fields first (including multi-valued artists/albumartists)
|
||||||
|
const preferred = ["title", "artist", "artists", "album", "track", "genre", "date", "comment", "disc", "composer", "year", "albumartist", "albumartists", "picture", "cover"];
|
||||||
|
const rest = Array.from(keys).filter((k) => !preferred.includes(k)).sort();
|
||||||
|
this.fields = preferred.filter((k) => keys.has(k)).concat(rest);
|
||||||
|
|
||||||
|
// Setup field labels (use translation when available, otherwise humanize)
|
||||||
|
// For multi-valued fields, initialize newValues appropriately
|
||||||
|
this.fields.forEach((f) => {
|
||||||
|
this.fieldLabels[f] = this.trans(`prompts.${f}`, this.humanize(f));
|
||||||
|
if (isMultiValuedKey(f)) {
|
||||||
|
// Initialize with current values or empty array
|
||||||
|
const current = this.getCurrentMultiValues(f);
|
||||||
|
this.newValues[f] = current.length > 0 ? [...current] : [];
|
||||||
|
} else {
|
||||||
|
this.newValues[f] = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Get current multi-values directly from metadataList (for initialization)
|
||||||
|
getCurrentMultiValues(field) {
|
||||||
|
if (!this.metadataList || this.metadataList.length === 0) return [];
|
||||||
|
const allValues = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const m of this.metadataList) {
|
||||||
|
const v = m[field];
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
for (const item of v) {
|
||||||
|
const s = String(item || '').trim();
|
||||||
|
if (s && !seen.has(s)) {
|
||||||
|
allValues.push(s);
|
||||||
|
seen.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (v !== undefined && v !== null) {
|
||||||
|
const s = String(v).trim();
|
||||||
|
if (s && !seen.has(s)) {
|
||||||
|
allValues.push(s);
|
||||||
|
seen.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allValues;
|
||||||
|
},
|
||||||
|
toggleAddField() {
|
||||||
|
this.addFieldVisible = !this.addFieldVisible;
|
||||||
|
if (this.addFieldVisible) {
|
||||||
|
this.addFieldKey = "";
|
||||||
|
this.addFieldValue = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelAddField() {
|
||||||
|
this.addFieldVisible = false;
|
||||||
|
this.addFieldKey = "";
|
||||||
|
this.addFieldValue = "";
|
||||||
|
},
|
||||||
|
confirmAddField() {
|
||||||
|
const key = String(this.addFieldKey || "").trim();
|
||||||
|
const val = String(this.addFieldValue || "").trim();
|
||||||
|
if (!key) return;
|
||||||
|
// If field doesn't exist yet, add it to the top for visibility
|
||||||
|
if (!this.fields.includes(key)) {
|
||||||
|
this.fields.unshift(key);
|
||||||
|
// keep labels in English technical form
|
||||||
|
// label is computed via fieldLabel()
|
||||||
|
}
|
||||||
|
// Initialize newValues entry and set initial value if provided
|
||||||
|
if (isMultiValuedTag(canonicalizeKey(key))) {
|
||||||
|
// For multi-valued tags, initialize as array
|
||||||
|
if (val) {
|
||||||
|
this.newValues[key] = splitMultiValues(val);
|
||||||
|
} else {
|
||||||
|
this.newValues[key] = [''];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!(key in this.newValues)) this.newValues[key] = "";
|
||||||
|
if (val !== "") this.newValues[key] = val;
|
||||||
|
}
|
||||||
|
this.cancelAddField();
|
||||||
|
},
|
||||||
|
// Check if a field is multi-valued (ARTISTS, ALBUMARTISTS)
|
||||||
|
isMultiValued(field) {
|
||||||
|
return isMultiValuedKey(field);
|
||||||
|
},
|
||||||
|
// Get current values as array for multi-valued display
|
||||||
|
displayCurrentArray(field) {
|
||||||
|
return this.getCurrentMultiValues(field);
|
||||||
|
},
|
||||||
|
// Get entries for editing multi-valued field
|
||||||
|
getMultiValueEntries(field) {
|
||||||
|
const val = this.newValues[field];
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
// Initialize from current if not yet edited
|
||||||
|
if (!val || val === '') {
|
||||||
|
const current = this.getCurrentMultiValues(field);
|
||||||
|
if (current.length > 0) {
|
||||||
|
this.newValues[field] = [...current];
|
||||||
|
return this.newValues[field];
|
||||||
|
}
|
||||||
|
// Return empty array with one entry for new field
|
||||||
|
this.newValues[field] = [''];
|
||||||
|
return this.newValues[field];
|
||||||
|
}
|
||||||
|
// Handle legacy string format
|
||||||
|
const arr = splitMultiValues(String(val));
|
||||||
|
this.newValues[field] = arr.length > 0 ? arr : [''];
|
||||||
|
return this.newValues[field];
|
||||||
|
},
|
||||||
|
// Update a single entry in a multi-valued field
|
||||||
|
updateMultiValueEntry(field, index, value) {
|
||||||
|
if (!Array.isArray(this.newValues[field])) {
|
||||||
|
this.newValues[field] = this.getMultiValueEntries(field);
|
||||||
|
}
|
||||||
|
this.newValues[field][index] = value;
|
||||||
|
},
|
||||||
|
// Remove an entry from a multi-valued field
|
||||||
|
removeMultiValueEntry(field, index) {
|
||||||
|
if (!Array.isArray(this.newValues[field])) {
|
||||||
|
this.newValues[field] = this.getMultiValueEntries(field);
|
||||||
|
}
|
||||||
|
this.newValues[field].splice(index, 1);
|
||||||
|
// Keep at least one empty entry for UX
|
||||||
|
if (this.newValues[field].length === 0) {
|
||||||
|
this.newValues[field] = [''];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Add a new entry to a multi-valued field
|
||||||
|
addMultiValueEntry(field) {
|
||||||
|
if (!Array.isArray(this.newValues[field])) {
|
||||||
|
this.newValues[field] = this.getMultiValueEntries(field);
|
||||||
|
}
|
||||||
|
this.newValues[field].push('');
|
||||||
|
},
|
||||||
|
removeField(field) {
|
||||||
|
// Remove an added or existing field from the edit list (UI only)
|
||||||
|
this.fields = this.fields.filter((f) => f !== field);
|
||||||
|
delete this.newValues[field];
|
||||||
|
delete this.placeholders[field];
|
||||||
|
delete this.fieldLabels[field];
|
||||||
|
// Mark field for clearing on apply
|
||||||
|
const canon = canonicalizeKey(field);
|
||||||
|
this.toClear.add(canon);
|
||||||
|
},
|
||||||
|
displayCurrent(field) {
|
||||||
|
if (!this.metadataList || this.metadataList.length === 0) return "";
|
||||||
|
|
||||||
|
const vals = this.metadataList.map((m) => {
|
||||||
|
const v = m[field];
|
||||||
|
if (v === undefined || v === null) return "";
|
||||||
|
if (typeof v === "object") return JSON.stringify(v);
|
||||||
|
return String(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nonEmpty = vals.filter((v) => v !== "");
|
||||||
|
if (nonEmpty.length === 0) return "";
|
||||||
|
|
||||||
|
const allEqual = nonEmpty.every((v) => v === nonEmpty[0]);
|
||||||
|
if (allEqual) return nonEmpty[0];
|
||||||
|
|
||||||
|
// Count how many distinct values
|
||||||
|
const distinct = new Set(nonEmpty);
|
||||||
|
// If fields are objects (e.g., picture) avoid returning huge JSON blobs.
|
||||||
|
// Prefer a short summary.
|
||||||
|
const sample = nonEmpty[0];
|
||||||
|
if (typeof sample === 'string') {
|
||||||
|
return this.trans('prompts.multipleValuesCount', `(different on ${distinct.size} files)`).replace('{count}', String(distinct.size));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s = JSON.stringify(sample);
|
||||||
|
return s.length > 200 ? s.slice(0, 200) + '…' : s;
|
||||||
|
} catch (e) {
|
||||||
|
return this.trans('prompts.multipleValuesCount', `(different on ${distinct.size} files)`).replace('{count}', String(distinct.size));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isPicture(field) {
|
||||||
|
if (!this.metadataList || this.metadataList.length === 0) return false;
|
||||||
|
for (const m of this.metadataList) {
|
||||||
|
const v = m[field];
|
||||||
|
if (v && typeof v === 'object') {
|
||||||
|
if (v.data && typeof v.data === 'string') return true;
|
||||||
|
if (v.picture && v.picture.data) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
pictureSrc(field) {
|
||||||
|
if (!this.metadataList) return null;
|
||||||
|
for (const m of this.metadataList) {
|
||||||
|
const v = m[field];
|
||||||
|
if (v && typeof v === 'object') {
|
||||||
|
if (v.data && typeof v.data === 'string') return v.data;
|
||||||
|
if (v.picture && v.picture.data) return v.picture.data;
|
||||||
|
// sometimes library stores a raw buffer as base64 directly in 'data'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
trans(key, fallback) {
|
||||||
|
// Fallback to a human readable string if translation key is missing
|
||||||
|
try {
|
||||||
|
if (this.$te && this.$te(key)) return this.$t(key);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
},
|
||||||
|
|
||||||
|
humanize(str) {
|
||||||
|
if (!str) return str;
|
||||||
|
// replace camelCase / snake_case / dots with spaces and capitalize
|
||||||
|
const s = String(str)
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/[_\.\-]+/g, " ");
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyAll() {
|
||||||
|
if (!this.req) return;
|
||||||
|
|
||||||
|
// collect changed fields
|
||||||
|
const changes = {};
|
||||||
|
Object.keys(this.newValues).forEach((k) => {
|
||||||
|
const v = this.newValues[k];
|
||||||
|
const canon = canonicalizeKey(k);
|
||||||
|
if (isMultiValuedTag(canon)) {
|
||||||
|
// For multi-valued tags, filter out empty entries
|
||||||
|
let arr = Array.isArray(v) ? v : splitMultiValues(String(v || ""));
|
||||||
|
arr = arr.map(s => String(s || '').trim()).filter(Boolean);
|
||||||
|
if (arr.length > 0) changes[canon] = arr;
|
||||||
|
} else if (v !== undefined && v !== null && String(v).trim() !== "") {
|
||||||
|
changes[canon] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no value changes but some fields were removed, proceed
|
||||||
|
const hasValueChanges = Object.keys(changes).length > 0;
|
||||||
|
const hasClears = this.toClear && this.toClear.size > 0;
|
||||||
|
if (!hasValueChanges && !hasClears) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = this.selected.map((i) => this.req.items[i].url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// apply changes to each file
|
||||||
|
// Normalize keys on client before sending
|
||||||
|
const toSend = normalizeMixedChanges(changes);
|
||||||
|
// Attach clear list (canonicalized) so backend can remove tags
|
||||||
|
toSend.__clear__ = canonicalizeKeys(Array.from(this.toClear));
|
||||||
|
await Promise.all(files.map((u) => updateMetadata(u, toSend)));
|
||||||
|
// refresh listing
|
||||||
|
const fileStore = useFileStore();
|
||||||
|
fileStore.reload = true;
|
||||||
|
this.closeHovers();
|
||||||
|
this.toClear = new Set();
|
||||||
|
} catch (e) {
|
||||||
|
this.$showError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// per-field apply removed in favor of global applyAll
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.mountedFetch();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.metadata-card {
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* prefer a wider layout on large screens but stay responsive */
|
||||||
|
/* increase target width to give more horizontal space */
|
||||||
|
width: min(1400px, 98vw);
|
||||||
|
max-width: 98vw;
|
||||||
|
min-width: 680px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-with-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.title-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-field-row {
|
||||||
|
padding: 0 1rem 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
.add-field-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.add-field-controls input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override global small max-width for floating cards for this metadata modal */
|
||||||
|
.card.floating.metadata-card {
|
||||||
|
/* remove the global max-width:25em constraint */
|
||||||
|
max-width: none !important;
|
||||||
|
width: min(1400px, 98vw) !important;
|
||||||
|
left: 50% !important;
|
||||||
|
transform: translate(-50%, -50%) !important;
|
||||||
|
}
|
||||||
|
.metadata-field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.metadata-title {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.metadata-current {
|
||||||
|
color: var(--color-muted, #666);
|
||||||
|
margin: 0.25rem 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
.metadata-edit {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.metadata-edit input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-with-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
padding: 0.25em 0.4em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.icon-button .material-icons {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 1rem; /* add horizontal padding so left column isn't flush to modal edge */
|
||||||
|
}
|
||||||
|
.metadata-table {
|
||||||
|
border-collapse: separate;
|
||||||
|
width: auto; /* allow table to size to content */
|
||||||
|
/* allow a comfortable minimum width for readable columns on very large screens
|
||||||
|
but let it shrink responsively in smaller viewports */
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
table-layout: auto;
|
||||||
|
border-spacing: 0;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.metadata-table td, .metadata-table th { word-break: break-word; }
|
||||||
|
.metadata-table th,
|
||||||
|
.metadata-table td {
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header styling: subtle background and divider */
|
||||||
|
.metadata-table thead {
|
||||||
|
background: rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.metadata-table thead th {
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* extra left padding for the first column so labels don't touch the modal edge */
|
||||||
|
.metadata-table td:first-child,
|
||||||
|
.metadata-table th:first-child,
|
||||||
|
.metadata-title {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use CSS Grid per row so columns adapt to content without one taking all space */
|
||||||
|
.metadata-table thead tr,
|
||||||
|
.metadata-table tbody tr {
|
||||||
|
display: grid;
|
||||||
|
/* Fix column widths to prevent jitter when a cell contains very long content.
|
||||||
|
First column (labels) is fixed; current/new columns are flexible with a comfortable minimum. */
|
||||||
|
grid-template-columns: 220px minmax(280px, 1fr) minmax(280px, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.metadata-table thead tr {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.metadata-table thead th,
|
||||||
|
.metadata-table tbody td {
|
||||||
|
display: block; /* make cells behave as grid items */
|
||||||
|
}
|
||||||
|
.metadata-table thead th { font-weight: 600; }
|
||||||
|
|
||||||
|
/* column sizing helpers */
|
||||||
|
.metadata-table .col-label { width: 1%; }
|
||||||
|
.metadata-table .col-current { width: auto; }
|
||||||
|
.metadata-table .col-new { width: auto; }
|
||||||
|
.metadata-edit-cell input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.12);
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure long labels wrap inside the first column without expanding it */
|
||||||
|
.metadata-title strong {
|
||||||
|
display: block;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row aesthetics: zebra striping and hover */
|
||||||
|
.metadata-table tbody tr {
|
||||||
|
border-top: 1px solid rgba(0,0,0,0.06);
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.metadata-table tbody tr:nth-child(odd) {
|
||||||
|
background: rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
.metadata-table tbody tr:hover {
|
||||||
|
background: rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle vertical separators between columns */
|
||||||
|
.metadata-table tbody td:not(:last-child) {
|
||||||
|
border-right: 1px solid rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On small screens, stack rows to improve readability */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.metadata-table {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.metadata-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.metadata-table tr {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.metadata-table td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.metadata-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On medium screens allow a comfortable min width; on very large screens keep roomy layout */
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.metadata-table {
|
||||||
|
min-width: 100ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make sure table cells wrap content and images don't overflow */
|
||||||
|
.metadata-table td, .metadata-table th {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
.metadata-table img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.cover-preview {
|
||||||
|
max-width: 160px;
|
||||||
|
max-height: 160px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.metadata-muted { color: var(--color-muted, #666); font-size: 0.9em; }
|
||||||
|
.metadata-current-row td {
|
||||||
|
color: var(--color-muted, #666);
|
||||||
|
}
|
||||||
|
.metadata-edit-row td {
|
||||||
|
background: rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-valued tag styling */
|
||||||
|
.multi-value-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--color-muted, #888);
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-value-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-value-item {
|
||||||
|
background: rgba(0,0,0,0.04);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-value-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-value-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-value-input {
|
||||||
|
flex: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.12);
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-button {
|
||||||
|
padding: 0.15em 0.25em !important;
|
||||||
|
min-width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-button .material-icons {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-entry-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-entry-btn .material-icons {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure multi-value editor aligns well in the edit cell */
|
||||||
|
.edit-with-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-with-actions > .multi-value-editor {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-with-actions > .icon-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
<p>{{ $t("prompts.moveMessage") }}</p>
|
||||||
<file-list
|
<file-list
|
||||||
ref="fileList"
|
ref="fileList"
|
||||||
@update:selected="(val) => (dest = val)"
|
@update:selected="(val) => (dest = val)"
|
||||||
@@ -78,7 +79,7 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapState(useFileStore, ["req", "selected"]),
|
...mapState(useFileStore, ["req", "selected"]),
|
||||||
...mapState(useAuthStore, ["user"]),
|
...mapState(useAuthStore, ["user"]),
|
||||||
...mapWritableState(useFileStore, ["preselect"]),
|
...mapWritableState(useFileStore, ["reload", "preselect"]),
|
||||||
excludedFolders() {
|
excludedFolders() {
|
||||||
return this.selected
|
return this.selected
|
||||||
.filter((idx) => this.req.items[idx].isDir)
|
.filter((idx) => this.req.items[idx].isDir)
|
||||||
@@ -107,7 +108,9 @@ export default {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
buttons.success("move");
|
buttons.success("move");
|
||||||
this.preselect = removePrefix(items[0].to);
|
this.preselect = removePrefix(items[0].to);
|
||||||
this.$router.push({ path: this.dest });
|
if (this.user.redirectAfterCopyMove)
|
||||||
|
this.$router.push({ path: this.dest });
|
||||||
|
else this.reload = true;
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
buttons.done("move");
|
buttons.done("move");
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
v-model.trim="name"
|
v-model.trim="name"
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
/>
|
/>
|
||||||
|
<CreateFilePath :name="name" :is-dir="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-action">
|
<div class="card-action">
|
||||||
@@ -48,6 +49,7 @@ import { files as api } from "@/api";
|
|||||||
import url from "@/utils/url";
|
import url from "@/utils/url";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import CreateFilePath from "@/components/prompts/CreateFilePath.vue";
|
||||||
|
|
||||||
const $showError = inject<IToastError>("$showError")!;
|
const $showError = inject<IToastError>("$showError")!;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
@keyup.enter="submit"
|
@keyup.enter="submit"
|
||||||
v-model.trim="name"
|
v-model.trim="name"
|
||||||
/>
|
/>
|
||||||
|
<CreateFilePath :name="name" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-action">
|
<div class="card-action">
|
||||||
@@ -42,6 +43,7 @@ import { useI18n } from "vue-i18n";
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useFileStore } from "@/stores/file";
|
import { useFileStore } from "@/stores/file";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import CreateFilePath from "@/components/prompts/CreateFilePath.vue";
|
||||||
|
|
||||||
import { files as api } from "@/api";
|
import { files as api } from "@/api";
|
||||||
import url from "@/utils/url";
|
import url from "@/utils/url";
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import Share from "./Share.vue";
|
|||||||
import ShareDelete from "./ShareDelete.vue";
|
import ShareDelete from "./ShareDelete.vue";
|
||||||
import Upload from "./Upload.vue";
|
import Upload from "./Upload.vue";
|
||||||
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
|
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
|
||||||
|
import ModifyMetadata from "./ModifyMetadata.vue";
|
||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ const components = new Map<string, any>([
|
|||||||
["replace-rename", ReplaceRename],
|
["replace-rename", ReplaceRename],
|
||||||
["share", Share],
|
["share", Share],
|
||||||
["upload", Upload],
|
["upload", Upload],
|
||||||
|
["modifyMetadata", ModifyMetadata],
|
||||||
["share-delete", ShareDelete],
|
["share-delete", ShareDelete],
|
||||||
["deleteUser", DeleteUser],
|
["deleteUser", DeleteUser],
|
||||||
["discardEditorChanges", DiscardEditorChanges],
|
["discardEditorChanges", DiscardEditorChanges],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<th>{{ $t("settings.shareDuration") }}</th>
|
<th>{{ $t("settings.shareDuration") }}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th></th>
|
<th></th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr v-for="link in links" :key="link.hash">
|
<tr v-for="link in links" :key="link.hash">
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="small">
|
<td class="small">
|
||||||
<button
|
<button
|
||||||
class="action copy-clipboard"
|
class="action"
|
||||||
:aria-label="$t('buttons.copyToClipboard')"
|
:aria-label="$t('buttons.copyToClipboard')"
|
||||||
:title="$t('buttons.copyToClipboard')"
|
:title="$t('buttons.copyToClipboard')"
|
||||||
@click="copyToClipboard(buildLink(link))"
|
@click="copyToClipboard(buildLink(link))"
|
||||||
@@ -32,6 +33,17 @@
|
|||||||
<i class="material-icons">content_paste</i>
|
<i class="material-icons">content_paste</i>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
<button
|
||||||
|
class="action"
|
||||||
|
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
|
||||||
|
:title="$t('buttons.copyDownloadLinkToClipboard')"
|
||||||
|
:disabled="!!link.password_hash"
|
||||||
|
@click="copyToClipboard(buildDownloadLink(link))"
|
||||||
|
>
|
||||||
|
<i class="material-icons">content_paste_go</i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td class="small">
|
<td class="small">
|
||||||
<button
|
<button
|
||||||
class="action"
|
class="action"
|
||||||
@@ -132,7 +144,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapActions, mapState } from "pinia";
|
import { mapActions, mapState } from "pinia";
|
||||||
import { useFileStore } from "@/stores/file";
|
import { useFileStore } from "@/stores/file";
|
||||||
import { share as api } from "@/api";
|
import * as api from "@/api/index";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
import { copy } from "@/utils/clipboard";
|
import { copy } from "@/utils/clipboard";
|
||||||
@@ -172,7 +184,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async beforeMount() {
|
async beforeMount() {
|
||||||
try {
|
try {
|
||||||
const links = await api.get(this.url);
|
const links = await api.share.get(this.url);
|
||||||
this.links = links;
|
this.links = links;
|
||||||
this.sort();
|
this.sort();
|
||||||
|
|
||||||
@@ -211,9 +223,14 @@ export default {
|
|||||||
let res = null;
|
let res = null;
|
||||||
|
|
||||||
if (!this.time) {
|
if (!this.time) {
|
||||||
res = await api.create(this.url, this.password);
|
res = await api.share.create(this.url, this.password);
|
||||||
} else {
|
} else {
|
||||||
res = await api.create(this.url, this.password, this.time, this.unit);
|
res = await api.share.create(
|
||||||
|
this.url,
|
||||||
|
this.password,
|
||||||
|
this.time,
|
||||||
|
this.unit
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.links.push(res);
|
this.links.push(res);
|
||||||
@@ -231,7 +248,7 @@ export default {
|
|||||||
deleteLink: async function (event, link) {
|
deleteLink: async function (event, link) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
await api.remove(link.hash);
|
await api.share.remove(link.hash);
|
||||||
this.links = this.links.filter((item) => item.hash !== link.hash);
|
this.links = this.links.filter((item) => item.hash !== link.hash);
|
||||||
|
|
||||||
if (this.links.length == 0) {
|
if (this.links.length == 0) {
|
||||||
@@ -245,7 +262,16 @@ export default {
|
|||||||
return dayjs(time * 1000).fromNow();
|
return dayjs(time * 1000).fromNow();
|
||||||
},
|
},
|
||||||
buildLink(share) {
|
buildLink(share) {
|
||||||
return api.getShareURL(share);
|
return api.share.getShareURL(share);
|
||||||
|
},
|
||||||
|
buildDownloadLink(share) {
|
||||||
|
return api.pub.getDownloadURL(
|
||||||
|
{
|
||||||
|
hash: share.hash,
|
||||||
|
path: "",
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
},
|
},
|
||||||
sort() {
|
sort() {
|
||||||
this.links = this.links.sort((a, b) => {
|
this.links = this.links.sort((a, b) => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
}}
|
}}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="upload-info">
|
<div class="upload-info">
|
||||||
<div class="upload-speed">{{ speedMbytes }}/s</div>
|
<div class="upload-speed">{{ speedText }}/s</div>
|
||||||
<div class="upload-eta">{{ formattedETA }} remaining</div>
|
<div class="upload-eta">{{ formattedETA }} remaining</div>
|
||||||
<div class="upload-percentage">{{ sentPercent }}% Completed</div>
|
<div class="upload-percentage">{{ sentPercent }}% Completed</div>
|
||||||
<div class="upload-fraction">
|
<div class="upload-fraction">
|
||||||
@@ -88,6 +88,7 @@ const uploadStore = useUploadStore();
|
|||||||
const { sentBytes, totalBytes } = storeToRefs(uploadStore);
|
const { sentBytes, totalBytes } = storeToRefs(uploadStore);
|
||||||
|
|
||||||
const byteToMbyte = partial({ exponent: 2 });
|
const byteToMbyte = partial({ exponent: 2 });
|
||||||
|
const byteToKbyte = partial({ exponent: 1 });
|
||||||
|
|
||||||
const sentPercent = computed(() =>
|
const sentPercent = computed(() =>
|
||||||
((uploadStore.sentBytes / uploadStore.totalBytes) * 100).toFixed(2)
|
((uploadStore.sentBytes / uploadStore.totalBytes) * 100).toFixed(2)
|
||||||
@@ -95,11 +96,33 @@ const sentPercent = computed(() =>
|
|||||||
|
|
||||||
const sentMbytes = computed(() => byteToMbyte(uploadStore.sentBytes));
|
const sentMbytes = computed(() => byteToMbyte(uploadStore.sentBytes));
|
||||||
const totalMbytes = computed(() => byteToMbyte(uploadStore.totalBytes));
|
const totalMbytes = computed(() => byteToMbyte(uploadStore.totalBytes));
|
||||||
const speedMbytes = computed(() => byteToMbyte(speed.value));
|
const speedText = computed(() => {
|
||||||
|
const bytes = speed.value;
|
||||||
|
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
const kb = parseFloat(byteToKbyte(bytes));
|
||||||
|
return `${kb.toFixed(2)} KB`;
|
||||||
|
} else {
|
||||||
|
const mb = parseFloat(byteToMbyte(bytes));
|
||||||
|
return `${mb.toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let lastSpeedUpdate: number = 0;
|
let lastSpeedUpdate: number = 0;
|
||||||
let recentSpeeds: number[] = [];
|
let recentSpeeds: number[] = [];
|
||||||
|
|
||||||
|
let lastThrottleTime = 0;
|
||||||
|
|
||||||
|
const throttledCalculateSpeed = (sentBytes: number, oldSentBytes: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastThrottleTime < 100) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastThrottleTime = now;
|
||||||
|
calculateSpeed(sentBytes, oldSentBytes);
|
||||||
|
};
|
||||||
|
|
||||||
const calculateSpeed = (sentBytes: number, oldSentBytes: number) => {
|
const calculateSpeed = (sentBytes: number, oldSentBytes: number) => {
|
||||||
// Reset the state when the uploads batch is complete
|
// Reset the state when the uploads batch is complete
|
||||||
if (sentBytes === 0) {
|
if (sentBytes === 0) {
|
||||||
@@ -149,7 +172,7 @@ const calculateEta = () => {
|
|||||||
eta.value = remainingSize / speedBytesPerSecond;
|
eta.value = remainingSize / speedBytesPerSecond;
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(sentBytes, calculateSpeed);
|
watch(sentBytes, throttledCalculateSpeed);
|
||||||
|
|
||||||
watch(totalBytes, (totalBytes, oldTotalBytes) => {
|
watch(totalBytes, (totalBytes, oldTotalBytes) => {
|
||||||
if (oldTotalBytes !== 0) {
|
if (oldTotalBytes !== 0) {
|
||||||
|
|||||||
28
frontend/src/components/settings/AceEditorTheme.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<select
|
||||||
|
name="selectAceEditorTheme"
|
||||||
|
v-on:change="change"
|
||||||
|
:value="aceEditorTheme"
|
||||||
|
>
|
||||||
|
<option v-for="theme in themes" :value="theme.theme" :key="theme.theme">
|
||||||
|
{{ theme.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type SelectHTMLAttributes } from "vue";
|
||||||
|
import { themes } from "ace-builds/src-noconflict/ext-themelist";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
aceEditorTheme: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:aceEditorTheme", val: string | null): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const change = (event: Event) => {
|
||||||
|
emit("update:aceEditorTheme", (event.target as SelectHTMLAttributes)?.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -15,9 +15,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
const dataObj = {};
|
const dataObj = {};
|
||||||
const locales = {
|
const locales = {
|
||||||
he: "עברית",
|
|
||||||
hu: "Magyar",
|
|
||||||
ar: "العربية",
|
ar: "العربية",
|
||||||
|
bg: "български език",
|
||||||
ca: "Català",
|
ca: "Català",
|
||||||
cs: "Čeština",
|
cs: "Čeština",
|
||||||
de: "Deutsch",
|
de: "Deutsch",
|
||||||
@@ -25,15 +24,18 @@ export default {
|
|||||||
en: "English",
|
en: "English",
|
||||||
es: "Español",
|
es: "Español",
|
||||||
fr: "Français",
|
fr: "Français",
|
||||||
|
he: "עברית",
|
||||||
|
hr: "Hrvatski",
|
||||||
|
hu: "Magyar",
|
||||||
is: "Icelandic",
|
is: "Icelandic",
|
||||||
it: "Italiano",
|
it: "Italiano",
|
||||||
ja: "日本語",
|
ja: "日本語",
|
||||||
ko: "한국어",
|
ko: "한국어",
|
||||||
"nl-be": "Dutch (Belgium)",
|
|
||||||
no: "Norsk",
|
no: "Norsk",
|
||||||
|
"nl-be": "Dutch (Belgium)",
|
||||||
pl: "Polski",
|
pl: "Polski",
|
||||||
"pt-br": "Português",
|
"pt-br": "Português (Brasil)",
|
||||||
pt: "Português (Brasil)",
|
pt: "Português (Portugal)",
|
||||||
ro: "Romanian",
|
ro: "Romanian",
|
||||||
ru: "Русский",
|
ru: "Русский",
|
||||||
sk: "Slovenčina",
|
sk: "Slovenčina",
|
||||||
|
|||||||
@@ -185,3 +185,7 @@ html[dir="rtl"] .breadcrumbs a {
|
|||||||
.vfm-modal {
|
.vfm-modal {
|
||||||
z-index: 9999999 !important;
|
z-index: 9999999 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body > div[style*="z-index: 9990"] {
|
||||||
|
z-index: 10000 !important;
|
||||||
|
}
|
||||||
|
|||||||
21
frontend/src/css/context-menu.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
.context-menu {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--surfacePrimary);
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 220px;
|
||||||
|
border: 1px solid var(--borderSecondary);
|
||||||
|
box-shadow: 0 2px 4px var(--borderPrimary);
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu .action {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu .action .counter {
|
||||||
|
left: 1.75em;
|
||||||
|
}
|
||||||
@@ -63,8 +63,8 @@
|
|||||||
local("Roboto"),
|
local("Roboto"),
|
||||||
local("Roboto-Regular"),
|
local("Roboto-Regular"),
|
||||||
url(../assets/fonts/roboto/normal-latin-ext.woff2) format("woff2");
|
url(../assets/fonts/roboto/normal-latin-ext.woff2) format("woff2");
|
||||||
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF,
|
unicode-range:
|
||||||
U+2C60-2C7F, U+A720-A7FF;
|
U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
@@ -75,8 +75,9 @@
|
|||||||
local("Roboto"),
|
local("Roboto"),
|
||||||
local("Roboto-Regular"),
|
local("Roboto-Regular"),
|
||||||
url(../assets/fonts/roboto/normal-latin.woff2) format("woff2");
|
url(../assets/fonts/roboto/normal-latin.woff2) format("woff2");
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC,
|
unicode-range:
|
||||||
U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
|
U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F,
|
||||||
|
U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
@@ -142,8 +143,8 @@
|
|||||||
local("Roboto Medium"),
|
local("Roboto Medium"),
|
||||||
local("Roboto-Medium"),
|
local("Roboto-Medium"),
|
||||||
url(../assets/fonts/roboto/medium-latin-ext.woff2) format("woff2");
|
url(../assets/fonts/roboto/medium-latin-ext.woff2) format("woff2");
|
||||||
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF,
|
unicode-range:
|
||||||
U+2C60-2C7F, U+A720-A7FF;
|
U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
@@ -154,8 +155,9 @@
|
|||||||
local("Roboto Medium"),
|
local("Roboto Medium"),
|
||||||
local("Roboto-Medium"),
|
local("Roboto-Medium"),
|
||||||
url(../assets/fonts/roboto/medium-latin.woff2) format("woff2");
|
url(../assets/fonts/roboto/medium-latin.woff2) format("woff2");
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC,
|
unicode-range:
|
||||||
U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
|
U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F,
|
||||||
|
U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
@@ -221,8 +223,8 @@
|
|||||||
local("Roboto Bold"),
|
local("Roboto Bold"),
|
||||||
local("Roboto-Bold"),
|
local("Roboto-Bold"),
|
||||||
url(../assets/fonts/roboto/bold-latin-ext.woff2) format("woff2");
|
url(../assets/fonts/roboto/bold-latin-ext.woff2) format("woff2");
|
||||||
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF,
|
unicode-range:
|
||||||
U+2C60-2C7F, U+A720-A7FF;
|
U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
@@ -233,8 +235,9 @@
|
|||||||
local("Roboto Bold"),
|
local("Roboto Bold"),
|
||||||
local("Roboto-Bold"),
|
local("Roboto-Bold"),
|
||||||
url(../assets/fonts/roboto/bold-latin.woff2) format("woff2");
|
url(../assets/fonts/roboto/bold-latin.woff2) format("woff2");
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC,
|
unicode-range:
|
||||||
U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
|
U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F,
|
||||||
|
U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons {
|
.material-icons {
|
||||||
|
|||||||
@@ -45,6 +45,15 @@
|
|||||||
animation: 0.2s opac forwards;
|
animation: 0.2s opac forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#login .logout-message {
|
||||||
|
background: var(--icon-orange);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5em;
|
||||||
|
text-align: center;
|
||||||
|
animation: 0.2s opac forwards;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes opac {
|
@keyframes opac {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
.md_preview {
|
.md_preview {
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 80vh;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid #000;
|
border: 1px solid #000;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -9,5 +7,5 @@
|
|||||||
|
|
||||||
#preview-container {
|
#preview-container {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: 80vh; /* Match the max-height of md_preview for scrolling */
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
@import "./mobile.css";
|
@import "./mobile.css";
|
||||||
@import "./epubReader.css";
|
@import "./epubReader.css";
|
||||||
@import "./mdPreview.css";
|
@import "./mdPreview.css";
|
||||||
|
@import "./context-menu.css";
|
||||||
|
|
||||||
/* For testing only
|
/* For testing only
|
||||||
:focus {
|
:focus {
|
||||||
@@ -97,7 +98,8 @@ main .spinner .bounce2 {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action.disabled {
|
.action.disabled,
|
||||||
|
.action[disabled] {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||