Compare commits
326
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7efb2e6a | ||
|
|
2651260a1c | ||
|
|
4470288ba1 | ||
|
|
58d22578e8 | ||
|
|
dfc2e887e1 | ||
|
|
aac2516637 | ||
|
|
c05ead7e8e | ||
|
|
d76b7d1610 | ||
|
|
f30fca636c | ||
|
|
ec13054671 | ||
|
|
883a36f02f | ||
|
|
8503ba61ff | ||
|
|
1fb05d65de | ||
|
|
2472fbcd30 | ||
|
|
43a404ca69 | ||
|
|
d9cf2f0100 | ||
|
|
6209f8fddd | ||
|
|
bd1520fe09 | ||
|
|
8cfa6a175f | ||
|
|
a1063925e1 | ||
|
|
64511ce45e | ||
|
|
be23ab3a15 | ||
|
|
ffb486e05f | ||
|
|
403d2bbd33 | ||
|
|
dfe6e5b333 | ||
|
|
d9816b1531 | ||
|
|
7c2c0a11b3 | ||
|
|
3406d3d7f9 | ||
|
|
67ed670d92 | ||
|
|
a1a514dcbb | ||
|
|
5f7311d324 | ||
|
|
998bd95bfa | ||
|
|
c1abe8f561 | ||
|
|
0bb2768754 | ||
|
|
7b7ff8ae8f | ||
|
|
1086903c14 | ||
|
|
3471ec2c4b | ||
|
|
69c76d11cc | ||
|
|
6b04cbf5e9 | ||
|
|
503fd6b01f | ||
|
|
35db07d015 | ||
|
|
19514367ad | ||
|
|
cdd666fc95 | ||
|
|
103acd15fe | ||
|
|
f5e0c4e2e1 | ||
|
|
ca019ae7d9 | ||
|
|
4488f5b131 | ||
|
|
166583db63 | ||
|
|
85b7d2762d | ||
|
|
4edabb9ccc | ||
|
|
103683069e | ||
|
|
5328e80d2e | ||
|
|
847d08bdd1 | ||
|
|
0231b7ebdf | ||
|
|
e07c59df0b | ||
|
|
0d3eb9bea9 | ||
|
|
34ae34e764 | ||
|
|
ca0108f070 | ||
|
|
a1e442ef9e | ||
|
|
6ad8160aa3 | ||
|
|
22b848f26e | ||
|
|
a418dd6bb3 | ||
|
|
d978d1ecca | ||
|
|
9cc18a81e3 | ||
|
|
e38c28273a | ||
|
|
da2dff0933 | ||
|
|
261bca67bc | ||
|
|
ca5e249e3c | ||
|
|
f4e148523e | ||
|
|
1f22fe65ec | ||
|
|
e2bdf6f747 | ||
|
|
d236f1c563 | ||
|
|
4edf425a9f | ||
|
|
a1a7ac4f04 | ||
|
|
74917c8037 | ||
|
|
7bd27f5e82 | ||
|
|
9f4288b1b4 | ||
|
|
41b801d30c | ||
|
|
da6b7ac26b | ||
|
|
7f8b920aa4 | ||
|
|
dd53644acb | ||
|
|
9b80a9aa6c | ||
|
|
0321415a15 | ||
|
|
23e84c9974 | ||
|
|
0fadf28b18 | ||
|
|
871f337892 | ||
|
|
29c73eaca6 | ||
|
|
f13c7c8cff | ||
|
|
1e03feadb5 | ||
|
|
8adf127c7d | ||
|
|
7dbf7a3528 | ||
|
|
65a837de49 | ||
|
|
876cdb3426 | ||
|
|
2f805de527 | ||
|
|
7a16129bfc | ||
|
|
0f39bd055e | ||
|
|
860c19ddf5 | ||
|
|
a8fc1657b7 | ||
|
|
c406bda0c7 | ||
|
|
d9f9460c1e | ||
|
|
126227bb27 | ||
|
|
b6a4fb1f27 | ||
|
|
2368e468b9 | ||
|
|
8d8cd26d7f | ||
|
|
8f81b77cf2 | ||
|
|
0616f6811f | ||
|
|
432f3e60ff | ||
|
|
ae72f93667 | ||
|
|
4812536555 | ||
|
|
6d44b3ae1d | ||
|
|
811cf2dfe5 | ||
|
|
b5f970731b | ||
|
|
fc80f4f44c | ||
|
|
6aea227617 | ||
|
|
09a26166b4 | ||
|
|
a63573b67e | ||
|
|
4bd7d69c82 | ||
|
|
c21af0791a | ||
|
|
858eb42651 | ||
|
|
21709428b1 | ||
|
|
846fb3333a | ||
|
|
6f772f2b10 | ||
|
|
3c5d36673f | ||
|
|
27afbb8813 | ||
|
|
79875bac7f | ||
|
|
be8ba18937 | ||
|
|
ef2e9992dc | ||
|
|
0542fc0ba4 | ||
|
|
f04af0cac6 | ||
|
|
f5f8b60b33 | ||
|
|
6dcef07f40 | ||
|
|
6700a981a5 | ||
|
|
490e5bbf99 | ||
|
|
df63cb595c | ||
|
|
3cb21c727d | ||
|
|
c01b6a840c | ||
|
|
cbcf708d94 | ||
|
|
4d9e6b8218 | ||
|
|
8ee55761a1 | ||
|
|
8598db2acc | ||
|
|
a7dc7bf88a | ||
|
|
9f56826fb2 | ||
|
|
d7b00ce5f6 | ||
|
|
177c7cfcce | ||
|
|
4af3f85e64 | ||
|
|
c950a57df8 | ||
|
|
7ed1425115 | ||
|
|
148b3c5942 | ||
|
|
a40f80672d | ||
|
|
8dc618a24b | ||
|
|
31194fb57a | ||
|
|
aa809096eb | ||
|
|
e3d00d591b | ||
|
|
7b16e2de90 | ||
|
|
2470b9eb69 | ||
|
|
9940bdd663 | ||
|
|
200d501547 | ||
|
|
3169a14a4d | ||
|
|
7e78ad97ad | ||
|
|
0467326d5c | ||
|
|
63a76ef18c | ||
|
|
3cad9a2b0e | ||
|
|
804b14b698 | ||
|
|
95e6ed75a7 | ||
|
|
b09960e538 | ||
|
|
5e8f5be245 | ||
|
|
e5bc0d3cce | ||
|
|
f67bccf8c5 | ||
|
|
88b97def9e | ||
|
|
1f7904dad2 | ||
|
|
5feaf66575 | ||
|
|
c94870fcfe | ||
|
|
6a76dfeba9 | ||
|
|
e193d43278 | ||
|
|
489af403a1 | ||
|
|
ff2f00498c | ||
|
|
2957b4605b | ||
|
|
099dfb0ae8 | ||
|
|
a4289011ef | ||
|
|
942d59848a | ||
|
|
854e5371b6 | ||
|
|
b9df030e6e | ||
|
|
2b82612e3f | ||
|
|
08d7a1504c | ||
|
|
b8da36e630 | ||
|
|
a8fe32f4aa | ||
|
|
8fee2561af | ||
|
|
d441b28f43 | ||
|
|
87cfead033 | ||
|
|
3a08949c7f | ||
|
|
b594d4d4e2 | ||
|
|
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 |
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
Before submitting your PR, please indicate which issues are either fixed or closed by this PR. See [GitHub Help: Closing issues using keywords](https://help.github.com/articles/closing-issues-via-commit-messages/).
|
Before submitting your PR, please indicate which issues are either fixed or closed by this PR. See [GitHub Help: Closing issues using keywords](https://help.github.com/articles/closing-issues-via-commit-messages/).
|
||||||
|
|
||||||
- [ ] I am aware the project is currently in maintenance-only mode. See [README](https://github.com/filebrowser/community/blob/master/README.md)
|
- [ ] I am aware the project is currently in **maintenance-only** mode and new feature requests won't be accepted. See [README](https://github.com/filebrowser/community/blob/master/README.md)
|
||||||
- [ ] I am aware that translations MUST be made through [Transifex](https://app.transifex.com/file-browser/file-browser/) and that this PR is NOT a translation update
|
- [ ] I am aware that translations MUST be made through [Transifex](https://app.transifex.com/file-browser/file-browser/) and that this PR is NOT a translation update
|
||||||
- [ ] I am making a PR against the `master` branch.
|
- [ ] I am making a PR against the `master` branch.
|
||||||
- [ ] I am sure File Browser can be successfully built. See [builds](https://github.com/filebrowser/community/blob/master/builds.md) and [development](https://github.com/filebrowser/community/blob/master/development.md).
|
- [ ] I am sure File Browser can be successfully built. See [builds](https://github.com/filebrowser/community/blob/master/builds.md) and [development](https://github.com/filebrowser/community/blob/master/development.md).
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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@v6
|
||||||
|
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
|
||||||
|
|
||||||
|
test-frontend:
|
||||||
|
name: Test Frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
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 test
|
||||||
|
|
||||||
|
lint-backend:
|
||||||
|
name: Lint Backend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: "1.26.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.26.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.26'
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
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@v2
|
||||||
|
- 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.26'
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
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@v4
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
- name: Install Task
|
||||||
|
uses: go-task/setup-task@v2
|
||||||
|
- run: task build:frontend
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v7
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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@v4
|
||||||
|
- name: Install Task
|
||||||
|
uses: go-task/setup-task@v2
|
||||||
|
- 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@v4
|
||||||
|
- name: Install Task
|
||||||
|
uses: go-task/setup-task@v2
|
||||||
|
- name: Build site
|
||||||
|
run: task docs
|
||||||
|
- name: Upload static files as artifact
|
||||||
|
uses: actions/upload-pages-artifact@v5
|
||||||
|
with:
|
||||||
|
path: www/public
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
uses: actions/deploy-pages@v5
|
||||||
@@ -13,7 +13,7 @@ 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@v6
|
- uses: amannn/action-semantic-pull-request@v6
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: marocchino/sticky-pull-request-comment@v2
|
- uses: marocchino/sticky-pull-request-comment@v3
|
||||||
# When the previous steps fails, the workflow would stop. By adding this
|
# When the previous steps fails, the workflow would stop. By adding this
|
||||||
# condition you can continue the execution with the populated error message.
|
# condition you can continue the execution with the populated error message.
|
||||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
# Delete a previous comment when the issue has been resolved
|
# Delete a previous comment when the issue has been resolved
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
uses: marocchino/sticky-pull-request-comment@v3
|
||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
delete: true
|
delete: true
|
||||||
@@ -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@v5
|
|
||||||
- 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"
|
|
||||||
- run: make lint-frontend
|
|
||||||
lint-backend:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version: '1.25'
|
|
||||||
- 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@v5
|
|
||||||
- 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"
|
|
||||||
- run: make test-frontend
|
|
||||||
test-backend:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version: '1.25'
|
|
||||||
- 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@v5
|
|
||||||
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: Build frontend
|
|
||||||
run: make 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 }}
|
|
||||||
@@ -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@v5
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build site
|
|
||||||
run: make site
|
|
||||||
@@ -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@v5
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- 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 }}
|
|
||||||
+2
-120
@@ -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
|
||||||
|
|
||||||
|
|||||||
+525
@@ -2,6 +2,531 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
||||||
|
|
||||||
|
## [2.63.18](https://github.com/filebrowser/filebrowser/compare/v2.63.17...v2.63.18) (2026-07-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* avoid recursive conflict checks for copy and move ([#6009](https://github.com/filebrowser/filebrowser/issues/6009)) ([dfc2e88](https://github.com/filebrowser/filebrowser/commit/dfc2e887e1a19d54984a0d7e39a2a63caf73ef19))
|
||||||
|
* deduplicate PT language ([4470288](https://github.com/filebrowser/filebrowser/commit/4470288ba1828a14453a99348fd22c62aa0b9460))
|
||||||
|
* **preview:** keep the EPUB table-of-contents button clear of the header ([#6010](https://github.com/filebrowser/filebrowser/issues/6010)) ([aac2516](https://github.com/filebrowser/filebrowser/commit/aac25166378422135e624e305c410c54a39374fb))
|
||||||
|
|
||||||
|
## [2.63.17](https://github.com/filebrowser/filebrowser/compare/v2.63.16...v2.63.17) (2026-06-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **auth:** reject signup when normalized home dir collides (GHSA-7rc3-g7h6-22m7) ([883a36f](https://github.com/filebrowser/filebrowser/commit/883a36f02fcb69566a8628cb47f18fdc73348387))
|
||||||
|
* match admin share paths by owner scope ([#5992](https://github.com/filebrowser/filebrowser/issues/5992)) ([43a404c](https://github.com/filebrowser/filebrowser/commit/43a404ca69bf25553bfbbb2b446f0f53077c6302))
|
||||||
|
* normalize recursive listing paths to forward slashes ([#6003](https://github.com/filebrowser/filebrowser/issues/6003)) ([2472fbc](https://github.com/filebrowser/filebrowser/commit/2472fbcd30502606feb11fbc8b8dc4f3803e6641))
|
||||||
|
* preserve SRT subtitle line breaks ([#6002](https://github.com/filebrowser/filebrowser/issues/6002)) ([d9cf2f0](https://github.com/filebrowser/filebrowser/commit/d9cf2f0100d2c4892cad8e339eacca96df1aa5b6))
|
||||||
|
* **raw:** neutralize backslashes in archive entry names (GHSA-83xp-526h-j3ww) ([8503ba6](https://github.com/filebrowser/filebrowser/commit/8503ba61ff51d48a7313896483d130eb6a5abfe0))
|
||||||
|
* **share:** delete exact directory share on trailing-slash delete (GHSA-pp88-jhwj-5qh5) ([f30fca6](https://github.com/filebrowser/filebrowser/commit/f30fca636c1af9ef401e9a82ff60391cb3db97e1))
|
||||||
|
* **share:** stop exposing password hash and bypass token in share API (GHSA-833g-cqhp-h72j) ([ec13054](https://github.com/filebrowser/filebrowser/commit/ec130546713c44cd24556907552ac554c7f809c9))
|
||||||
|
|
||||||
|
## [2.63.16](https://github.com/filebrowser/filebrowser/compare/v2.63.15...v2.63.16) (2026-06-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* dangling symlink, write, delete scope bugs ([64511ce](https://github.com/filebrowser/filebrowser/commit/64511ce45e3be379e965f7f4fb0929a068d5bb81))
|
||||||
|
* restore symlink behavior as opt-in followExternalSymlinks ([a106392](https://github.com/filebrowser/filebrowser/commit/a1063925e15ef27f9d5dc26aae371bbf52af608c))
|
||||||
|
|
||||||
|
## [2.63.15](https://github.com/filebrowser/filebrowser/compare/v2.63.14...v2.63.15) (2026-06-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* restore ScopedFs RealPath ([#5986](https://github.com/filebrowser/filebrowser/issues/5986)) ([403d2bb](https://github.com/filebrowser/filebrowser/commit/403d2bbd3357aa57e78745b52dcdad3490901bb3))
|
||||||
|
|
||||||
|
## [2.63.14](https://github.com/filebrowser/filebrowser/compare/v2.63.13...v2.63.14) (2026-06-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* recursive check ([3406d3d](https://github.com/filebrowser/filebrowser/commit/3406d3d7f98dfc3c16e4ff7ff4a87e3bdfe221dd))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactorings
|
||||||
|
|
||||||
|
* ScopedFs to avoid escaping symlinks ([7c2c0a1](https://github.com/filebrowser/filebrowser/commit/7c2c0a11b31b2bb214d741005a0b02b1764208b3))
|
||||||
|
|
||||||
|
## [2.63.13](https://github.com/filebrowser/filebrowser/compare/v2.63.12...v2.63.13) (2026-06-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* copy/move allow overwrite ([a1a514d](https://github.com/filebrowser/filebrowser/commit/a1a514dcbb216d2080412c5354eea1e1fb033050))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactorings
|
||||||
|
|
||||||
|
* cleanup and simplify upload.ts ([5f7311d](https://github.com/filebrowser/filebrowser/commit/5f7311d32437e98d7c14c7b307a4f68109275535))
|
||||||
|
|
||||||
|
## [2.63.12](https://github.com/filebrowser/filebrowser/compare/v2.63.11...v2.63.12) (2026-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* await copy move conflict detection ([#5978](https://github.com/filebrowser/filebrowser/issues/5978)) ([c1abe8f](https://github.com/filebrowser/filebrowser/commit/c1abe8f561208bf36bde70879d1a15ef9de998fa))
|
||||||
|
* keep mobile file sort controls visible ([#5977](https://github.com/filebrowser/filebrowser/issues/5977)) ([0bb2768](https://github.com/filebrowser/filebrowser/commit/0bb2768754d11b865d68e72dcd7cebb232a6308a))
|
||||||
|
* skip inaccessible children when listing directories ([#5958](https://github.com/filebrowser/filebrowser/issues/5958)) ([7b7ff8a](https://github.com/filebrowser/filebrowser/commit/7b7ff8ae8f97393b2e6ae6e061c1f780077c32b6))
|
||||||
|
|
||||||
|
## [2.63.11](https://github.com/filebrowser/filebrowser/compare/v2.63.10...v2.63.11) (2026-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* incomplete fix for symlinked directories let scopes users and public-share recipients read and write files outside of scope ([3471ec2](https://github.com/filebrowser/filebrowser/commit/3471ec2c4b6473831c72ee889cb3c1a6849a1fb1))
|
||||||
|
|
||||||
|
## [2.63.10](https://github.com/filebrowser/filebrowser/compare/v2.63.9...v2.63.10) (2026-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow writes when user scope resolves to filesystem root ([6b04cbf](https://github.com/filebrowser/filebrowser/commit/6b04cbf5e9db1f5b9c0b1624843607ce2881cfc4))
|
||||||
|
|
||||||
|
## [2.63.9](https://github.com/filebrowser/filebrowser/compare/v2.63.8...v2.63.9) (2026-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* force octet-stream for attachment downloads ([#5942](https://github.com/filebrowser/filebrowser/issues/5942)) ([103acd1](https://github.com/filebrowser/filebrowser/commit/103acd15fe57554fe0246bfe70a49b6cb4ae0c51))
|
||||||
|
* prevent symlink scope escape in copy/move/rename ([cdd666f](https://github.com/filebrowser/filebrowser/commit/cdd666fc95f569ad583c32391e45646fed676dfd))
|
||||||
|
* set X-Content-Type-Options: nosniff on raw file responses ([35db07d](https://github.com/filebrowser/filebrowser/commit/35db07d0159c520a6b3c969ac52033593914fadd))
|
||||||
|
* use constant-time comparison for share access token ([1951436](https://github.com/filebrowser/filebrowser/commit/19514367adf2d9fe5be2b7666e397979ea679b94))
|
||||||
|
|
||||||
|
## [2.63.8](https://github.com/filebrowser/filebrowser/compare/v2.63.7...v2.63.8) (2026-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* check if share is within scope when creating ([ca019ae](https://github.com/filebrowser/filebrowser/commit/ca019ae7d966a7c28de2b2341271cd13e3458ae6))
|
||||||
|
|
||||||
|
## [2.63.7](https://github.com/filebrowser/filebrowser/compare/v2.63.6...v2.63.7) (2026-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* disallow shares for non-existent paths ([166583d](https://github.com/filebrowser/filebrowser/commit/166583db632e088e9f0adce30aec43bb9d9019f4))
|
||||||
|
|
||||||
|
## [2.63.6](https://github.com/filebrowser/filebrowser/compare/v2.63.5...v2.63.6) (2026-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* address three security disclosures (archive traversal, login DoS, symlink escape) ([847d08b](https://github.com/filebrowser/filebrowser/commit/847d08bdd135e5c3659f2e6dea2f0cd36617af9b))
|
||||||
|
* cross-user unauthorized share-link deletion ([0231b7e](https://github.com/filebrowser/filebrowser/commit/0231b7ebdfbe77a6c54027d30c4856c3fd81ee4d))
|
||||||
|
* incorrect access control in public directory shares via rule path rebasing ([e07c59d](https://github.com/filebrowser/filebrowser/commit/e07c59df0b850f5924d5b1683e8609661ddcf534))
|
||||||
|
* parse csv files with uneven columns in their rows ([#5965](https://github.com/filebrowser/filebrowser/issues/5965)) ([5328e80](https://github.com/filebrowser/filebrowser/commit/5328e80d2e88d1c279a1250a7dfee4fc96f703ec))
|
||||||
|
* remove undocumented hook auth with shell replacement ([34ae34e](https://github.com/filebrowser/filebrowser/commit/34ae34e764d72540c039f1f5ea2ec4c974168c1f))
|
||||||
|
|
||||||
|
## [2.63.5](https://github.com/filebrowser/filebrowser/compare/v2.63.4...v2.63.5) (2026-05-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **router:** handle undefined catchAll param on root redirect ([#5955](https://github.com/filebrowser/filebrowser/issues/5955)) ([6ad8160](https://github.com/filebrowser/filebrowser/commit/6ad8160aa3309314c1b471c5090b67c824464396))
|
||||||
|
|
||||||
|
## [2.63.4](https://github.com/filebrowser/filebrowser/compare/v2.63.3...v2.63.4) (2026-05-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* show item shares from all users to admins ([#5941](https://github.com/filebrowser/filebrowser/issues/5941)) ([9cc18a8](https://github.com/filebrowser/filebrowser/commit/9cc18a81e3e1b8bf96795bfbe3d83ced294ecfd7))
|
||||||
|
|
||||||
|
## [2.63.3](https://github.com/filebrowser/filebrowser/compare/v2.63.2...v2.63.3) (2026-05-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* correct environment variable in compose.yaml ([#5910](https://github.com/filebrowser/filebrowser/issues/5910)) ([41b801d](https://github.com/filebrowser/filebrowser/commit/41b801d30c736c8ca863e2be6aece7d99e92129e))
|
||||||
|
* Fix conflict modal and add a resume transfert option ([#5884](https://github.com/filebrowser/filebrowser/issues/5884)) ([f4e1485](https://github.com/filebrowser/filebrowser/commit/f4e148523e0dc9242081831b53544396f995c611))
|
||||||
|
|
||||||
|
## [2.63.2](https://github.com/filebrowser/filebrowser/compare/v2.63.1...v2.63.2) (2026-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **preview:** let arrow keys seek video instead of switching files ([#5895](https://github.com/filebrowser/filebrowser/issues/5895)) ([0fadf28](https://github.com/filebrowser/filebrowser/commit/0fadf28b18e506ddca0027e83ebe567ac57932bf))
|
||||||
|
|
||||||
|
## [2.63.1](https://github.com/filebrowser/filebrowser/compare/v2.63.0...v2.63.1) (2026-04-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* check download permission in resource handler ([#5891](https://github.com/filebrowser/filebrowser/issues/5891)) ([1e03fea](https://github.com/filebrowser/filebrowser/commit/1e03feadb550e4414b5589a6a8df57f538efba15))
|
||||||
|
* check share owner permissions on public share access ([#5888](https://github.com/filebrowser/filebrowser/issues/5888)) ([7dbf7a3](https://github.com/filebrowser/filebrowser/commit/7dbf7a3528234b2a9ee9c4115e8ecf58d258ca51))
|
||||||
|
* enforce directory boundary in rule path matching ([#5889](https://github.com/filebrowser/filebrowser/issues/5889)) ([8adf127](https://github.com/filebrowser/filebrowser/commit/8adf127c7d33585333b8030869f6f318e6517179))
|
||||||
|
* restrict default permissions for proxy-auth auto-provisioned users ([#5890](https://github.com/filebrowser/filebrowser/issues/5890)) ([f13c7c8](https://github.com/filebrowser/filebrowser/commit/f13c7c8cffd6d58ff29c4a6763ced1385f69961e))
|
||||||
|
|
||||||
|
## [2.63.0](https://github.com/filebrowser/filebrowser/compare/v2.62.2...v2.63.0) (2026-04-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* enable copy operation on drag‑and‑drop with ctrl key ([#5882](https://github.com/filebrowser/filebrowser/issues/5882)) ([876cdb3](https://github.com/filebrowser/filebrowser/commit/876cdb34265b090c2a74a69509f4106f2c5e8726))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* check download permission when sharing permission is enabled ([#5875](https://github.com/filebrowser/filebrowser/issues/5875)) ([0f39bd0](https://github.com/filebrowser/filebrowser/commit/0f39bd055efdadc15abd2f8146cf5da3793f8318))
|
||||||
|
* **tus:** reject negative upload-length to prevent inconsistent cache entry ([#5876](https://github.com/filebrowser/filebrowser/issues/5876)) ([7a16129](https://github.com/filebrowser/filebrowser/commit/7a16129bfc07dbdc2fa52b99d2985c1bc0ea12e2))
|
||||||
|
|
||||||
|
## [2.62.2](https://github.com/filebrowser/filebrowser/compare/v2.62.1...v2.62.2) (2026-03-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* disable scripted content in epub ([126227b](https://github.com/filebrowser/filebrowser/commit/126227bb2754eee15cd7c722916c3bb8821084a2))
|
||||||
|
* double slash in TUS upload path when readEntries returns multiple batches ([#5848](https://github.com/filebrowser/filebrowser/issues/5848)) ([432f3e6](https://github.com/filebrowser/filebrowser/commit/432f3e60ffdf92af6f8f56119a1bac8084f52a60))
|
||||||
|
* include filename in Content-Disposition header for inline downloads ([#5860](https://github.com/filebrowser/filebrowser/issues/5860)) ([8f81b77](https://github.com/filebrowser/filebrowser/commit/8f81b77cf2a3da0a445f3700fbf4a0091ea46c07))
|
||||||
|
* json escaping ([c406bda](https://github.com/filebrowser/filebrowser/commit/c406bda0c73ac8b187e23a97c05521edc77efa84))
|
||||||
|
* self-registered users don't get execute perms ([b6a4fb1](https://github.com/filebrowser/filebrowser/commit/b6a4fb1f27f4d894b384c0f3acacda276d1338a5))
|
||||||
|
* shares listing ([a8fc165](https://github.com/filebrowser/filebrowser/commit/a8fc1657b796c5da7190466beff13e680721b6d3))
|
||||||
|
* touch Redis upload cache key on GetLength to prevent TTL expiry ([#5850](https://github.com/filebrowser/filebrowser/issues/5850)) ([4812536](https://github.com/filebrowser/filebrowser/commit/48125365551ce2b27790aaafd7594cf5ce52f1ba))
|
||||||
|
* use html/template ([d9f9460](https://github.com/filebrowser/filebrowser/commit/d9f9460c1e51d10a25065e10358c12d5ced66ad9))
|
||||||
|
|
||||||
|
## [2.62.1](https://github.com/filebrowser/filebrowser/compare/v2.62.0...v2.62.1) (2026-03-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* base url/reverse proxy redirect ([fc80f4f](https://github.com/filebrowser/filebrowser/commit/fc80f4f44c856ddc19df3024c245990fffd55630))
|
||||||
|
|
||||||
|
## [2.62.0](https://github.com/filebrowser/filebrowser/compare/v2.61.2...v2.62.0) (2026-03-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Updates for project File Browser ([#5807](https://github.com/filebrowser/filebrowser/issues/5807)) ([858eb42](https://github.com/filebrowser/filebrowser/commit/858eb426515ec55172e9cca47bdf1e25a0d0d81d))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow deleting the user's own account ([#5820](https://github.com/filebrowser/filebrowser/issues/5820)) ([f04af0c](https://github.com/filebrowser/filebrowser/commit/f04af0cac6c808b8e7c9a9651380c252c4de9132))
|
||||||
|
* around languages ([c21af07](https://github.com/filebrowser/filebrowser/commit/c21af0791a5df458c2ddb81ce9ae44b772b6d82d))
|
||||||
|
* clean path in patch handler ([4bd7d69](https://github.com/filebrowser/filebrowser/commit/4bd7d69c82163b201a987e99c0c50d7ecc6ee5f1))
|
||||||
|
* make perm.share depend on share.download ([09a2616](https://github.com/filebrowser/filebrowser/commit/09a26166b4f79446e7174c017380f6db45444e32))
|
||||||
|
* properly surface config parse errors ([#5822](https://github.com/filebrowser/filebrowser/issues/5822)) ([ef2e999](https://github.com/filebrowser/filebrowser/commit/ef2e9992dc3098f6c4722c2a98966cd8abf8bab5))
|
||||||
|
* signup handler shouldn't create admins ([a63573b](https://github.com/filebrowser/filebrowser/commit/a63573b67eb302167b4c4f218361a2d0c138deab))
|
||||||
|
* **tus:** preserve percent-encoded upload paths in Location header ([#5817](https://github.com/filebrowser/filebrowser/issues/5817)) ([0542fc0](https://github.com/filebrowser/filebrowser/commit/0542fc0ba43740c967414eebd156bac86ad80376))
|
||||||
|
* **upload:** avoid skipping whole folder upload on conflict modal ([#5814](https://github.com/filebrowser/filebrowser/issues/5814)) ([f5f8b60](https://github.com/filebrowser/filebrowser/commit/f5f8b60b331a07729a1fed1ed065cb6fc20930ea))
|
||||||
|
* **upload:** don't mark every folder-upload file as conflicting ([#5813](https://github.com/filebrowser/filebrowser/issues/5813)) ([6dcef07](https://github.com/filebrowser/filebrowser/commit/6dcef07f40d550acee63dd01e0a3bcf78532f690))
|
||||||
|
|
||||||
|
## [2.61.2](https://github.com/filebrowser/filebrowser/compare/v2.61.1...v2.61.2) (2026-03-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* added dateFormat to getUserDefaults so this is respected in the … ([#5804](https://github.com/filebrowser/filebrowser/issues/5804)) ([8598db2](https://github.com/filebrowser/filebrowser/commit/8598db2accccf5b87353e5e718b2ad1c946e5c44))
|
||||||
|
* avoid sending the same name in the file/folder rename modal ([#5806](https://github.com/filebrowser/filebrowser/issues/5806)) ([d7b00ce](https://github.com/filebrowser/filebrowser/commit/d7b00ce5f672b7ce0b26ce31abdfc74f8b00b939))
|
||||||
|
* **csv-viewer:** add support for missing text encodings in dropdown list ([#5795](https://github.com/filebrowser/filebrowser/issues/5795)) ([4af3f85](https://github.com/filebrowser/filebrowser/commit/4af3f85e64e795e8ae1d87d4caee8185028294ac))
|
||||||
|
* **frontend:** do not delete original assets ([4d9e6b8](https://github.com/filebrowser/filebrowser/commit/4d9e6b821852203cef67233791a922013bd5b64d))
|
||||||
|
* **frontend:** input password type ([8ee5576](https://github.com/filebrowser/filebrowser/commit/8ee55761a1aa9bc091d8466c44f03c2043a8ca79))
|
||||||
|
* validate current password with a modal ([#5805](https://github.com/filebrowser/filebrowser/issues/5805)) ([177c7cf](https://github.com/filebrowser/filebrowser/commit/177c7cfcce36779e2c5ebaa4b59a055dd1e17648))
|
||||||
|
|
||||||
|
## [2.61.1](https://github.com/filebrowser/filebrowser/compare/v2.61.0...v2.61.1) (2026-03-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* check for correct permission in TUS Delete ([7ed1425](https://github.com/filebrowser/filebrowser/commit/7ed1425115be602c2b23236c410098ea2d74b42f))
|
||||||
|
|
||||||
|
## [2.61.0](https://github.com/filebrowser/filebrowser/compare/v2.60.0...v2.61.0) (2026-02-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* improved conflict resolution when uploading/copying/moving files ([#5765](https://github.com/filebrowser/filebrowser/issues/5765)) ([aa80909](https://github.com/filebrowser/filebrowser/commit/aa809096eb35fdfbdeb6784b1ebfe2ca1e42f52b))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* correctly clean path ([31194fb](https://github.com/filebrowser/filebrowser/commit/31194fb57a5b92e7155219d7ec7273028fcb2e83))
|
||||||
|
|
||||||
|
## [2.60.0](https://github.com/filebrowser/filebrowser/compare/v2.59.0...v2.60.0) (2026-02-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Updates for project File Browser ([#5764](https://github.com/filebrowser/filebrowser/issues/5764)) ([9940bdd](https://github.com/filebrowser/filebrowser/commit/9940bdd663ff5141110778524b8a22c957036e78))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* always show separators and encoding list in the CSV viewer ([#5774](https://github.com/filebrowser/filebrowser/issues/5774)) ([3169a14](https://github.com/filebrowser/filebrowser/commit/3169a14a4d63a0a11a5288f4f3a674c0a0edb972))
|
||||||
|
* modal lifecycle issues, multiple modals, new directory creation and discard changes behavior ([#5773](https://github.com/filebrowser/filebrowser/issues/5773)) ([200d501](https://github.com/filebrowser/filebrowser/commit/200d5015472c79d5caa683ea291ebf500356a39f))
|
||||||
|
|
||||||
|
## [2.59.0](https://github.com/filebrowser/filebrowser/compare/v2.58.0...v2.59.0) (2026-02-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add 'Open direct' button to images ([#5678](https://github.com/filebrowser/filebrowser/issues/5678)) ([804b14b](https://github.com/filebrowser/filebrowser/commit/804b14b698aa218fa5c2aaba687e72c5f7617f0f))
|
||||||
|
* Updates for project File Browser ([#5760](https://github.com/filebrowser/filebrowser/issues/5760)) ([63a76ef](https://github.com/filebrowser/filebrowser/commit/63a76ef18c51121e08634810a894c1e22a870428))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* render equations in markdown preview ([#5745](https://github.com/filebrowser/filebrowser/issues/5745)) ([0467326](https://github.com/filebrowser/filebrowser/commit/0467326d5c082c42c0ede88ee2d3472f5fb65600))
|
||||||
|
|
||||||
|
## [2.58.0](https://github.com/filebrowser/filebrowser/compare/v2.57.1...v2.58.0) (2026-02-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* nederlands ([88b97de](https://github.com/filebrowser/filebrowser/commit/88b97def9ee72fe6e8094209aebb71830b7305be))
|
||||||
|
* support for multiple encodings in CSV files ([#5756](https://github.com/filebrowser/filebrowser/issues/5756)) ([f67bccf](https://github.com/filebrowser/filebrowser/commit/f67bccf8c5470cb280fe854d92aa2666c270bcf5))
|
||||||
|
* Updates for project File Browser ([#5749](https://github.com/filebrowser/filebrowser/issues/5749)) ([c94870f](https://github.com/filebrowser/filebrowser/commit/c94870fcfe1b4acb2db9ab897b9f7d35e3b75770))
|
||||||
|
* Updates for project File Browser ([#5759](https://github.com/filebrowser/filebrowser/issues/5759)) ([5e8f5be](https://github.com/filebrowser/filebrowser/commit/5e8f5be245fd0126545ef5ca61c2d428ac128ad5))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **frontend:** pnpm lock ([b09960e](https://github.com/filebrowser/filebrowser/commit/b09960e538387ff29371c80be1584720f65181e7))
|
||||||
|
* ignore version.go ([1f7904d](https://github.com/filebrowser/filebrowser/commit/1f7904dad21a87f04e1543ee10b60ce79e5eebe9))
|
||||||
|
* respect Accept-Encoding for pre-compressed JS ([#5750](https://github.com/filebrowser/filebrowser/issues/5750)) ([6a76dfe](https://github.com/filebrowser/filebrowser/commit/6a76dfeba9254a938e320928c67d110f73f83715))
|
||||||
|
* wrap response text in Error before reject ([#5753](https://github.com/filebrowser/filebrowser/issues/5753)) ([e5bc0d3](https://github.com/filebrowser/filebrowser/commit/e5bc0d3cce18fa7b069688b176b99efbb67382d2))
|
||||||
|
|
||||||
|
## [2.57.1](https://github.com/filebrowser/filebrowser/compare/v2.57.0...v2.57.1) (2026-02-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* normalize fields capitalization ([ff2f004](https://github.com/filebrowser/filebrowser/commit/ff2f00498cff151e2fb1f5f0b16963bf33c3d6d4))
|
||||||
|
* remove skip clean ([489af40](https://github.com/filebrowser/filebrowser/commit/489af403a19057f6b6b4b1dc0e48cbb26a202ef9))
|
||||||
|
|
||||||
|
## [2.57.0](https://github.com/filebrowser/filebrowser/compare/v2.56.0...v2.57.0) (2026-02-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add Redis upload cache for multi-replica deployments ([#5724](https://github.com/filebrowser/filebrowser/issues/5724)) ([08d7a15](https://github.com/filebrowser/filebrowser/commit/08d7a1504c42c115fdd82d3845694fe87147f1db))
|
||||||
|
* Updates for project File Browser ([#5725](https://github.com/filebrowser/filebrowser/issues/5725)) ([8fee256](https://github.com/filebrowser/filebrowser/commit/8fee2561afbf968ed577bc4139562a42b2278243))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* adjust yaml config decodification to yaml.v3 ([#5722](https://github.com/filebrowser/filebrowser/issues/5722)) ([b594d4d](https://github.com/filebrowser/filebrowser/commit/b594d4d4e28a1b35e69d81d2c35948fe0d629888))
|
||||||
|
* avoid 409 conflict when renaming files differing only by case ([#5729](https://github.com/filebrowser/filebrowser/issues/5729)) ([d441b28](https://github.com/filebrowser/filebrowser/commit/d441b28f432c3448a29ac828400321f1f4ed32d9))
|
||||||
|
|
||||||
|
## [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)
|
## [2.45.2](https://github.com/filebrowser/filebrowser/compare/v2.45.1...v2.45.2) (2025-11-13)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+46
-9
@@ -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
-1
@@ -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 && \
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
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 libcap
|
apk --no-cache add ca-certificates mailcap jq libcap
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
fmt: $(goimports) ## Format source files
|
|
||||||
$Q $(goimports) -local $(MODULE) -w $$(find . -type f -name '*.go' -not -path "./vendor/*")
|
|
||||||
|
|
||||||
clean: clean-tools ## Clean
|
|
||||||
|
|
||||||
## Release:
|
|
||||||
|
|
||||||
.PHONY: release-dry-run
|
|
||||||
release-dry-run:
|
|
||||||
pnpm dlx commit-and-tag-version --dry-run --skip
|
|
||||||
|
|
||||||
.PHONY: release
|
|
||||||
release:
|
|
||||||
pnpm dlx commit-and-tag-version -s
|
|
||||||
|
|
||||||
.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)
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<img src="https://raw.githubusercontent.com/filebrowser/filebrowser/master/branding/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/v2)
|
[](https://goreportcard.com/report/github.com/filebrowser/filebrowser/v2)
|
||||||
[](https://github.com/filebrowser/filebrowser/releases/latest)
|
[](https://github.com/filebrowser/filebrowser/releases/latest)
|
||||||
|
|
||||||
@@ -14,18 +14,14 @@ 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).
|
||||||
|
- The priority is triaging issues, addressing security issues and reviewing pull requests meant to solve bugs.
|
||||||
|
- No new features are planned. Pull requests for new features are not guaranteed to be reviewed.
|
||||||
|
|
||||||
|
Please read [@hacdias' personal reflection](https://hacdias.com/2026/03/11/filebrowser/) on the project status.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+27
-38
@@ -11,7 +11,7 @@ import (
|
|||||||
"slices"
|
"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"
|
||||||
@@ -68,7 +68,7 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store, stg *settings.Settings
|
|||||||
case "block":
|
case "block":
|
||||||
return nil, os.ErrPermission
|
return nil, os.ErrPermission
|
||||||
case "pass":
|
case "pass":
|
||||||
u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
|
u, err := a.Users.Get(a.Server.Root, a.Server.FollowExternalSymlinks, a.Cred.Username)
|
||||||
if err != nil || !users.CheckPwd(a.Cred.Password, u.Password) {
|
if err != nil || !users.CheckPwd(a.Cred.Password, u.Password) {
|
||||||
return nil, os.ErrPermission
|
return nil, os.ErrPermission
|
||||||
}
|
}
|
||||||
@@ -86,24 +86,8 @@ func (a *HookAuth) LoginPage() bool {
|
|||||||
// RunCommand starts the hook command and returns the action
|
// RunCommand starts the hook command and returns the action
|
||||||
func (a *HookAuth) RunCommand() (string, error) {
|
func (a *HookAuth) RunCommand() (string, error) {
|
||||||
command := strings.Split(a.Command, " ")
|
command := strings.Split(a.Command, " ")
|
||||||
envMapping := func(key string) string {
|
|
||||||
switch key {
|
|
||||||
case "USERNAME":
|
|
||||||
return a.Cred.Username
|
|
||||||
case "PASSWORD":
|
|
||||||
return a.Cred.Password
|
|
||||||
default:
|
|
||||||
return os.Getenv(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i, arg := range command {
|
|
||||||
if i == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
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()
|
||||||
@@ -145,8 +129,8 @@ 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.Server.FollowExternalSymlinks, a.Cred.Username)
|
||||||
if err != nil && !errors.Is(err, fbErrors.ErrNotExist) {
|
if err != nil && !errors.Is(err, fberrors.ErrNotExist) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,16 +142,18 @@ 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,
|
||||||
|
DateFormat: a.Settings.Defaults.DateFormat,
|
||||||
|
HideDotfiles: a.Settings.Defaults.HideDotfiles,
|
||||||
}
|
}
|
||||||
u = a.GetUser(d)
|
u = a.GetUser(d)
|
||||||
|
|
||||||
@@ -219,18 +205,20 @@ 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),
|
||||||
},
|
},
|
||||||
Commands: a.Fields.GetArray("user.commands", d.Commands),
|
Commands: a.Fields.GetArray("user.commands", d.Commands),
|
||||||
|
DateFormat: a.Fields.GetBoolean("user.dateFormat", d.DateFormat),
|
||||||
HideDotfiles: a.Fields.GetBoolean("user.hideDotfiles", d.HideDotfiles),
|
HideDotfiles: a.Fields.GetBoolean("user.hideDotfiles", d.HideDotfiles),
|
||||||
Perm: perms,
|
Perm: perms,
|
||||||
LockPassword: true,
|
LockPassword: true,
|
||||||
@@ -251,6 +239,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",
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeHookScript writes a POSIX shell script to a temp file and returns its
|
||||||
|
// path, marking it executable.
|
||||||
|
func writeHookScript(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "hook.sh")
|
||||||
|
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o700); err != nil {
|
||||||
|
t.Fatalf("failed to write hook script: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunCommandNoCredentialInjection ensures that attacker-controlled
|
||||||
|
// credentials submitted at the unauthenticated login endpoint cannot be
|
||||||
|
// injected into the hook command string. Credentials must only ever reach the
|
||||||
|
// hook through the USERNAME/PASSWORD environment variables, never via string
|
||||||
|
// substitution into the command itself (CWE-78/CWE-88).
|
||||||
|
func TestRunCommandNoCredentialInjection(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses POSIX shell")
|
||||||
|
}
|
||||||
|
|
||||||
|
marker := filepath.Join(t.TempDir(), "pwned")
|
||||||
|
|
||||||
|
// The hook simply blocks. If the credential were ever interpolated into the
|
||||||
|
// command string and evaluated by a shell, the embedded `touch` would
|
||||||
|
// create the marker file.
|
||||||
|
script := writeHookScript(t, "echo hook.action=block\n")
|
||||||
|
|
||||||
|
a := &HookAuth{
|
||||||
|
Command: script,
|
||||||
|
Cred: hookCred{
|
||||||
|
Username: `"; touch ` + marker + `; #`,
|
||||||
|
Password: `$(touch ` + marker + `)`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err := a.RunCommand()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunCommand returned error: %v", err)
|
||||||
|
}
|
||||||
|
if action != "block" {
|
||||||
|
t.Fatalf("expected action %q, got %q", "block", action)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(marker); err == nil {
|
||||||
|
t.Fatalf("credential injection executed: marker file %q was created", marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunCommandReceivesCredentialsViaEnv verifies the supported contract: the
|
||||||
|
// hook receives credentials through the USERNAME and PASSWORD environment
|
||||||
|
// variables.
|
||||||
|
func TestRunCommandReceivesCredentialsViaEnv(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses POSIX shell")
|
||||||
|
}
|
||||||
|
|
||||||
|
script := writeHookScript(t, `if [ "$USERNAME" = alice ] && [ "$PASSWORD" = secret ]; then
|
||||||
|
echo hook.action=auth
|
||||||
|
else
|
||||||
|
echo hook.action=block
|
||||||
|
fi
|
||||||
|
`)
|
||||||
|
|
||||||
|
a := &HookAuth{
|
||||||
|
Command: script,
|
||||||
|
Cred: hookCred{
|
||||||
|
Username: "alice",
|
||||||
|
Password: "secret",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err := a.RunCommand()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunCommand returned error: %v", err)
|
||||||
|
}
|
||||||
|
if action != "auth" {
|
||||||
|
t.Fatalf("expected action %q, got %q", "auth", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-3
@@ -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
|
||||||
@@ -51,8 +55,18 @@ 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, srv.FollowExternalSymlinks, 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ type NoAuth struct{}
|
|||||||
|
|
||||||
// Auth uses authenticates user 1.
|
// Auth uses authenticates user 1.
|
||||||
func (a NoAuth) Auth(_ *http.Request, usr users.Store, _ *settings.Settings, srv *settings.Server) (*users.User, error) {
|
func (a NoAuth) Auth(_ *http.Request, usr users.Store, _ *settings.Settings, srv *settings.Server) (*users.User, error) {
|
||||||
return usr.Get(srv.Root, uint(1))
|
return usr.Get(srv.Root, srv.FollowExternalSymlinks, uint(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginPage tells that no auth doesn't require a login page.
|
// LoginPage tells that no auth doesn't require a login page.
|
||||||
|
|||||||
+6
-3
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -20,8 +20,8 @@ type ProxyAuth struct {
|
|||||||
// Auth authenticates the user via an HTTP header.
|
// Auth authenticates the user via an HTTP header.
|
||||||
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, srv.FollowExternalSymlinks, 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
|
||||||
@@ -46,6 +46,9 @@ func (a ProxyAuth) createUser(usr users.Store, setting *settings.Settings, srv *
|
|||||||
LockPassword: true,
|
LockPassword: true,
|
||||||
}
|
}
|
||||||
setting.Defaults.Apply(user)
|
setting.Defaults.Apply(user)
|
||||||
|
user.Perm.Admin = false
|
||||||
|
user.Perm.Execute = false
|
||||||
|
user.Commands = []string{}
|
||||||
|
|
||||||
var userHome string
|
var userHome string
|
||||||
userHome, err = setting.MakeUserDir(user.Username, user.Scope, srv.Root)
|
userHome, err = setting.MakeUserDir(user.Username, user.Scope, srv.Root)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
fberrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||||
|
"github.com/filebrowser/filebrowser/v2/settings"
|
||||||
|
"github.com/filebrowser/filebrowser/v2/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockUserStore struct {
|
||||||
|
users map[string]*users.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserStore) Get(_ string, _ bool, id interface{}) (*users.User, error) {
|
||||||
|
if v, ok := id.(string); ok {
|
||||||
|
if u, ok := m.users[v]; ok {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fberrors.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserStore) GetByScope(_ string) (*users.User, error) { return nil, fberrors.ErrNotExist }
|
||||||
|
func (m *mockUserStore) Gets(_ string, _ bool) ([]*users.User, error) { return nil, nil }
|
||||||
|
func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil }
|
||||||
|
func (m *mockUserStore) Save(user *users.User) error {
|
||||||
|
m.users[user.Username] = user
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockUserStore) Delete(_ interface{}) error { return nil }
|
||||||
|
func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 }
|
||||||
|
|
||||||
|
func TestProxyAuthCreateUserRestrictsDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
store := &mockUserStore{users: make(map[string]*users.User)}
|
||||||
|
srv := &settings.Server{Root: t.TempDir()}
|
||||||
|
|
||||||
|
s := &settings.Settings{
|
||||||
|
Key: []byte("key"),
|
||||||
|
AuthMethod: MethodProxyAuth,
|
||||||
|
Defaults: settings.UserDefaults{
|
||||||
|
Perm: users.Permissions{
|
||||||
|
Admin: true,
|
||||||
|
Execute: true,
|
||||||
|
Create: true,
|
||||||
|
Rename: true,
|
||||||
|
Modify: true,
|
||||||
|
Delete: true,
|
||||||
|
Share: true,
|
||||||
|
Download: true,
|
||||||
|
},
|
||||||
|
Commands: []string{"git", "ls", "cat", "id"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := ProxyAuth{Header: "X-Remote-User"}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||||
|
req.Header.Set("X-Remote-User", "newproxyuser")
|
||||||
|
|
||||||
|
user, err := auth.Auth(req, store, s, srv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Auth() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Perm.Admin {
|
||||||
|
t.Error("auto-provisioned proxy user should not have Admin permission")
|
||||||
|
}
|
||||||
|
if user.Perm.Execute {
|
||||||
|
t.Error("auto-provisioned proxy user should not have Execute permission")
|
||||||
|
}
|
||||||
|
if len(user.Commands) != 0 {
|
||||||
|
t.Errorf("auto-provisioned proxy user should have empty Commands, got %v", user.Commands)
|
||||||
|
}
|
||||||
|
if !user.Perm.Create {
|
||||||
|
t.Error("auto-provisioned proxy user should retain Create permission from defaults")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-20
@@ -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,19 +224,34 @@ 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.Fprintf(w, "\tFollow External Symlinks:\t%t\n", ser.FollowExternalSymlinks)
|
||||||
|
|
||||||
|
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, "\tDateFormat:\t%t\n", set.Defaults.DateFormat)
|
||||||
|
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, "\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)
|
||||||
@@ -234,6 +261,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, "", " ")
|
||||||
@@ -243,3 +271,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
|
||||||
|
}
|
||||||
|
|||||||
+5
-5
@@ -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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-14
@@ -3,7 +3,6 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -34,11 +33,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,23 +52,22 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawAuther interface{}
|
var rawAuther = file.Auther
|
||||||
if filepath.Ext(args[0]) != ".json" {
|
|
||||||
rawAuther = cleanUpInterfaceMap(file.Auther.(map[interface{}]interface{}))
|
|
||||||
} else {
|
|
||||||
rawAuther = file.Auther
|
|
||||||
}
|
|
||||||
|
|
||||||
var auther auth.Auther
|
var auther auth.Auther
|
||||||
var autherErr error
|
var autherErr error
|
||||||
@@ -98,13 +96,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) {
|
||||||
|
|||||||
+13
-134
@@ -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}),
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-72
@@ -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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-115
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+202
-222
@@ -17,16 +17,14 @@ import (
|
|||||||
"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 +34,69 @@ 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",
|
||||||
|
"redis-cache-url": "redisCacheUrl",
|
||||||
|
"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.String("redisCacheUrl", "", "redis cache URL (for multi-instance deployments), e.g. redis://user:pass@host:port")
|
||||||
|
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 +105,14 @@ 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("followExternalSymlinks", false, "follow symlinks whose target is outside the user scope (unsafe)")
|
||||||
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 +127,66 @@ 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)
|
redisCacheURL := v.GetString("redisCacheUrl")
|
||||||
|
uploadCache, err := fbhttp.NewUploadCache(redisCacheURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize upload cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := getServerSettings(v, st.Storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -169,16 +208,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 +237,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, uploadCache, st.Storage, server, assetsFs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -233,7 +269,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 +277,86 @@ 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 v.IsSet("followExternalSymlinks") {
|
||||||
|
server.FollowExternalSymlinks = v.GetBool("followExternalSymlinks")
|
||||||
}
|
}
|
||||||
|
|
||||||
if isAddrSet && isSocketSet {
|
if isAddrSet && isSocketSet {
|
||||||
@@ -312,18 +368,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 +375,28 @@ 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 {
|
if server.FollowExternalSymlinks {
|
||||||
server.TokenExpirationTime = val
|
log.Println("WARNING: Following external symlinks enabled!")
|
||||||
|
log.Println("WARNING: Symlinks pointing outside a user's scope will be followed,")
|
||||||
|
log.Println("WARNING: which can expose files outside that scope. Only enable this if")
|
||||||
|
log.Println("WARNING: you fully understand and trust the contents of every user scope.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if set, err := st.Settings.Get(); err == nil && set.Signup {
|
||||||
|
scope := strings.TrimSpace(set.Defaults.Scope)
|
||||||
|
scopeIsRoot := scope == "" || scope == "." || scope == "/"
|
||||||
|
|
||||||
|
if !set.CreateUserDir && scopeIsRoot {
|
||||||
|
log.Println("WARNING: Signup is enabled without createUserDir and the default scope is")
|
||||||
|
log.Println("WARNING: the server root, so every self-registered user can read, modify and")
|
||||||
|
log.Println("WARNING: delete all files File Browser serves, including other users' files.")
|
||||||
|
log.Println("WARNING: Enable createUserDir, or set a default scope other than the root.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,20 +415,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,
|
||||||
AceEditorTheme: getStringParam(flags, "defaults.aceEditorTheme"),
|
RedirectAfterCopyMove: true,
|
||||||
|
AceEditorTheme: v.GetString("defaults.aceEditorTheme"),
|
||||||
Perm: users.Permissions{
|
Perm: users.Permissions{
|
||||||
Admin: false,
|
Admin: false,
|
||||||
Execute: true,
|
Execute: true,
|
||||||
@@ -451,39 +454,46 @@ 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"),
|
||||||
|
FollowExternalSymlinks: v.GetBool("followExternalSymlinks"),
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -514,35 +524,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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -36,7 +36,7 @@ func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User)
|
|||||||
}
|
}
|
||||||
if id != nil {
|
if id != nil {
|
||||||
var user *users.User
|
var user *users.User
|
||||||
user, err = st.Users.Get("", id)
|
user, err = st.Users.Get("", false, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
+36
-27
@@ -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,67 +78,74 @@ 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 in file listings")
|
||||||
flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users")
|
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":
|
case "aceEditorTheme":
|
||||||
defaults.AceEditorTheme, err = getString(flags, flag.Name)
|
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 "dateFormat":
|
||||||
|
defaults.DateFormat, 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,5 +154,6 @@ func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all
|
|||||||
} else {
|
} else {
|
||||||
flags.Visit(visit)
|
flags.Visit(visit)
|
||||||
}
|
}
|
||||||
return visitErr
|
|
||||||
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-27
@@ -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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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("", false)
|
||||||
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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -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("", false, username)
|
||||||
} else {
|
} else {
|
||||||
user, err = d.store.Users.Get("", id)
|
user, err = st.Users.Get("", false, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
list = []*users.User{user}
|
list = []*users.User{user}
|
||||||
} else {
|
} else {
|
||||||
list, err = d.store.Users.Gets("")
|
list, err = st.Users.Gets("", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
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{})
|
||||||
|
|||||||
+11
-10
@@ -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
|
||||||
@@ -39,19 +40,19 @@ list or set it to 0.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, user := range list {
|
for _, user := range list {
|
||||||
err = user.Clean("")
|
err = user.Clean("", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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("", false)
|
||||||
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("", false, 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("", false, 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 {
|
||||||
|
|||||||
+4
-4
@@ -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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-24
@@ -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("", false, id)
|
||||||
} else {
|
} else {
|
||||||
user, err = d.store.Users.Get("", username)
|
user, err = st.Users.Get("", false, 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{}),
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-89
@@ -3,7 +3,6 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,8 +11,11 @@ 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"
|
||||||
|
"github.com/spf13/viper"
|
||||||
yaml "gopkg.in/yaml.v3"
|
yaml "gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"github.com/filebrowser/filebrowser/v2/settings"
|
"github.com/filebrowser/filebrowser/v2/settings"
|
||||||
@@ -21,42 +23,22 @@ import (
|
|||||||
"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 +47,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 +57,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 +67,138 @@ 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.As(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 {
|
||||||
@@ -185,33 +249,6 @@ func jsonYamlArg(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanUpInterfaceMap(in map[interface{}]interface{}) map[string]interface{} {
|
|
||||||
result := make(map[string]interface{})
|
|
||||||
for k, v := range in {
|
|
||||||
result[fmt.Sprintf("%v", k)] = cleanUpMapValue(v)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanUpInterfaceArray(in []interface{}) []interface{} {
|
|
||||||
result := make([]interface{}, len(in))
|
|
||||||
for i, v := range in {
|
|
||||||
result[i] = cleanUpMapValue(v)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanUpMapValue(v interface{}) interface{} {
|
|
||||||
switch v := v.(type) {
|
|
||||||
case []interface{}:
|
|
||||||
return cleanUpInterfaceArray(v)
|
|
||||||
case map[interface{}]interface{}:
|
|
||||||
return cleanUpInterfaceMap(v)
|
|
||||||
default:
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// convertCmdStrToCmdArray checks if cmd string is blank (whitespace included)
|
// convertCmdStrToCmdArray checks if cmd string is blank (whitespace included)
|
||||||
// then returns empty string array, else returns the split word array of cmd.
|
// then returns empty string array, else returns the split word array of cmd.
|
||||||
// This is to ensure the result will never be []string{""}
|
// This is to ensure the result will never be []string{""}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
filebrowser:
|
||||||
|
container_name: filebrowser
|
||||||
|
image: filebrowser/filebrowser:latest
|
||||||
|
networks:
|
||||||
|
- filebrowser
|
||||||
|
ports:
|
||||||
|
- 8000:80
|
||||||
|
volumes:
|
||||||
|
- filebrowser:/flux/vault
|
||||||
|
environment:
|
||||||
|
- FB_REDIS_CACHE_URL=redis://default:filebrowser@redis:6379 # Use rediss:// for ssl
|
||||||
|
|
||||||
|
redis:
|
||||||
|
container_name: redis
|
||||||
|
image: redis:latest
|
||||||
|
networks:
|
||||||
|
- filebrowser
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
cat > /tmp/users.acl <<EOF
|
||||||
|
user default on >filebrowser ~* +@all
|
||||||
|
EOF
|
||||||
|
redis-server --aclfile /tmp/users.acl
|
||||||
|
|
||||||
|
networks:
|
||||||
|
filebrowser:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
filebrowser:
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-71
@@ -1,40 +1,29 @@
|
|||||||
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("the sole admin 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")
|
ErrShareRequiresDownload = errors.New("permission to share requires permission to download")
|
||||||
ErrSigint = errors.New("exit on signal: sigint")
|
|
||||||
ErrSigquit = errors.New("exit on signal: sigquit")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ErrShortPassword struct {
|
type ErrShortPassword struct {
|
||||||
@@ -44,44 +33,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
|
|
||||||
}
|
|
||||||
|
|||||||
+86
-25
@@ -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"
|
||||||
@@ -18,13 +18,13 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -60,6 +60,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 +91,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 +169,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 +184,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 +194,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 +219,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 +250,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,9 +390,8 @@ 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}
|
dir, err := readDir(i.Fs, i.Path)
|
||||||
dir, err := afs.ReadDir(i.Path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -412,12 +413,19 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error {
|
|||||||
isSymlink, isInvalidLink := false, false
|
isSymlink, isInvalidLink := false, false
|
||||||
if IsSymlink(f.Mode()) {
|
if IsSymlink(f.Mode()) {
|
||||||
isSymlink = true
|
isSymlink = true
|
||||||
// It's a symbolic link. We try to follow it. If it doesn't work,
|
// It's a symbolic link. We try to follow it. The scoped filesystem
|
||||||
// we stay with the link information instead of the target's.
|
// refuses to dereference a link whose target escapes the scope
|
||||||
|
// (permission error); such a link is omitted from the listing
|
||||||
|
// entirely so it cannot leak the target's metadata. Any other
|
||||||
|
// failure means a broken link, which we surface as an invalid link
|
||||||
|
// rather than the target's information.
|
||||||
info, err := i.Fs.Stat(fPath)
|
info, err := i.Fs.Stat(fPath)
|
||||||
if err == nil {
|
switch {
|
||||||
|
case err == nil:
|
||||||
f = info
|
f = info
|
||||||
} else {
|
case errors.Is(err, os.ErrPermission):
|
||||||
|
continue
|
||||||
|
default:
|
||||||
isInvalidLink = true
|
isInvalidLink = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,7 +443,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 +460,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
|
||||||
}
|
}
|
||||||
@@ -465,3 +473,56 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error {
|
|||||||
i.Listing = listing
|
i.Listing = listing
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readDir(afs afero.Fs, dirname string) ([]os.FileInfo, error) {
|
||||||
|
dir, err := afero.ReadDir(afs, dirname)
|
||||||
|
if err == nil {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, fallbackErr := readDirNames(afs, dirname)
|
||||||
|
if fallbackErr != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDirNames(afs afero.Fs, dirname string) ([]os.FileInfo, error) {
|
||||||
|
file, err := afs.Open(dirname)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := file.Readdirnames(-1)
|
||||||
|
if closeErr := file.Close(); err == nil && closeErr != nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(names)
|
||||||
|
dir := make([]os.FileInfo, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
fPath := path.Join(dirname, name)
|
||||||
|
info, err := lstatIfPossible(afs, fPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Skipping inaccessible file %s: %v", fPath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dir = append(dir, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lstatIfPossible(afs afero.Fs, name string) (os.FileInfo, error) {
|
||||||
|
if lstaterFs, ok := afs.(afero.Lstater); ok {
|
||||||
|
info, _, err := lstaterFs.LstatIfPossible(name)
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return afs.Stat(name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScopedFs(t *testing.T) {
|
||||||
|
t.Run("path inside scope is allowed", func(t *testing.T) {
|
||||||
|
scope := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(scope, "file.txt"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fs := NewScopedFs(afero.NewOsFs(), scope)
|
||||||
|
|
||||||
|
if _, err := fs.Stat("/file.txt"); err != nil {
|
||||||
|
t.Fatalf("expected in-scope file to be accessible, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("new file inside scope can be created", func(t *testing.T) {
|
||||||
|
scope := t.TempDir()
|
||||||
|
fs := NewScopedFs(afero.NewOsFs(), scope)
|
||||||
|
|
||||||
|
f, err := fs.OpenFile("/does-not-exist-yet.txt", os.O_RDWR|os.O_CREATE, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected to create a new in-scope file, got %v", err)
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression for #5975: when the scope resolves to the filesystem root,
|
||||||
|
// root+separator used to be "//", which no path matched, so every write
|
||||||
|
// was rejected with os.ErrPermission (HTTP 403).
|
||||||
|
t.Run("filesystem root scope allows access", func(t *testing.T) {
|
||||||
|
f := filepath.Join(t.TempDir(), "file.txt")
|
||||||
|
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fs := NewScopedFs(afero.NewOsFs(), "/")
|
||||||
|
|
||||||
|
if _, err := fs.Stat(f); err != nil {
|
||||||
|
t.Fatalf("expected a path under root scope to be accessible, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("escaping symlink to a sibling is rejected", func(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
scope := filepath.Join(base, "srv")
|
||||||
|
sibling := filepath.Join(base, "srvother")
|
||||||
|
for _, d := range []string{scope, sibling} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(sibling, "secret.txt"), []byte("secret"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A symlink lexically inside the scope pointing at a sibling directory
|
||||||
|
// must not be followed for reads or stats.
|
||||||
|
if err := os.Symlink(sibling, filepath.Join(scope, "escape")); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
fs := NewScopedFs(afero.NewOsFs(), scope)
|
||||||
|
|
||||||
|
if _, err := fs.Stat("/escape"); !os.IsPermission(err) {
|
||||||
|
t.Fatalf("expected stat of escaping symlink to be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.Open("/escape/secret.txt"); !os.IsPermission(err) {
|
||||||
|
t.Fatalf("expected read through escaping symlink to be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("symlink whose target stays within scope is allowed", func(t *testing.T) {
|
||||||
|
scope := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(scope, "real"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(scope, "real", "f.txt"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(scope, "real"), filepath.Join(scope, "link")); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
fs := NewScopedFs(afero.NewOsFs(), scope)
|
||||||
|
|
||||||
|
if _, err := fs.Stat("/link/f.txt"); err != nil {
|
||||||
|
t.Fatalf("expected in-scope symlink target to be accessible, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression for the dangling-symlink write escape (GHSA-8wc8-hf36-mjh9 /
|
||||||
|
// GHSA-fh54-6rfh-r8f3): a symlink whose target does not exist yet must not be
|
||||||
|
// followed for writes. Previously within() validated the link's in-scope
|
||||||
|
// parent directory, so OpenFile(O_CREATE) dereferenced the link and created
|
||||||
|
// the file at its out-of-scope target.
|
||||||
|
t.Run("write through a dangling escaping symlink is rejected", func(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
scope := filepath.Join(base, "scope")
|
||||||
|
outside := filepath.Join(base, "outside")
|
||||||
|
for _, d := range []string{scope, outside} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outsideTarget := filepath.Join(outside, "created.txt") // does not exist yet
|
||||||
|
if err := os.Symlink(outsideTarget, filepath.Join(scope, "evil")); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
fs := NewScopedFs(afero.NewOsFs(), scope)
|
||||||
|
|
||||||
|
f, err := fs.OpenFile("/evil", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if err == nil {
|
||||||
|
_ = f.Close()
|
||||||
|
t.Fatal("VULNERABLE: write through a dangling escaping symlink was allowed")
|
||||||
|
}
|
||||||
|
if !os.IsPermission(err) {
|
||||||
|
t.Fatalf("expected permission error, got %v", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(outsideTarget); statErr == nil {
|
||||||
|
t.Fatal("VULNERABLE: file was created outside the scope")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// A dangling *relative* symlink that lives under an escaping directory
|
||||||
|
// symlink must be resolved against the link's real directory, not its lexical
|
||||||
|
// parent. Otherwise the symlinked ancestor can shift the computed target back
|
||||||
|
// into scope while the real OS write lands outside it.
|
||||||
|
t.Run("write through a dangling relative symlink under a symlinked dir is rejected", func(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
scope := filepath.Join(base, "scope")
|
||||||
|
outside := filepath.Join(base, "outside")
|
||||||
|
for _, d := range []string{scope, outside} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An escaping directory symlink inside the scope: /scope/m -> /base/outside.
|
||||||
|
if err := os.Symlink(outside, filepath.Join(scope, "m")); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
// A relative dangling symlink inside the escaping dir whose target,
|
||||||
|
// resolved against the real directory (/base/outside), is /base/escaped —
|
||||||
|
// outside the scope.
|
||||||
|
if err := os.Symlink("../escaped", filepath.Join(outside, "evil")); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
fs := NewScopedFs(afero.NewOsFs(), scope)
|
||||||
|
|
||||||
|
f, err := fs.OpenFile("/m/evil", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if err == nil {
|
||||||
|
_ = f.Close()
|
||||||
|
t.Fatal("VULNERABLE: write through a dangling relative symlink under a symlinked dir was allowed")
|
||||||
|
}
|
||||||
|
if !os.IsPermission(err) {
|
||||||
|
t.Fatalf("expected permission error, got %v", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(base, "escaped")); statErr == nil {
|
||||||
|
t.Fatal("VULNERABLE: file was created outside the scope")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression for the symlink-following delete escape (GHSA-hq4g-mpch-f9vp /
|
||||||
|
// GHSA-fmm7-x4gx-8jhr): Remove/RemoveAll used to skip guard(), so RemoveAll
|
||||||
|
// followed a symlinked ancestor escaping the scope and deleted an
|
||||||
|
// out-of-scope file.
|
||||||
|
t.Run("RemoveAll through an escaping symlink is rejected", func(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
scope := filepath.Join(base, "scope")
|
||||||
|
outside := filepath.Join(base, "outside")
|
||||||
|
for _, d := range []string{scope, outside} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
victim := filepath.Join(outside, "victim.txt")
|
||||||
|
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outside, filepath.Join(scope, "link")); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
fs := NewScopedFs(afero.NewOsFs(), scope)
|
||||||
|
|
||||||
|
if err := fs.RemoveAll("/link/victim.txt"); !os.IsPermission(err) {
|
||||||
|
t.Fatalf("expected RemoveAll through escaping symlink to be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(victim); statErr != nil {
|
||||||
|
t.Fatalf("VULNERABLE: out-of-scope victim file was deleted: %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// The guard added for the delete escape must not break legitimate deletes of
|
||||||
|
// in-scope files.
|
||||||
|
t.Run("RemoveAll of an in-scope file is allowed", func(t *testing.T) {
|
||||||
|
scope := t.TempDir()
|
||||||
|
target := filepath.Join(scope, "deleteme.txt")
|
||||||
|
if err := os.WriteFile(target, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fs := NewScopedFs(afero.NewOsFs(), scope)
|
||||||
|
|
||||||
|
if err := fs.RemoveAll("/deleteme.txt"); err != nil {
|
||||||
|
t.Fatalf("expected in-scope RemoveAll to succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(target); statErr == nil {
|
||||||
|
t.Fatal("expected in-scope file to be deleted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// stat must reject a regular file reached through a symlinked ancestor that
|
||||||
|
// escapes the scope (GHSA-hf77-9m7w-fq8q), while still serving in-scope files.
|
||||||
|
func TestStatRejectsLinkedAncestorEscape(t *testing.T) {
|
||||||
|
scope := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(scope, "shared"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(scope, "private"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(scope, "private", "secret.txt"), []byte("secret"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(scope, "shared", "ok.txt"), []byte("ok"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(scope, "private"), filepath.Join(scope, "shared", "link")); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filesystem scoped to the shared directory, as a public share would be.
|
||||||
|
bfs := NewScopedFs(afero.NewOsFs(), filepath.Join(scope, "shared"))
|
||||||
|
|
||||||
|
if _, err := stat(&FileOptions{Fs: bfs, Path: "/link/secret.txt"}); !os.IsPermission(err) {
|
||||||
|
t.Fatalf("expected permission error for linked-ancestor escape, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := stat(&FileOptions{Fs: bfs, Path: "/ok.txt"}); err != nil {
|
||||||
|
t.Fatalf("expected in-scope file to be served, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type allowAllChecker struct{}
|
||||||
|
|
||||||
|
func (allowAllChecker) Check(string) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type inaccessibleChildFs struct {
|
||||||
|
afero.Fs
|
||||||
|
child string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs inaccessibleChildFs) Open(name string) (afero.File, error) {
|
||||||
|
file, err := fs.Fs.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.Clean(name) == "/" {
|
||||||
|
return inaccessibleChildDir{File: file}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs inaccessibleChildFs) Stat(name string) (os.FileInfo, error) {
|
||||||
|
if path.Clean(name) == fs.child {
|
||||||
|
return nil, os.ErrPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.Fs.Stat(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs inaccessibleChildFs) LstatIfPossible(name string) (os.FileInfo, bool, error) {
|
||||||
|
if path.Clean(name) == fs.child {
|
||||||
|
return nil, false, os.ErrPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
if lstater, ok := fs.Fs.(afero.Lstater); ok {
|
||||||
|
return lstater.LstatIfPossible(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := fs.Fs.Stat(name)
|
||||||
|
return info, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type inaccessibleChildDir struct {
|
||||||
|
afero.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dir inaccessibleChildDir) Readdir(int) ([]os.FileInfo, error) {
|
||||||
|
return nil, os.ErrPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadListingSkipsInaccessibleChildren(t *testing.T) {
|
||||||
|
memFs := afero.NewMemMapFs()
|
||||||
|
for _, dir := range []string{"/media", "/proton-mount"} {
|
||||||
|
if err := memFs.Mkdir(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := NewFileInfo(&FileOptions{
|
||||||
|
Fs: inaccessibleChildFs{Fs: memFs, child: "/proton-mount"},
|
||||||
|
Path: "/",
|
||||||
|
Expand: true,
|
||||||
|
Checker: allowAllChecker{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.Listing == nil {
|
||||||
|
t.Fatal("expected root listing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := len(file.Items); got != 1 {
|
||||||
|
t.Fatalf("expected one accessible child, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := file.Items[0].Name; got != "media" {
|
||||||
|
t.Fatalf("expected accessible child to be listed, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := file.NumDirs; got != 1 {
|
||||||
|
t.Fatalf("expected one listed directory, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileInfoRealPathUsesScopedFsRealPath(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
file := &FileInfo{
|
||||||
|
Fs: NewScopedFs(afero.NewOsFs(), root),
|
||||||
|
Path: "/root/downloads",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := file.RealPath()
|
||||||
|
want := filepath.Join(root, "root", "downloads")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewFs verifies that NewFs picks the right implementation and that the
|
||||||
|
// follow-external-symlinks toggle flips whether a symlink pointing outside the
|
||||||
|
// scope is honored.
|
||||||
|
func TestNewFs(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
scope := filepath.Join(base, "srv")
|
||||||
|
outside := filepath.Join(base, "outside")
|
||||||
|
for _, d := range []string{scope, outside} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A symlink lexically inside the scope whose target resolves outside it.
|
||||||
|
if err := os.Symlink(outside, filepath.Join(scope, "escape")); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("disabled returns a ScopedFs that rejects the escaping symlink", func(t *testing.T) {
|
||||||
|
fs := NewFs(afero.NewOsFs(), scope, false)
|
||||||
|
if _, ok := fs.(*ScopedFs); !ok {
|
||||||
|
t.Fatalf("expected *ScopedFs, got %T", fs)
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat("/escape"); !os.IsPermission(err) {
|
||||||
|
t.Fatalf("expected stat of escaping symlink to be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("enabled returns a BasePathFs that follows the escaping symlink", func(t *testing.T) {
|
||||||
|
fs := NewFs(afero.NewOsFs(), scope, true)
|
||||||
|
if _, ok := fs.(*afero.BasePathFs); !ok {
|
||||||
|
t.Fatalf("expected *afero.BasePathFs, got %T", fs)
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat("/escape"); err != nil {
|
||||||
|
t.Fatalf("expected escaping symlink to be followed, got %v", err)
|
||||||
|
}
|
||||||
|
b, err := afero.ReadFile(fs, "/escape/secret.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected to read through escaping symlink, got %v", err)
|
||||||
|
}
|
||||||
|
if string(b) != "secret" {
|
||||||
|
t.Fatalf("got %q, want %q", b, "secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The link must also appear in a directory listing (the symptom in #5998).
|
||||||
|
entries, err := afero.ReadDir(fs, "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name() == "escape" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected escaping symlink to appear in the listing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBasePath verifies BasePath extracts the underlying *afero.BasePathFs from
|
||||||
|
// either filesystem NewFs may return, so User.FullPath keeps working.
|
||||||
|
func TestBasePath(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
osFs := afero.NewOsFs()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
followExternal bool
|
||||||
|
}{
|
||||||
|
{"ScopedFs", false},
|
||||||
|
{"BasePathFs", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
fs := NewFs(osFs, root, tc.followExternal)
|
||||||
|
base := BasePath(fs)
|
||||||
|
if base == nil {
|
||||||
|
t.Fatalf("expected non-nil base for %T", fs)
|
||||||
|
}
|
||||||
|
got := afero.FullBaseFsPath(base, "/x")
|
||||||
|
want := filepath.Join(root, "x")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FullBaseFsPath: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := BasePath(osFs); got != nil {
|
||||||
|
t.Fatalf("expected nil base for a plain OsFs, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileInfoRealPathUsesBasePathFsRealPath mirrors
|
||||||
|
// TestFileInfoRealPathUsesScopedFsRealPath for the follow-external-symlinks case,
|
||||||
|
// where the user filesystem is a bare BasePathFs.
|
||||||
|
func TestFileInfoRealPathUsesBasePathFsRealPath(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
file := &FileInfo{
|
||||||
|
Fs: NewFs(afero.NewOsFs(), root, true),
|
||||||
|
Path: "/root/downloads",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := file.RealPath()
|
||||||
|
want := filepath.Join(root, "root", "downloads")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScopedFs is an afero.Fs that confines every operation to a base directory and
|
||||||
|
// refuses to follow a symbolic link whose on-disk target resolves outside that
|
||||||
|
// base. It wraps an *afero.BasePathFs — which already provides the lexical
|
||||||
|
// confinement — and adds a per-operation scope check on every call that would
|
||||||
|
// dereference a symlink at the OS layer (open, stat, lstat, chmod, …).
|
||||||
|
type ScopedFs struct {
|
||||||
|
base *afero.BasePathFs
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ afero.Fs = (*ScopedFs)(nil)
|
||||||
|
_ afero.Lstater = (*ScopedFs)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxSymlinkHops bounds how many dangling symlinks within() will follow before
|
||||||
|
// giving up, so a pathological chain cannot loop forever. It mirrors the kernel
|
||||||
|
// MAXSYMLINKS limit; the operation is rejected once the bound is exceeded.
|
||||||
|
const maxSymlinkHops = 255
|
||||||
|
|
||||||
|
func NewScopedFs(source afero.Fs, path string) *ScopedFs {
|
||||||
|
if s, ok := source.(*ScopedFs); ok {
|
||||||
|
source = s.base
|
||||||
|
}
|
||||||
|
return &ScopedFs{base: afero.NewBasePathFs(source, path).(*afero.BasePathFs)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFs builds a user filesystem rooted at path. When followExternal is true it
|
||||||
|
// returns a bare BasePathFs, so symlinks whose target resolves outside the scope
|
||||||
|
// are followed; otherwise it returns a ScopedFs that refuses to follow them.
|
||||||
|
func NewFs(source afero.Fs, path string, followExternal bool) afero.Fs {
|
||||||
|
if followExternal {
|
||||||
|
return afero.NewBasePathFs(source, path)
|
||||||
|
}
|
||||||
|
return NewScopedFs(source, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasePath returns the underlying *afero.BasePathFs of a user filesystem built
|
||||||
|
// by NewFs, whether it is a *ScopedFs or a bare *afero.BasePathFs, or nil if it
|
||||||
|
// is neither.
|
||||||
|
func BasePath(fs afero.Fs) *afero.BasePathFs {
|
||||||
|
switch f := fs.(type) {
|
||||||
|
case *ScopedFs:
|
||||||
|
return f.BasePathFs()
|
||||||
|
case *afero.BasePathFs:
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasePathFs returns the underlying *afero.BasePathFs.
|
||||||
|
func (s *ScopedFs) BasePathFs() *afero.BasePathFs { return s.base }
|
||||||
|
|
||||||
|
// RealPath resolves a scoped path to the real on-disk path by delegating to
|
||||||
|
// the underlying BasePathFs. This is needed by callers that need the actual
|
||||||
|
// filesystem path (e.g. disk.UsageWithContext).
|
||||||
|
func (s *ScopedFs) RealPath(name string) (string, error) {
|
||||||
|
return s.base.RealPath(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard returns an error if name's on-disk target resolves outside the scope.
|
||||||
|
func (s *ScopedFs) guard(name string) error {
|
||||||
|
ok, err := s.within(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// within reports whether the on-disk target of p — after resolving any symbolic
|
||||||
|
// links — stays within the scoped root. It exists to stop a symlink that lives
|
||||||
|
// lexically inside the scope but points outside it from being followed for
|
||||||
|
// reads, writes, or shares.
|
||||||
|
//
|
||||||
|
// Paths that do not exist yet (e.g. a brand-new file being created) are
|
||||||
|
// validated against their nearest existing ancestor, so legitimate new files
|
||||||
|
// are always allowed. A dangling symlink — a link whose target does not exist
|
||||||
|
// yet — is the exception: it is followed to where it points and validated
|
||||||
|
// there, so a write cannot dereference the link to create a file outside the
|
||||||
|
// scope.
|
||||||
|
func (s *ScopedFs) within(p string) (bool, error) {
|
||||||
|
root, err := filepath.EvalSymlinks(afero.FullBaseFsPath(s.base, "/"))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
target := afero.FullBaseFsPath(s.base, p)
|
||||||
|
resolved, err := filepath.EvalSymlinks(target)
|
||||||
|
// When target does not resolve, work out where the operation would actually
|
||||||
|
// land. A non-existent regular path resolves to the file that would be
|
||||||
|
// created inside its containing directory, so walk up to the nearest
|
||||||
|
// existing ancestor and validate that. But when target itself is a dangling
|
||||||
|
// symlink, follow it one level instead: validating its lexical parent would
|
||||||
|
// wrongly accept a link pointing outside the scope, letting a write follow
|
||||||
|
// the link and create the file out of bounds.
|
||||||
|
for hops := 0; errors.Is(err, fs.ErrNotExist); {
|
||||||
|
if fi, lerr := os.Lstat(target); lerr == nil && fi.Mode()&os.ModeSymlink != 0 {
|
||||||
|
hops++
|
||||||
|
if hops > maxSymlinkHops {
|
||||||
|
return false, os.ErrPermission
|
||||||
|
}
|
||||||
|
dest, rerr := os.Readlink(target)
|
||||||
|
if rerr != nil {
|
||||||
|
return false, rerr
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(dest) {
|
||||||
|
// Resolve the link relative to the directory that really contains
|
||||||
|
// it, not its lexical parent: a symlinked ancestor could otherwise
|
||||||
|
// shift the computed target back into scope while the real write
|
||||||
|
// lands outside it. The parent is guaranteed to resolve here
|
||||||
|
// because os.Lstat above already traversed it.
|
||||||
|
base, berr := filepath.EvalSymlinks(filepath.Dir(target))
|
||||||
|
if berr != nil {
|
||||||
|
return false, berr
|
||||||
|
}
|
||||||
|
dest = filepath.Join(base, dest)
|
||||||
|
}
|
||||||
|
target = filepath.Clean(dest)
|
||||||
|
} else {
|
||||||
|
parent := filepath.Dir(target)
|
||||||
|
if parent == target {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
target = parent
|
||||||
|
}
|
||||||
|
resolved, err = filepath.EvalSymlinks(target)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare against root with a trailing separator so a sibling like
|
||||||
|
// "/srvother" is not treated as being inside "/srv". When root is itself the
|
||||||
|
// filesystem boundary (e.g. "/"), it already ends in a separator, so avoid
|
||||||
|
// producing "//" — which no path would match — and accept any path under it.
|
||||||
|
prefix := root
|
||||||
|
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
|
||||||
|
prefix += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved == root || strings.HasPrefix(resolved, prefix), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) Create(name string) (afero.File, error) {
|
||||||
|
if err := s.guard(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.base.Create(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) Mkdir(name string, perm os.FileMode) error {
|
||||||
|
if err := s.guard(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.base.Mkdir(name, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) MkdirAll(path string, perm os.FileMode) error {
|
||||||
|
if err := s.guard(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.base.MkdirAll(path, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) Open(name string) (afero.File, error) {
|
||||||
|
if err := s.guard(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.base.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
|
||||||
|
if err := s.guard(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.base.OpenFile(name, flag, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) Remove(name string) error {
|
||||||
|
if err := s.guard(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.base.Remove(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) RemoveAll(path string) error {
|
||||||
|
if err := s.guard(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.base.RemoveAll(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) Rename(oldname, newname string) error {
|
||||||
|
if err := s.guard(oldname); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.guard(newname); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.base.Rename(oldname, newname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) Stat(name string) (os.FileInfo, error) {
|
||||||
|
if err := s.guard(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.base.Stat(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) Name() string { return "ScopedFs" }
|
||||||
|
|
||||||
|
func (s *ScopedFs) Chmod(name string, mode os.FileMode) error {
|
||||||
|
if err := s.guard(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.base.Chmod(name, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) Chown(name string, uid, gid int) error {
|
||||||
|
if err := s.guard(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.base.Chown(name, uid, gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) Chtimes(name string, atime, mtime time.Time) error {
|
||||||
|
if err := s.guard(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.base.Chtimes(name, atime, mtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScopedFs) LstatIfPossible(name string) (os.FileInfo, bool, error) {
|
||||||
|
if err := s.guard(name); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return s.base.LstatIfPossible(name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package fileutils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/filebrowser/filebrowser/v2/files"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Copying an in-scope directory that contains a symlink whose target escapes
|
||||||
|
// the user's scope must not dereference that symlink into the destination.
|
||||||
|
// Otherwise a scoped user could exfiltrate out-of-scope file content via the
|
||||||
|
// recursive copy path (GHSA-c2gv-wf5f-hjhh, an incomplete fix of
|
||||||
|
// GHSA-239w-m3h6-ch8v).
|
||||||
|
func TestCopyDoesNotDereferenceEscapingSymlink(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
scope := filepath.Join(base, "scope")
|
||||||
|
if err := os.MkdirAll(filepath.Join(scope, "srcdir"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A secret living outside the scope.
|
||||||
|
secret := filepath.Join(base, "secret.txt")
|
||||||
|
if err := os.WriteFile(secret, []byte("OUT-OF-SCOPE-SECRET"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An escaping symlink planted inside the user's scope.
|
||||||
|
if err := os.Symlink(secret, filepath.Join(scope, "srcdir", "link.txt")); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
afs := files.NewScopedFs(afero.NewOsFs(), scope)
|
||||||
|
|
||||||
|
err := Copy(afs, "/srcdir", "/dstdir", 0o644, 0o755)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected copy of a directory containing an escaping symlink to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The escaping symlink's target content must not have landed in scope.
|
||||||
|
if data, readErr := afero.ReadFile(afs, "/dstdir/link.txt"); readErr == nil {
|
||||||
|
t.Fatalf("escaping symlink was dereferenced into scope: got %q", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A symlink whose target stays within scope is legitimate and must still be
|
||||||
|
// copied (dereferenced) so the fix does not over-block normal usage.
|
||||||
|
func TestCopyAllowsInScopeSymlink(t *testing.T) {
|
||||||
|
scope := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(scope, "srcdir", "real"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(scope, "srcdir", "real", "f.txt"), []byte("in-scope"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(scope, "srcdir", "real", "f.txt"), filepath.Join(scope, "srcdir", "link.txt")); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
afs := files.NewScopedFs(afero.NewOsFs(), scope)
|
||||||
|
|
||||||
|
if err := Copy(afs, "/srcdir", "/dstdir", 0o644, 0o755); err != nil {
|
||||||
|
t.Fatalf("expected copy of an in-scope symlink to succeed, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := afero.ReadFile(afs, "/dstdir/link.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected in-scope symlink to be copied, got: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "in-scope" {
|
||||||
|
t.Fatalf("unexpected copied content: %q", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
//go:build dev
|
|
||||||
|
|
||||||
package frontend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
var assets fs.FS = os.DirFS("frontend")
|
|
||||||
|
|
||||||
func Assets() fs.FS {
|
|
||||||
return assets
|
|
||||||
}
|
|
||||||
@@ -39,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,
|
||||||
|
|||||||
+12
-9
@@ -14,13 +14,15 @@
|
|||||||
"typecheck": "vue-tsc -p ./tsconfig.app.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": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chenfengyuan/vue-number-input": "^2.0.1",
|
"@chenfengyuan/vue-number-input": "^2.0.1",
|
||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
"@vueuse/integrations": "^14.0.0",
|
"@vueuse/integrations": "^14.0.0",
|
||||||
"ace-builds": "^1.43.2",
|
"ace-builds": "^1.43.2",
|
||||||
|
"csv-parse": "^6.1.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",
|
||||||
@@ -28,7 +30,8 @@
|
|||||||
"js-base64": "^3.7.7",
|
"js-base64": "^3.7.7",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"marked": "^17.0.0",
|
"marked": "^18.0.0",
|
||||||
|
"marked-katex-extension": "^5.1.6",
|
||||||
"material-icons": "^1.13.14",
|
"material-icons": "^1.13.14",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
@@ -40,11 +43,10 @@
|
|||||||
"videojs-hotkeys": "^0.2.28",
|
"videojs-hotkeys": "^0.2.28",
|
||||||
"videojs-mobile-ui": "^1.1.1",
|
"videojs-mobile-ui": "^1.1.1",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.17",
|
||||||
"vue-final-modal": "^4.5.5",
|
|
||||||
"vue-i18n": "^11.1.10",
|
"vue-i18n": "^11.1.10",
|
||||||
"vue-lazyload": "^3.0.0",
|
"vue-lazyload": "^3.0.0",
|
||||||
"vue-reader": "^1.2.17",
|
"vue-reader": "^1.2.17",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^5.0.0",
|
||||||
"vue-toastification": "^2.0.0-rc.5"
|
"vue-toastification": "^2.0.0-rc.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -53,13 +55,13 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||||
"@vitejs/plugin-legacy": "^7.2.1",
|
"@vitejs/plugin-legacy": "^8.0.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@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.8.1",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.31.0",
|
"eslint": "^10.0.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": "^10.5.1",
|
"eslint-plugin-vue": "^10.5.1",
|
||||||
@@ -67,9 +69,10 @@
|
|||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"terser": "^5.43.1",
|
"terser": "^5.43.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.2",
|
"vite": "^8.0.0",
|
||||||
"vite-plugin-compression2": "^2.3.1",
|
"vite-plugin-compression2": "^2.3.1",
|
||||||
|
"vitest": "^4.1.0",
|
||||||
"vue-tsc": "^3.1.3"
|
"vue-tsc": "^3.1.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c"
|
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2811
-1717
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,25 @@ import { useLayoutStore } from "@/stores/layout";
|
|||||||
import { baseURL } from "@/utils/constants";
|
import { baseURL } from "@/utils/constants";
|
||||||
import { upload as postTus, useTus } from "./tus";
|
import { upload as postTus, useTus } from "./tus";
|
||||||
import { createURL, fetchURL, removePrefix, StatusError } from "./utils";
|
import { createURL, fetchURL, removePrefix, StatusError } from "./utils";
|
||||||
|
import { isEncodableResponse, makeRawResource } from "@/utils/encodings";
|
||||||
|
|
||||||
export async function fetch(url: string, signal?: AbortSignal) {
|
export async function fetch(url: string, signal?: AbortSignal) {
|
||||||
|
const encoding = isEncodableResponse(url);
|
||||||
url = removePrefix(url);
|
url = removePrefix(url);
|
||||||
const res = await fetchURL(`/api/resources${url}`, { signal });
|
const res = await fetchURL(`/api/resources${url}`, {
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
"X-Encoding": encoding ? "true" : "false",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
let data: Resource;
|
let data: Resource;
|
||||||
try {
|
try {
|
||||||
data = (await res.json()) as Resource;
|
if (res.headers.get("Content-Type") == "application/octet-stream") {
|
||||||
|
data = await makeRawResource(res, url);
|
||||||
|
} else {
|
||||||
|
data = (await res.json()) as Resource;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Check if the error is an intentional cancellation
|
// Check if the error is an intentional cancellation
|
||||||
if (e instanceof Error && e.name === "AbortError") {
|
if (e instanceof Error && e.name === "AbortError") {
|
||||||
@@ -38,6 +49,12 @@ export async function fetch(url: string, signal?: AbortSignal) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAll(url: string): Promise<RecursiveEntry[]> {
|
||||||
|
url = removePrefix(url);
|
||||||
|
const res = await fetchURL(`/api/resources/recursive${url}`, {});
|
||||||
|
return (await res.json()) as RecursiveEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
async function resourceAction(url: string, method: ApiMethod, content?: any) {
|
async function resourceAction(url: string, method: ApiMethod, content?: any) {
|
||||||
url = removePrefix(url);
|
url = removePrefix(url);
|
||||||
|
|
||||||
@@ -140,9 +157,9 @@ async function postResources(
|
|||||||
if (request.status === 200) {
|
if (request.status === 200) {
|
||||||
resolve(request.responseText);
|
resolve(request.responseText);
|
||||||
} else if (request.status === 409) {
|
} else if (request.status === 409) {
|
||||||
reject(request.status);
|
reject(new Error(request.status.toString()));
|
||||||
} else {
|
} else {
|
||||||
reject(request.responseText);
|
reject(new Error(request.responseText));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,9 +183,12 @@ function moveCopy(
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const from = item.from;
|
const from = item.from;
|
||||||
const to = encodeURIComponent(removePrefix(item.to ?? ""));
|
const to = encodeURIComponent(removePrefix(item.to ?? ""));
|
||||||
|
const finalOverwrite =
|
||||||
|
item.overwrite == undefined ? overwrite : item.overwrite;
|
||||||
|
const finalRename = item.rename == undefined ? rename : item.rename;
|
||||||
const url = `${from}?action=${
|
const url = `${from}?action=${
|
||||||
copy ? "copy" : "rename"
|
copy ? "copy" : "rename"
|
||||||
}&destination=${to}&override=${overwrite}&rename=${rename}`;
|
}&destination=${to}&override=${finalOverwrite}&rename=${finalRename}`;
|
||||||
promises.push(resourceAction(url, "PATCH"));
|
promises.push(resourceAction(url, "PATCH"));
|
||||||
}
|
}
|
||||||
layoutStore.closeHovers();
|
layoutStore.closeHovers();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+61
-15
@@ -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 } : {}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import { vClickOutside } from "@/utils/index";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
position?: "top-left" | "bottom-left" | "bottom-right" | "top-right";
|
||||||
|
closeOnClick?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
position: "bottom-right",
|
||||||
|
closeOnClick: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const isOpen = defineModel<boolean>();
|
||||||
|
|
||||||
|
const triggerRef = ref<HTMLElement | null>(null);
|
||||||
|
const listRef = ref<HTMLElement | null>(null);
|
||||||
|
const dropdownStyle = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!isOpen.value || !triggerRef.value) return;
|
||||||
|
|
||||||
|
const rect = triggerRef.value.getBoundingClientRect();
|
||||||
|
|
||||||
|
dropdownStyle.value = {
|
||||||
|
position: "fixed",
|
||||||
|
top: props.position.includes("bottom")
|
||||||
|
? `${rect.bottom + 2}px`
|
||||||
|
: `${rect.top}px`,
|
||||||
|
left: props.position.includes("left") ? `${rect.left}px` : "auto",
|
||||||
|
right: props.position.includes("right")
|
||||||
|
? `${window.innerWidth - rect.right}px`
|
||||||
|
: "auto",
|
||||||
|
zIndex: "11000",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
updatePosition();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onWindowChange = () => {
|
||||||
|
updatePosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = (e: Event) => {
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLElement &&
|
||||||
|
listRef.value?.contains(e.target) &&
|
||||||
|
!props.closeOnClick
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("resize", onWindowChange);
|
||||||
|
window.addEventListener("scroll", onWindowChange, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("resize", onWindowChange);
|
||||||
|
window.removeEventListener("scroll", onWindowChange, true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
directives: {
|
||||||
|
clickOutside: vClickOutside,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="dropdown-modal-container"
|
||||||
|
v-click-outside="closeDropdown"
|
||||||
|
ref="triggerRef"
|
||||||
|
>
|
||||||
|
<button @click="isOpen = !isOpen" class="dropdown-modal-trigger">
|
||||||
|
<slot></slot>
|
||||||
|
<i class="material-icons">chevron_right</i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<teleport to="body">
|
||||||
|
<div
|
||||||
|
ref="listRef"
|
||||||
|
class="dropdown-modal-list"
|
||||||
|
:class="{ 'dropdown-modal-open': isOpen }"
|
||||||
|
:style="dropdownStyle"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<slot name="list"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dropdown-modal-trigger {
|
||||||
|
background: var(--surfacePrimary);
|
||||||
|
color: var(--textSecondary);
|
||||||
|
border: 1px solid var(--borderPrimary);
|
||||||
|
border-radius: 0.1em;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
transition: 0.2s ease all;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-modal-trigger > i {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-modal-list {
|
||||||
|
padding: 0.25rem;
|
||||||
|
background-color: var(--surfacePrimary);
|
||||||
|
color: var(--textSecondary);
|
||||||
|
display: none;
|
||||||
|
border: 1px solid var(--borderPrimary);
|
||||||
|
border-radius: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-modal-list > div {
|
||||||
|
max-height: 450px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-modal-open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
<!-- This component taken directly from vue-simple-progress
|
||||||
|
since it didnt support Vue 3 but the component itself does
|
||||||
|
https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/components/Progress.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@@ -41,164 +44,182 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { computed } from "vue";
|
// We're leaving this untouched as you can read in the beginning
|
||||||
|
const isNumber = function (n) {
|
||||||
const isNumber = (n: number | string): boolean => {
|
return !isNaN(parseFloat(n)) && isFinite(n);
|
||||||
return !isNaN(parseFloat(n as string)) && isFinite(n as number);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = withDefaults(
|
export default {
|
||||||
defineProps<{
|
name: "progress-bar",
|
||||||
val?: number;
|
props: {
|
||||||
max?: number;
|
val: {
|
||||||
size?: number | string;
|
default: 0,
|
||||||
bgColor?: string;
|
},
|
||||||
barColor?: string;
|
max: {
|
||||||
barTransition?: string;
|
default: 100,
|
||||||
barBorderRadius?: number;
|
},
|
||||||
spacing?: number;
|
size: {
|
||||||
text?: string;
|
// either a number (pixel width/height) or 'tiny', 'small',
|
||||||
textAlign?: string;
|
// 'medium', 'large', 'huge', 'massive' for common sizes
|
||||||
textPosition?: string;
|
default: 3,
|
||||||
fontSize?: number;
|
},
|
||||||
textFgColor?: string;
|
"bg-color": {
|
||||||
}>(),
|
type: String,
|
||||||
{
|
default: "#eee",
|
||||||
val: 0,
|
},
|
||||||
max: 100,
|
"bar-color": {
|
||||||
size: 3,
|
type: String,
|
||||||
bgColor: "#eee",
|
default: "#2196f3", // match .blue color to Material Design's 'Blue 500' color
|
||||||
barColor: "#2196f3",
|
},
|
||||||
barTransition: "all 0.5s ease",
|
"bar-transition": {
|
||||||
barBorderRadius: 0,
|
type: String,
|
||||||
spacing: 4,
|
default: "all 0.5s ease",
|
||||||
text: "",
|
},
|
||||||
textAlign: "center",
|
"bar-border-radius": {
|
||||||
textPosition: "bottom",
|
type: Number,
|
||||||
fontSize: 13,
|
default: 0,
|
||||||
textFgColor: "#222",
|
},
|
||||||
}
|
spacing: {
|
||||||
);
|
type: Number,
|
||||||
|
default: 4,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
"text-align": {
|
||||||
|
type: String,
|
||||||
|
default: "center", // 'left', 'right'
|
||||||
|
},
|
||||||
|
"text-position": {
|
||||||
|
type: String,
|
||||||
|
default: "bottom", // 'bottom', 'top', 'middle', 'inside'
|
||||||
|
},
|
||||||
|
"font-size": {
|
||||||
|
type: Number,
|
||||||
|
default: 13,
|
||||||
|
},
|
||||||
|
"text-fg-color": {
|
||||||
|
type: String,
|
||||||
|
default: "#222",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
pct() {
|
||||||
|
let pct = (this.val / this.max) * 100;
|
||||||
|
pct = pct.toFixed(2);
|
||||||
|
return Math.min(pct, this.max);
|
||||||
|
},
|
||||||
|
size_px() {
|
||||||
|
switch (this.size) {
|
||||||
|
case "tiny":
|
||||||
|
return 2;
|
||||||
|
case "small":
|
||||||
|
return 4;
|
||||||
|
case "medium":
|
||||||
|
return 8;
|
||||||
|
case "large":
|
||||||
|
return 12;
|
||||||
|
case "big":
|
||||||
|
return 16;
|
||||||
|
case "huge":
|
||||||
|
return 32;
|
||||||
|
case "massive":
|
||||||
|
return 64;
|
||||||
|
}
|
||||||
|
|
||||||
const pct = computed(() => {
|
return isNumber(this.size) ? this.size : 32;
|
||||||
const pct = (props.val / props.max) * 100;
|
},
|
||||||
const pctFixed = pct.toFixed(2);
|
text_padding() {
|
||||||
return Math.min(parseFloat(pctFixed), props.max);
|
switch (this.size) {
|
||||||
});
|
case "tiny":
|
||||||
|
case "small":
|
||||||
|
case "medium":
|
||||||
|
case "large":
|
||||||
|
case "big":
|
||||||
|
case "huge":
|
||||||
|
case "massive":
|
||||||
|
return Math.min(Math.max(Math.ceil(this.size_px / 8), 3), 12);
|
||||||
|
}
|
||||||
|
|
||||||
const size_px = computed(() => {
|
return isNumber(this.spacing) ? this.spacing : 4;
|
||||||
switch (props.size) {
|
},
|
||||||
case "tiny":
|
text_font_size() {
|
||||||
return 2;
|
switch (this.size) {
|
||||||
case "small":
|
case "tiny":
|
||||||
return 4;
|
case "small":
|
||||||
case "medium":
|
case "medium":
|
||||||
return 8;
|
case "large":
|
||||||
case "large":
|
case "big":
|
||||||
return 12;
|
case "huge":
|
||||||
case "big":
|
case "massive":
|
||||||
return 16;
|
return Math.min(Math.max(Math.ceil(this.size_px * 1.4), 11), 32);
|
||||||
case "huge":
|
}
|
||||||
return 32;
|
|
||||||
case "massive":
|
|
||||||
return 64;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isNumber(props.size) ? (props.size as number) : 32;
|
return isNumber(this.fontSize) ? this.fontSize : 13;
|
||||||
});
|
},
|
||||||
|
progress_style() {
|
||||||
|
const style = {
|
||||||
|
background: this.bgColor,
|
||||||
|
};
|
||||||
|
|
||||||
const text_padding = computed(() => {
|
if (this.textPosition == "middle" || this.textPosition == "inside") {
|
||||||
switch (props.size) {
|
style["position"] = "relative";
|
||||||
case "tiny":
|
style["min-height"] = this.size_px + "px";
|
||||||
case "small":
|
style["z-index"] = "-2";
|
||||||
case "medium":
|
}
|
||||||
case "large":
|
|
||||||
case "big":
|
|
||||||
case "huge":
|
|
||||||
case "massive":
|
|
||||||
return Math.min(Math.max(Math.ceil(size_px.value / 8), 3), 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isNumber(props.spacing) ? props.spacing : 4;
|
if (this.barBorderRadius > 0) {
|
||||||
});
|
style["border-radius"] = this.barBorderRadius + "px";
|
||||||
|
}
|
||||||
|
|
||||||
const text_font_size = computed(() => {
|
return style;
|
||||||
switch (props.size) {
|
},
|
||||||
case "tiny":
|
bar_style() {
|
||||||
case "small":
|
const style = {
|
||||||
case "medium":
|
background: this.barColor,
|
||||||
case "large":
|
width: this.pct + "%",
|
||||||
case "big":
|
height: this.size_px + "px",
|
||||||
case "huge":
|
transition: this.barTransition,
|
||||||
case "massive":
|
};
|
||||||
return Math.min(Math.max(Math.ceil(size_px.value * 1.4), 11), 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isNumber(props.fontSize) ? props.fontSize : 13;
|
if (this.barBorderRadius > 0) {
|
||||||
});
|
style["border-radius"] = this.barBorderRadius + "px";
|
||||||
|
}
|
||||||
|
|
||||||
const progress_style = computed(() => {
|
if (this.textPosition == "middle" || this.textPosition == "inside") {
|
||||||
const style: Record<string, string> = {
|
style["position"] = "absolute";
|
||||||
background: props.bgColor,
|
style["top"] = "0";
|
||||||
};
|
style["height"] = "100%";
|
||||||
|
((style["min-height"] = this.size_px + "px"),
|
||||||
|
(style["z-index"] = "-1"));
|
||||||
|
}
|
||||||
|
|
||||||
if (props.textPosition == "middle" || props.textPosition == "inside") {
|
return style;
|
||||||
style["position"] = "relative";
|
},
|
||||||
style["min-height"] = size_px.value + "px";
|
text_style() {
|
||||||
style["z-index"] = "-2";
|
const style = {
|
||||||
}
|
color: this.textFgColor,
|
||||||
|
"font-size": this.text_font_size + "px",
|
||||||
|
"text-align": this.textAlign,
|
||||||
|
};
|
||||||
|
|
||||||
if (props.barBorderRadius > 0) {
|
if (
|
||||||
style["border-radius"] = props.barBorderRadius + "px";
|
this.textPosition == "top" ||
|
||||||
}
|
this.textPosition == "middle" ||
|
||||||
|
this.textPosition == "inside"
|
||||||
|
)
|
||||||
|
style["padding-bottom"] = this.text_padding + "px";
|
||||||
|
if (
|
||||||
|
this.textPosition == "bottom" ||
|
||||||
|
this.textPosition == "middle" ||
|
||||||
|
this.textPosition == "inside"
|
||||||
|
)
|
||||||
|
style["padding-top"] = this.text_padding + "px";
|
||||||
|
|
||||||
return style;
|
return style;
|
||||||
});
|
},
|
||||||
|
},
|
||||||
const bar_style = computed(() => {
|
};
|
||||||
const style: Record<string, string> = {
|
|
||||||
background: props.barColor,
|
|
||||||
width: pct.value + "%",
|
|
||||||
height: size_px.value + "px",
|
|
||||||
transition: props.barTransition,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (props.barBorderRadius > 0) {
|
|
||||||
style["border-radius"] = props.barBorderRadius + "px";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.textPosition == "middle" || props.textPosition == "inside") {
|
|
||||||
style["position"] = "absolute";
|
|
||||||
style["top"] = "0";
|
|
||||||
style["height"] = "100%";
|
|
||||||
style["min-height"] = size_px.value + "px";
|
|
||||||
style["z-index"] = "-1";
|
|
||||||
}
|
|
||||||
|
|
||||||
return style;
|
|
||||||
});
|
|
||||||
|
|
||||||
const text_style = computed(() => {
|
|
||||||
const style: Record<string, string> = {
|
|
||||||
color: props.textFgColor,
|
|
||||||
"font-size": text_font_size.value + "px",
|
|
||||||
"text-align": props.textAlign,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
props.textPosition == "top" ||
|
|
||||||
props.textPosition == "middle" ||
|
|
||||||
props.textPosition == "inside"
|
|
||||||
)
|
|
||||||
style["padding-bottom"] = text_padding.value + "px";
|
|
||||||
if (
|
|
||||||
props.textPosition == "bottom" ||
|
|
||||||
props.textPosition == "middle" ||
|
|
||||||
props.textPosition == "inside"
|
|
||||||
)
|
|
||||||
style["padding-top"] = text_padding.value + "px";
|
|
||||||
|
|
||||||
return style;
|
|
||||||
});
|
|
||||||
</script>
|
</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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+140
-164
@@ -2,13 +2,13 @@
|
|||||||
<div
|
<div
|
||||||
class="shell"
|
class="shell"
|
||||||
:class="{ ['shell--hidden']: !showShell }"
|
:class="{ ['shell--hidden']: !showShell }"
|
||||||
:style="{ height: `${shellHeight}em`, direction: 'ltr' }"
|
:style="{ height: `${this.shellHeight}em`, direction: 'ltr' }"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@pointerdown="startDrag()"
|
@pointerdown="startDrag()"
|
||||||
@pointerup="stopDrag()"
|
@pointerup="stopDrag()"
|
||||||
class="shell__divider"
|
class="shell__divider"
|
||||||
:style="shellDrag ? { background: `${checkTheme()}` } : ''"
|
:style="this.shellDrag ? { background: `${checkTheme()}` } : ''"
|
||||||
></div>
|
></div>
|
||||||
<div @click="focus" class="shell__content" ref="scrollable">
|
<div @click="focus" class="shell__content" ref="scrollable">
|
||||||
<div v-for="(c, index) in content" :key="index" class="shell__result">
|
<div v-for="(c, index) in content" :key="index" class="shell__result">
|
||||||
@@ -39,15 +39,13 @@
|
|||||||
<div
|
<div
|
||||||
@pointerup="stopDrag()"
|
@pointerup="stopDrag()"
|
||||||
class="shell__overlay"
|
class="shell__overlay"
|
||||||
v-show="shellDrag"
|
v-show="this.shellDrag"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
import { mapState, mapActions } from "pinia";
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
import { useFileStore } from "@/stores/file";
|
import { useFileStore } from "@/stores/file";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
|
||||||
@@ -55,164 +53,142 @@ import { commands } from "@/api";
|
|||||||
import { throttle } from "lodash-es";
|
import { throttle } from "lodash-es";
|
||||||
import { theme } from "@/utils/constants";
|
import { theme } from "@/utils/constants";
|
||||||
|
|
||||||
const route = useRoute();
|
export default {
|
||||||
|
name: "shell",
|
||||||
|
computed: {
|
||||||
|
...mapState(useLayoutStore, ["showShell"]),
|
||||||
|
...mapState(useFileStore, ["isFiles"]),
|
||||||
|
path: function () {
|
||||||
|
if (this.isFiles) {
|
||||||
|
return this.$route.path;
|
||||||
|
}
|
||||||
|
|
||||||
const fileStore = useFileStore();
|
return "";
|
||||||
const layoutStore = useLayoutStore();
|
|
||||||
|
|
||||||
const { showShell } = storeToRefs(layoutStore);
|
|
||||||
const { isFiles } = storeToRefs(fileStore);
|
|
||||||
const { toggleShell } = layoutStore;
|
|
||||||
|
|
||||||
const scrollable = ref<HTMLElement | null>(null);
|
|
||||||
const input = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const content = ref<Array<{ text: string }>>([]);
|
|
||||||
const history = ref<string[]>([]);
|
|
||||||
const historyPos = ref(0);
|
|
||||||
const canInput = ref(true);
|
|
||||||
const shellDrag = ref(false);
|
|
||||||
const shellHeight = ref(25);
|
|
||||||
const fontsize = ref(
|
|
||||||
parseFloat(getComputedStyle(document.documentElement).fontSize)
|
|
||||||
);
|
|
||||||
|
|
||||||
const path = computed(() => {
|
|
||||||
if (isFiles.value) {
|
|
||||||
return route.path;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkTheme = () => {
|
|
||||||
if (theme == "dark") {
|
|
||||||
return "rgba(255, 255, 255, 0.4)";
|
|
||||||
}
|
|
||||||
return "rgba(127, 127, 127, 0.4)";
|
|
||||||
};
|
|
||||||
|
|
||||||
const scroll = () => {
|
|
||||||
if (scrollable.value) {
|
|
||||||
scrollable.value.scrollTop = scrollable.value.scrollHeight;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const focus = () => {
|
|
||||||
input.value?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrag = throttle((event: PointerEvent) => {
|
|
||||||
const top = window.innerHeight / fontsize.value - 4;
|
|
||||||
const userPos = (window.innerHeight - event.clientY) / fontsize.value;
|
|
||||||
const divider = document.querySelector(".shell__divider") as HTMLElement;
|
|
||||||
const bottom = 2.25 + (divider?.offsetHeight ?? 0) / fontsize.value;
|
|
||||||
|
|
||||||
if (userPos <= top && userPos >= bottom) {
|
|
||||||
shellHeight.value = parseFloat(userPos.toFixed(2));
|
|
||||||
}
|
|
||||||
}, 32);
|
|
||||||
|
|
||||||
const resize = throttle(() => {
|
|
||||||
const top = window.innerHeight / fontsize.value - 4;
|
|
||||||
const divider = document.querySelector(".shell__divider") as HTMLElement;
|
|
||||||
const bottom = 2.25 + (divider?.offsetHeight ?? 0) / fontsize.value;
|
|
||||||
|
|
||||||
if (shellHeight.value > top) {
|
|
||||||
shellHeight.value = top;
|
|
||||||
} else if (shellHeight.value < bottom) {
|
|
||||||
shellHeight.value = bottom;
|
|
||||||
}
|
|
||||||
}, 32);
|
|
||||||
|
|
||||||
const startDrag = () => {
|
|
||||||
document.addEventListener("pointermove", handleDrag as any);
|
|
||||||
shellDrag.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopDrag = () => {
|
|
||||||
document.removeEventListener("pointermove", handleDrag as any);
|
|
||||||
shellDrag.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const historyUp = () => {
|
|
||||||
if (historyPos.value > 0 && input.value) {
|
|
||||||
historyPos.value--;
|
|
||||||
input.value.innerText = history.value[historyPos.value];
|
|
||||||
focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const historyDown = () => {
|
|
||||||
if (
|
|
||||||
historyPos.value >= 0 &&
|
|
||||||
historyPos.value < history.value.length - 1 &&
|
|
||||||
input.value
|
|
||||||
) {
|
|
||||||
historyPos.value++;
|
|
||||||
input.value.innerText = history.value[historyPos.value];
|
|
||||||
focus();
|
|
||||||
} else {
|
|
||||||
historyPos.value = history.value.length;
|
|
||||||
if (input.value) {
|
|
||||||
input.value.innerText = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = (event: Event) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
const cmd = target.innerText.trim();
|
|
||||||
|
|
||||||
if (cmd === "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmd === "clear") {
|
|
||||||
content.value = [];
|
|
||||||
target.innerHTML = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmd === "exit") {
|
|
||||||
target.innerHTML = "";
|
|
||||||
toggleShell();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
canInput.value = false;
|
|
||||||
target.innerHTML = "";
|
|
||||||
|
|
||||||
const results = {
|
|
||||||
text: `${cmd}\n\n`,
|
|
||||||
};
|
|
||||||
|
|
||||||
history.value.push(cmd);
|
|
||||||
historyPos.value = history.value.length;
|
|
||||||
content.value.push(results);
|
|
||||||
|
|
||||||
commands(
|
|
||||||
path.value,
|
|
||||||
cmd,
|
|
||||||
(event: MessageEvent) => {
|
|
||||||
results.text += `${event.data}\n`;
|
|
||||||
scroll();
|
|
||||||
},
|
},
|
||||||
() => {
|
},
|
||||||
results.text = results.text
|
data: () => ({
|
||||||
.replace(/\u001b\[[0-9;]+m/g, "") // Filter ANSI color for now
|
content: [],
|
||||||
.trimEnd();
|
history: [],
|
||||||
canInput.value = true;
|
historyPos: 0,
|
||||||
input.value?.focus();
|
canInput: true,
|
||||||
scroll();
|
shellDrag: false,
|
||||||
}
|
shellHeight: 25,
|
||||||
);
|
fontsize: parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||||||
|
}),
|
||||||
|
mounted() {
|
||||||
|
window.addEventListener("resize", this.resize);
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
window.removeEventListener("resize", this.resize);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["toggleShell"]),
|
||||||
|
checkTheme() {
|
||||||
|
if (theme == "dark") {
|
||||||
|
return "rgba(255, 255, 255, 0.4)";
|
||||||
|
}
|
||||||
|
return "rgba(127, 127, 127, 0.4)";
|
||||||
|
},
|
||||||
|
startDrag() {
|
||||||
|
document.addEventListener("pointermove", this.handleDrag);
|
||||||
|
this.shellDrag = true;
|
||||||
|
},
|
||||||
|
stopDrag() {
|
||||||
|
document.removeEventListener("pointermove", this.handleDrag);
|
||||||
|
this.shellDrag = false;
|
||||||
|
},
|
||||||
|
handleDrag: throttle(function (event) {
|
||||||
|
const top = window.innerHeight / this.fontsize - 4;
|
||||||
|
const userPos = (window.innerHeight - event.clientY) / this.fontsize;
|
||||||
|
const bottom =
|
||||||
|
2.25 +
|
||||||
|
document.querySelector(".shell__divider").offsetHeight / this.fontsize;
|
||||||
|
|
||||||
|
if (userPos <= top && userPos >= bottom) {
|
||||||
|
this.shellHeight = userPos.toFixed(2);
|
||||||
|
}
|
||||||
|
}, 32),
|
||||||
|
resize: throttle(function () {
|
||||||
|
const top = window.innerHeight / this.fontsize - 4;
|
||||||
|
const bottom =
|
||||||
|
2.25 +
|
||||||
|
document.querySelector(".shell__divider").offsetHeight / this.fontsize;
|
||||||
|
|
||||||
|
if (this.shellHeight > top) {
|
||||||
|
this.shellHeight = top;
|
||||||
|
} else if (this.shellHeight < bottom) {
|
||||||
|
this.shellHeight = bottom;
|
||||||
|
}
|
||||||
|
}, 32),
|
||||||
|
scroll: function () {
|
||||||
|
this.$refs.scrollable.scrollTop = this.$refs.scrollable.scrollHeight;
|
||||||
|
},
|
||||||
|
focus: function () {
|
||||||
|
this.$refs.input.focus();
|
||||||
|
},
|
||||||
|
historyUp() {
|
||||||
|
if (this.historyPos > 0) {
|
||||||
|
this.$refs.input.innerText = this.history[--this.historyPos];
|
||||||
|
this.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
historyDown() {
|
||||||
|
if (this.historyPos >= 0 && this.historyPos < this.history.length - 1) {
|
||||||
|
this.$refs.input.innerText = this.history[++this.historyPos];
|
||||||
|
this.focus();
|
||||||
|
} else {
|
||||||
|
this.historyPos = this.history.length;
|
||||||
|
this.$refs.input.innerText = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submit: function (event) {
|
||||||
|
const cmd = event.target.innerText.trim();
|
||||||
|
|
||||||
|
if (cmd === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd === "clear") {
|
||||||
|
this.content = [];
|
||||||
|
event.target.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd === "exit") {
|
||||||
|
event.target.innerHTML = "";
|
||||||
|
this.toggleShell();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canInput = false;
|
||||||
|
event.target.innerHTML = "";
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
text: `${cmd}\n\n`,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.history.push(cmd);
|
||||||
|
this.historyPos = this.history.length;
|
||||||
|
this.content.push(results);
|
||||||
|
|
||||||
|
commands(
|
||||||
|
this.path,
|
||||||
|
cmd,
|
||||||
|
(event) => {
|
||||||
|
results.text += `${event.data}\n`;
|
||||||
|
this.scroll();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
results.text = results.text
|
||||||
|
|
||||||
|
.replace(/\u001b\[[0-9;]+m/g, "") // Filter ANSI color for now
|
||||||
|
.trimEnd();
|
||||||
|
this.canInput = true;
|
||||||
|
this.$refs.input.focus();
|
||||||
|
this.scroll();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener("resize", resize as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener("resize", resize as any);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<template v-if="isLoggedIn">
|
<template v-if="isLoggedIn">
|
||||||
<button @click="toAccountSettings" class="action">
|
<button @click="toAccountSettings" class="action">
|
||||||
<i class="material-icons">person</i>
|
<i class="material-icons">person</i>
|
||||||
<span>{{ user?.username }}</span>
|
<span>{{ user.username }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="action"
|
class="action"
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<span>{{ $t("sidebar.myFiles") }}</span>
|
<span>{{ $t("sidebar.myFiles") }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="user?.perm.create">
|
<div v-if="user.perm.create">
|
||||||
<button
|
<button
|
||||||
@click="showHover('newDir')"
|
@click="showHover('newDir')"
|
||||||
class="action"
|
class="action"
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="user?.perm.admin">
|
<div v-if="user.perm.admin">
|
||||||
<button
|
<button
|
||||||
class="action"
|
class="action"
|
||||||
@click="toGlobalSettings"
|
@click="toGlobalSettings"
|
||||||
@@ -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')"
|
||||||
@@ -91,7 +92,7 @@
|
|||||||
>
|
>
|
||||||
<progress-bar :val="usage.usedPercentage" size="small"></progress-bar>
|
<progress-bar :val="usage.usedPercentage" size="small"></progress-bar>
|
||||||
<br />
|
<br />
|
||||||
{{ usage.used }} of {{ usage.total }} used
|
{{ $t("sidebar.diskUsed", { used: usage.used, total: usage.total }) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="credits">
|
<p class="credits">
|
||||||
@@ -113,10 +114,9 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { reactive, ref, computed, watch, onUnmounted } from "vue";
|
import { reactive } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { mapActions, mapState } from "pinia";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
|
||||||
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 { useLayoutStore } from "@/stores/layout";
|
||||||
@@ -125,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";
|
||||||
@@ -136,85 +138,85 @@ import prettyBytes from "pretty-bytes";
|
|||||||
|
|
||||||
const USAGE_DEFAULT = { used: "0 B", total: "0 B", usedPercentage: 0 };
|
const USAGE_DEFAULT = { used: "0 B", total: "0 B", usedPercentage: 0 };
|
||||||
|
|
||||||
const route = useRoute();
|
export default {
|
||||||
const router = useRouter();
|
name: "sidebar",
|
||||||
|
setup() {
|
||||||
const authStore = useAuthStore();
|
const usage = reactive(USAGE_DEFAULT);
|
||||||
const fileStore = useFileStore();
|
return { usage, usageAbortController: new AbortController() };
|
||||||
const layoutStore = useLayoutStore();
|
|
||||||
|
|
||||||
const { user, isLoggedIn } = storeToRefs(authStore);
|
|
||||||
const { isFiles } = storeToRefs(fileStore);
|
|
||||||
const { currentPromptName } = storeToRefs(layoutStore);
|
|
||||||
const { closeHovers, showHover } = layoutStore;
|
|
||||||
|
|
||||||
const usage = reactive(USAGE_DEFAULT);
|
|
||||||
const usageAbortController = ref(new AbortController());
|
|
||||||
|
|
||||||
const active = computed(() => {
|
|
||||||
return currentPromptName.value === "sidebar";
|
|
||||||
});
|
|
||||||
|
|
||||||
const canLogout = !noAuth && loginPage;
|
|
||||||
|
|
||||||
const abortOngoingFetchUsage = () => {
|
|
||||||
usageAbortController.value.abort();
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchUsage = async () => {
|
|
||||||
const path = route.path.endsWith("/") ? route.path : route.path + "/";
|
|
||||||
let usageStats = USAGE_DEFAULT;
|
|
||||||
if (disableUsedPercentage) {
|
|
||||||
return Object.assign(usage, usageStats);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
abortOngoingFetchUsage();
|
|
||||||
usageAbortController.value = new AbortController();
|
|
||||||
const usageData = await api.usage(path, usageAbortController.value.signal);
|
|
||||||
usageStats = {
|
|
||||||
used: prettyBytes(usageData.used, { binary: true }),
|
|
||||||
total: prettyBytes(usageData.total, { binary: true }),
|
|
||||||
usedPercentage: Math.round((usageData.used / usageData.total) * 100),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
return Object.assign(usage, usageStats);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toRoot = () => {
|
|
||||||
router.push({ path: "/files" });
|
|
||||||
closeHovers();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toAccountSettings = () => {
|
|
||||||
router.push({ path: "/settings/profile" });
|
|
||||||
closeHovers();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toGlobalSettings = () => {
|
|
||||||
router.push({ path: "/settings/global" });
|
|
||||||
closeHovers();
|
|
||||||
};
|
|
||||||
|
|
||||||
const help = () => {
|
|
||||||
showHover("help");
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
auth.logout();
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.path,
|
|
||||||
(newPath) => {
|
|
||||||
if (newPath.includes("/files")) {
|
|
||||||
fetchUsage();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
components: {
|
||||||
);
|
ProgressBar,
|
||||||
|
},
|
||||||
onUnmounted(() => {
|
inject: ["$showError"],
|
||||||
abortOngoingFetchUsage();
|
computed: {
|
||||||
});
|
...mapState(useAuthStore, ["user", "isLoggedIn"]),
|
||||||
|
...mapState(useFileStore, ["isFiles", "reload"]),
|
||||||
|
...mapState(useLayoutStore, ["currentPromptName"]),
|
||||||
|
active() {
|
||||||
|
return this.currentPromptName === "sidebar";
|
||||||
|
},
|
||||||
|
signup: () => signup,
|
||||||
|
hideLoginButton: () => hideLoginButton,
|
||||||
|
version: () => version,
|
||||||
|
disableExternal: () => disableExternal,
|
||||||
|
disableUsedPercentage: () => disableUsedPercentage,
|
||||||
|
canLogout: () => !noAuth && (loginPage || logoutPage !== "/login"),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
|
||||||
|
abortOngoingFetchUsage() {
|
||||||
|
this.usageAbortController.abort();
|
||||||
|
},
|
||||||
|
async fetchUsage() {
|
||||||
|
const path = this.$route.path.endsWith("/")
|
||||||
|
? this.$route.path
|
||||||
|
: this.$route.path + "/";
|
||||||
|
let usageStats = USAGE_DEFAULT;
|
||||||
|
if (this.disableUsedPercentage) {
|
||||||
|
return Object.assign(this.usage, usageStats);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.abortOngoingFetchUsage();
|
||||||
|
this.usageAbortController = new AbortController();
|
||||||
|
const usage = await api.usage(path, this.usageAbortController.signal);
|
||||||
|
usageStats = {
|
||||||
|
used: prettyBytes(usage.used, { binary: true }),
|
||||||
|
total: prettyBytes(usage.total, { binary: true }),
|
||||||
|
usedPercentage: Math.round((usage.used / usage.total) * 100),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
return Object.assign(this.usage, usageStats);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toRoot() {
|
||||||
|
this.$router.push({ path: "/files" });
|
||||||
|
this.closeHovers();
|
||||||
|
},
|
||||||
|
toAccountSettings() {
|
||||||
|
this.$router.push({ path: "/settings/profile" });
|
||||||
|
this.closeHovers();
|
||||||
|
},
|
||||||
|
toGlobalSettings() {
|
||||||
|
this.$router.push({ path: "/settings/global" });
|
||||||
|
this.closeHovers();
|
||||||
|
},
|
||||||
|
help() {
|
||||||
|
this.showHover("help");
|
||||||
|
},
|
||||||
|
logout: auth.logout,
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
$route: {
|
||||||
|
handler(to) {
|
||||||
|
if (to.path.includes("/files")) {
|
||||||
|
this.fetchUsage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unmounted() {
|
||||||
|
this.abortOngoingFetchUsage();
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
<template>
|
||||||
|
<div class="csv-viewer">
|
||||||
|
<div class="csv-header">
|
||||||
|
<div class="header-select">
|
||||||
|
<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 class="header-select" v-if="isEncodedContent">
|
||||||
|
<label for="fileEncoding">{{ $t("files.fileEncoding") }}</label>
|
||||||
|
<DropdownModal
|
||||||
|
v-model="isEncondingDropdownOpen"
|
||||||
|
:close-on-click="false"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="selected-encoding">{{ selectedEncoding }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-slot:list>
|
||||||
|
<input
|
||||||
|
v-model="encodingSearch"
|
||||||
|
:placeholder="$t('search.search')"
|
||||||
|
class="input input--block"
|
||||||
|
name="encoding"
|
||||||
|
/>
|
||||||
|
<div class="encoding-list">
|
||||||
|
<div v-if="encodingList.length == 0" class="message">
|
||||||
|
<i class="material-icons">sentiment_dissatisfied</i>
|
||||||
|
<span>{{ $t("files.lonely") }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="encoding in encodingList"
|
||||||
|
:value="encoding"
|
||||||
|
:key="encoding"
|
||||||
|
class="encoding-button"
|
||||||
|
@click="selectedEncoding = encoding"
|
||||||
|
>
|
||||||
|
{{ encoding }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DropdownModal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="displayError" class="csv-error">
|
||||||
|
<i class="material-icons">error</i>
|
||||||
|
<p>{{ displayError }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="parsed.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 parsed.headers" :key="index">
|
||||||
|
{{ header || `Column ${index + 1}` }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, rowIndex) in parsed.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="parsed.rows.length > 100">
|
||||||
|
<i class="material-icons">info</i>
|
||||||
|
<span>
|
||||||
|
{{ $t("files.showingRows", { count: parsed.rows.length }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch, watchEffect } from "vue";
|
||||||
|
import { parse } from "csv-parse/browser/esm";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { availableEncodings, decode } from "@/utils/encodings";
|
||||||
|
import DropdownModal from "../DropdownModal.vue";
|
||||||
|
|
||||||
|
const { t } = useI18n({});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: ArrayBuffer | string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEncondingDropdownOpen = ref(false);
|
||||||
|
|
||||||
|
const encodingSearch = ref<string>("");
|
||||||
|
|
||||||
|
const encodingList = computed(() => {
|
||||||
|
return availableEncodings.filter((e) =>
|
||||||
|
e.toLowerCase().includes(encodingSearch.value.toLowerCase())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnSeparator = ref([",", ";"]);
|
||||||
|
|
||||||
|
const selectedEncoding = ref("utf-8");
|
||||||
|
|
||||||
|
const parsed = ref<CsvData>({ headers: [], rows: [] });
|
||||||
|
|
||||||
|
const displayError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const isEncodedContent = computed(() => {
|
||||||
|
return props.content instanceof ArrayBuffer;
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.content !== "" && columnSeparator.value.length > 0) {
|
||||||
|
const content = isEncodedContent.value
|
||||||
|
? decode(props.content as ArrayBuffer, selectedEncoding.value)
|
||||||
|
: props.content;
|
||||||
|
parse(
|
||||||
|
content as string,
|
||||||
|
{
|
||||||
|
delimiter: columnSeparator.value,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
relax_column_count: true,
|
||||||
|
},
|
||||||
|
(error, output) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to parse CSV:", error);
|
||||||
|
parsed.value = { headers: [], rows: [] };
|
||||||
|
displayError.value = t("files.csvLoadFailed", {
|
||||||
|
error: error.toString(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
parsed.value = {
|
||||||
|
headers: output[0],
|
||||||
|
rows: output.slice(1),
|
||||||
|
};
|
||||||
|
displayError.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(selectedEncoding, () => {
|
||||||
|
isEncondingDropdownOpen.value = false;
|
||||||
|
encodingSearch.value = "";
|
||||||
|
});
|
||||||
|
</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-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-direction: column;
|
||||||
|
@media (width >= 640px) {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-select > label {
|
||||||
|
font-size: small;
|
||||||
|
@media (width >= 640px) {
|
||||||
|
max-width: 70px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-select > select,
|
||||||
|
.header-select > div {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-info i {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.encoding-list {
|
||||||
|
max-height: 300px;
|
||||||
|
min-width: 120px;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encoding-button {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
color: var(--textPrimary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encoding-button:hover {
|
||||||
|
background-color: var(--surfaceSecondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-encoding {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 1.25em;
|
||||||
|
}
|
||||||
|
</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
|
||||||
@@ -177,6 +178,11 @@ const drop = async (event: Event) => {
|
|||||||
from: fileStore.req?.items[i].url,
|
from: fileStore.req?.items[i].url,
|
||||||
to: props.url + encodeURIComponent(fileStore.req?.items[i].name),
|
to: props.url + encodeURIComponent(fileStore.req?.items[i].name),
|
||||||
name: fileStore.req?.items[i].name,
|
name: fileStore.req?.items[i].name,
|
||||||
|
size: fileStore.req?.items[i].size,
|
||||||
|
isDir: fileStore.req?.items[i].isDir,
|
||||||
|
modified: fileStore.req?.items[i].modified,
|
||||||
|
overwrite: false,
|
||||||
|
rename: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,39 +192,50 @@ const drop = async (event: Event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const path = el.__vue__.url;
|
const path = el.__vue__.url;
|
||||||
const baseItems = (await api.fetch(path)).items;
|
|
||||||
|
|
||||||
const action = (overwrite: boolean, rename: boolean) => {
|
const action = (overwrite?: boolean, rename?: boolean) => {
|
||||||
api
|
const action =
|
||||||
.move(items, overwrite, rename)
|
(event as KeyboardEvent).ctrlKey || (event as KeyboardEvent).metaKey
|
||||||
|
? api.copy
|
||||||
|
: api.move;
|
||||||
|
action(items, overwrite, rename)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
fileStore.reload = true;
|
fileStore.reload = true;
|
||||||
})
|
})
|
||||||
.catch($showError);
|
.catch($showError);
|
||||||
};
|
};
|
||||||
|
|
||||||
const conflict = upload.checkConflict(items, baseItems);
|
const conflict = await upload.checkConflict(items, path, true);
|
||||||
|
|
||||||
let overwrite = false;
|
if (conflict.length > 0) {
|
||||||
let rename = false;
|
|
||||||
|
|
||||||
if (conflict) {
|
|
||||||
layoutStore.showHover({
|
layoutStore.showHover({
|
||||||
prompt: "replace-rename",
|
prompt: "resolve-conflict",
|
||||||
confirm: (event: Event, option: any) => {
|
props: {
|
||||||
overwrite = option == "overwrite";
|
conflict: conflict,
|
||||||
rename = option == "rename";
|
},
|
||||||
|
confirm: (event: Event, result: Array<ConflictingResource>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
layoutStore.closeHovers();
|
layoutStore.closeHovers();
|
||||||
action(overwrite, rename);
|
for (let i = result.length - 1; i >= 0; i--) {
|
||||||
|
const item = result[i];
|
||||||
|
if (item.checked.length == 2) {
|
||||||
|
items[item.index].rename = true;
|
||||||
|
} else if (item.checked.length == 1 && item.checked[0] == "origin") {
|
||||||
|
items[item.index].overwrite = true;
|
||||||
|
} else {
|
||||||
|
items.splice(item.index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
action();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
action(overwrite, rename);
|
action(false, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemClick = (event: Event | KeyboardEvent) => {
|
const itemClick = (event: Event | KeyboardEvent) => {
|
||||||
@@ -239,6 +256,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 +281,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 +315,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
|
||||||
|
|||||||
@@ -147,26 +147,33 @@ interface LanguageImports {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const languageImports: LanguageImports = {
|
const languageImports: LanguageImports = {
|
||||||
he: () => import("video.js/dist/lang/he.json"),
|
|
||||||
hu: () => import("video.js/dist/lang/hu.json"),
|
|
||||||
ar: () => import("video.js/dist/lang/ar.json"),
|
ar: () => import("video.js/dist/lang/ar.json"),
|
||||||
|
bg: () => import("video.js/dist/lang/bg.json"),
|
||||||
|
cs: () => import("video.js/dist/lang/cs.json"),
|
||||||
de: () => import("video.js/dist/lang/de.json"),
|
de: () => import("video.js/dist/lang/de.json"),
|
||||||
el: () => import("video.js/dist/lang/el.json"),
|
el: () => import("video.js/dist/lang/el.json"),
|
||||||
en: () => import("video.js/dist/lang/en.json"),
|
en: () => import("video.js/dist/lang/en.json"),
|
||||||
es: () => import("video.js/dist/lang/es.json"),
|
es: () => import("video.js/dist/lang/es.json"),
|
||||||
fr: () => import("video.js/dist/lang/fr.json"),
|
fr: () => import("video.js/dist/lang/fr.json"),
|
||||||
|
he: () => import("video.js/dist/lang/he.json"),
|
||||||
|
hr: () => import("video.js/dist/lang/hr.json"),
|
||||||
|
hu: () => import("video.js/dist/lang/hu.json"),
|
||||||
it: () => import("video.js/dist/lang/it.json"),
|
it: () => import("video.js/dist/lang/it.json"),
|
||||||
ja: () => import("video.js/dist/lang/ja.json"),
|
ja: () => import("video.js/dist/lang/ja.json"),
|
||||||
ko: () => import("video.js/dist/lang/ko.json"),
|
ko: () => import("video.js/dist/lang/ko.json"),
|
||||||
|
lv: () => import("video.js/dist/lang/lv.json"),
|
||||||
|
nb: () => import("video.js/dist/lang/nb.json"),
|
||||||
|
nl: () => import("video.js/dist/lang/nl.json"),
|
||||||
"nl-be": () => import("video.js/dist/lang/nl.json"),
|
"nl-be": () => import("video.js/dist/lang/nl.json"),
|
||||||
pl: () => import("video.js/dist/lang/pl.json"),
|
pl: () => import("video.js/dist/lang/pl.json"),
|
||||||
"pt-br": () => import("video.js/dist/lang/pt-BR.json"),
|
"pt-br": () => import("video.js/dist/lang/pt-BR.json"),
|
||||||
pt: () => import("video.js/dist/lang/pt-PT.json"),
|
"pt-pt": () => import("video.js/dist/lang/pt-PT.json"),
|
||||||
ro: () => import("video.js/dist/lang/ro.json"),
|
ro: () => import("video.js/dist/lang/ro.json"),
|
||||||
ru: () => import("video.js/dist/lang/ru.json"),
|
ru: () => import("video.js/dist/lang/ru.json"),
|
||||||
sk: () => import("video.js/dist/lang/sk.json"),
|
sk: () => import("video.js/dist/lang/sk.json"),
|
||||||
tr: () => import("video.js/dist/lang/tr.json"),
|
tr: () => import("video.js/dist/lang/tr.json"),
|
||||||
uk: () => import("video.js/dist/lang/uk.json"),
|
uk: () => import("video.js/dist/lang/uk.json"),
|
||||||
|
vi: () => import("video.js/dist/lang/vi.json"),
|
||||||
"zh-cn": () => import("video.js/dist/lang/zh-CN.json"),
|
"zh-cn": () => import("video.js/dist/lang/zh-CN.json"),
|
||||||
"zh-tw": () => import("video.js/dist/lang/zh-TW.json"),
|
"zh-tw": () => import("video.js/dist/lang/zh-TW.json"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<VueFinalModal
|
<div id="modal-background" @click="backgroundClick">
|
||||||
class="vfm-modal"
|
<div ref="modalContainer">
|
||||||
overlay-transition="vfm-fade"
|
<slot></slot>
|
||||||
content-transition="vfm-fade"
|
</div>
|
||||||
@closed="layoutStore.closeHovers"
|
</div>
|
||||||
:focus-trap="{
|
|
||||||
initialFocus: '#focus-prompt',
|
|
||||||
fallbackFocus: 'div.vfm__content',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</VueFinalModal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { VueFinalModal } from "vue-final-modal";
|
import { onMounted, ref } from "vue";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
|
||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
const emit = defineEmits(["closed"]);
|
||||||
|
|
||||||
|
const modalContainer = ref(null);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const element = document.querySelector("#focus-prompt") as HTMLElement | null;
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
} else if (modalContainer.value) {
|
||||||
|
(modalContainer.value as HTMLElement).focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const backgroundClick = (event: Event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.id == "modal-background") {
|
||||||
|
emit("closed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
emit("closed");
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#modal-background {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: #00000096;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: ease-in 150ms opacity-enter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes opacity-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<p>{{ $t("prompts.copyMessage") }}</p>
|
<p>{{ $t("prompts.copyMessage") }}</p>
|
||||||
<file-list
|
<file-list
|
||||||
ref="fileList"
|
ref="fileList"
|
||||||
@update:selected="(val: string) => (dest = val)"
|
@update:selected="(val) => (dest = val)"
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
class="card-action"
|
class="card-action"
|
||||||
style="display: flex; align-items: center; justify-content: space-between"
|
style="display: flex; align-items: center; justify-content: space-between"
|
||||||
>
|
>
|
||||||
<template v-if="user?.perm.create">
|
<template v-if="user.perm.create">
|
||||||
<button
|
<button
|
||||||
class="button button--flat"
|
class="button button--flat"
|
||||||
@click="fileList?.createDir()"
|
@click="$refs.fileList.createDir()"
|
||||||
:aria-label="$t('sidebar.newFolder')"
|
:aria-label="$t('sidebar.newFolder')"
|
||||||
:title="$t('sidebar.newFolder')"
|
:title="$t('sidebar.newFolder')"
|
||||||
style="justify-self: left"
|
style="justify-self: left"
|
||||||
@@ -53,10 +53,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { ref, inject } from "vue";
|
import { mapActions, mapState, mapWritableState } from "pinia";
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
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 { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
@@ -66,84 +64,100 @@ import buttons from "@/utils/buttons";
|
|||||||
import * as upload from "@/utils/upload";
|
import * as upload from "@/utils/upload";
|
||||||
import { removePrefix } from "@/api/utils";
|
import { removePrefix } from "@/api/utils";
|
||||||
|
|
||||||
const route = useRoute();
|
export default {
|
||||||
const router = useRouter();
|
name: "copy",
|
||||||
const $showError = inject<(error: unknown) => void>("$showError");
|
components: { FileList },
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
current: window.location.pathname,
|
||||||
|
dest: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: ["$showError"],
|
||||||
|
computed: {
|
||||||
|
...mapState(useFileStore, ["req", "selected"]),
|
||||||
|
...mapState(useAuthStore, ["user"]),
|
||||||
|
...mapWritableState(useFileStore, ["reload", "preselect"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["showHover", "closeHovers"]),
|
||||||
|
copy: async function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const items = [];
|
||||||
|
|
||||||
const fileStore = useFileStore();
|
// Create a new promise for each file.
|
||||||
const layoutStore = useLayoutStore();
|
for (const item of this.selected) {
|
||||||
const authStore = useAuthStore();
|
items.push({
|
||||||
|
from: this.req.items[item].url,
|
||||||
|
to: this.dest + encodeURIComponent(this.req.items[item].name),
|
||||||
|
name: this.req.items[item].name,
|
||||||
|
size: this.req.items[item].size,
|
||||||
|
isDir: this.req.items[item].isDir,
|
||||||
|
modified: this.req.items[item].modified,
|
||||||
|
overwrite: false,
|
||||||
|
rename: this.$route.path === this.dest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { req, selected } = storeToRefs(fileStore);
|
const action = async (overwrite, rename) => {
|
||||||
const { user } = storeToRefs(authStore);
|
buttons.loading("copy");
|
||||||
const { showHover, closeHovers } = layoutStore;
|
|
||||||
|
|
||||||
const fileList = ref<InstanceType<typeof FileList> | null>(null);
|
await api
|
||||||
const dest = ref<string | null>(null);
|
.copy(items, overwrite, rename)
|
||||||
|
.then(() => {
|
||||||
|
buttons.success("copy");
|
||||||
|
this.preselect = removePrefix(items[0].to);
|
||||||
|
|
||||||
const copy = async (event: Event) => {
|
if (this.$route.path === this.dest) {
|
||||||
event.preventDefault();
|
this.reload = true;
|
||||||
const items: Array<{ from: string; to: string; name: string }> = [];
|
|
||||||
|
|
||||||
// Create a new promise for each file.
|
return;
|
||||||
for (const item of selected.value) {
|
}
|
||||||
items.push({
|
|
||||||
from: req.value!.items[item].url,
|
|
||||||
to: dest.value! + encodeURIComponent(req.value!.items[item].name),
|
|
||||||
name: req.value!.items[item].name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = async (overwrite: boolean, rename: boolean) => {
|
if (this.user.redirectAfterCopyMove)
|
||||||
buttons.loading("copy");
|
this.$router.push({ path: this.dest });
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
buttons.done("copy");
|
||||||
|
this.$showError(e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
await api
|
const conflict = await upload.checkConflict(items, this.dest, true);
|
||||||
.copy(items, overwrite, rename)
|
|
||||||
.then(() => {
|
|
||||||
buttons.success("copy");
|
|
||||||
fileStore.preselect = removePrefix(items[0].to);
|
|
||||||
|
|
||||||
if (route.path === dest.value) {
|
if (conflict.length > 0) {
|
||||||
fileStore.reload = true;
|
this.showHover({
|
||||||
return;
|
prompt: "resolve-conflict",
|
||||||
}
|
props: {
|
||||||
|
conflict: conflict,
|
||||||
|
},
|
||||||
|
confirm: (event, result) => {
|
||||||
|
event.preventDefault();
|
||||||
|
this.closeHovers();
|
||||||
|
for (let i = result.length - 1; i >= 0; i--) {
|
||||||
|
const item = result[i];
|
||||||
|
if (item.checked.length == 2) {
|
||||||
|
items[item.index].rename = true;
|
||||||
|
} else if (
|
||||||
|
item.checked.length == 1 &&
|
||||||
|
item.checked[0] == "origin"
|
||||||
|
) {
|
||||||
|
items[item.index].overwrite = true;
|
||||||
|
} else {
|
||||||
|
items.splice(item.index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
router.push({ path: dest.value! });
|
return;
|
||||||
})
|
}
|
||||||
.catch((e) => {
|
|
||||||
buttons.done("copy");
|
|
||||||
$showError?.(e);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (route.path === dest.value) {
|
action(false, false);
|
||||||
closeHovers();
|
},
|
||||||
action(false, true);
|
},
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dstItems = (await api.fetch(dest.value!)).items;
|
|
||||||
const conflict = upload.checkConflict(items as any, dstItems);
|
|
||||||
|
|
||||||
let overwrite = false;
|
|
||||||
let rename = false;
|
|
||||||
|
|
||||||
if (conflict) {
|
|
||||||
showHover({
|
|
||||||
prompt: "replace-rename",
|
|
||||||
confirm: (event: Event, option: string) => {
|
|
||||||
overwrite = option == "overwrite";
|
|
||||||
rename = option == "rename";
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
closeHovers();
|
|
||||||
action(overwrite, rename);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
action(overwrite, rename);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick, defineProps } from "vue";
|
import { ref, computed, watch, nextTick } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { useFileStore } from "@/stores/file";
|
import { useFileStore } from "@/stores/file";
|
||||||
import url from "@/utils/url";
|
import url from "@/utils/url";
|
||||||
@@ -35,12 +35,17 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
path: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const container = ref<HTMLElement | null>(null);
|
const container = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
const path = computed(() => {
|
const path = computed(() => {
|
||||||
let basePath = fileStore.isFiles ? route.path : url.removeLastDir(route.path);
|
const routePath = props.path || route.path;
|
||||||
|
let basePath = fileStore.isFiles ? routePath : url.removeLastDir(routePath);
|
||||||
if (!basePath.endsWith("/")) {
|
if (!basePath.endsWith("/")) {
|
||||||
basePath += "/";
|
basePath += "/";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card floating">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>{{ $t("prompts.currentPassword") }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
{{ $t("prompts.currentPasswordMessage") }}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
id="focus-prompt"
|
||||||
|
class="input input--block"
|
||||||
|
type="password"
|
||||||
|
@keyup.enter="submit"
|
||||||
|
v-model="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-action">
|
||||||
|
<button
|
||||||
|
class="button button--flat button--grey"
|
||||||
|
@click="cancel"
|
||||||
|
:aria-label="$t('buttons.cancel')"
|
||||||
|
:title="$t('buttons.cancel')"
|
||||||
|
>
|
||||||
|
{{ $t("buttons.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="submit"
|
||||||
|
class="button button--flat"
|
||||||
|
type="submit"
|
||||||
|
:aria-label="$t('buttons.ok')"
|
||||||
|
:title="$t('buttons.ok')"
|
||||||
|
>
|
||||||
|
{{ $t("buttons.ok") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
|
|
||||||
|
const { currentPrompt } = layoutStore;
|
||||||
|
|
||||||
|
const password = ref("");
|
||||||
|
|
||||||
|
const submit = (event: Event) => {
|
||||||
|
currentPrompt?.confirm(event, password.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
layoutStore.closeHovers();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card floating">
|
<div class="card floating">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<p v-if="!isListing || selectedCount === 1">
|
<p v-if="!this.isListing || selectedCount === 1">
|
||||||
{{ $t("prompts.deleteMessageSingle") }}
|
{{ $t("prompts.deleteMessageSingle") }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
@@ -32,62 +32,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { inject } from "vue";
|
import { mapActions, mapState, mapWritableState } from "pinia";
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
import { files as api } from "@/api";
|
import { files as api } from "@/api";
|
||||||
import buttons from "@/utils/buttons";
|
import buttons from "@/utils/buttons";
|
||||||
import { useFileStore } from "@/stores/file";
|
import { useFileStore } from "@/stores/file";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
|
||||||
const route = useRoute();
|
export default {
|
||||||
const $showError = inject<(error: unknown) => void>("$showError");
|
name: "delete",
|
||||||
|
inject: ["$showError"],
|
||||||
|
computed: {
|
||||||
|
...mapState(useFileStore, [
|
||||||
|
"isListing",
|
||||||
|
"selectedCount",
|
||||||
|
"req",
|
||||||
|
"selected",
|
||||||
|
]),
|
||||||
|
...mapState(useLayoutStore, ["currentPrompt"]),
|
||||||
|
...mapWritableState(useFileStore, ["reload", "preselect"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
|
submit: async function () {
|
||||||
|
buttons.loading("delete");
|
||||||
|
|
||||||
const fileStore = useFileStore();
|
try {
|
||||||
const layoutStore = useLayoutStore();
|
if (!this.isListing) {
|
||||||
|
await api.remove(this.$route.path);
|
||||||
|
buttons.success("delete");
|
||||||
|
|
||||||
const { isListing, selectedCount, req, selected } = storeToRefs(fileStore);
|
this.currentPrompt?.confirm();
|
||||||
const { currentPrompt } = storeToRefs(layoutStore);
|
this.closeHovers();
|
||||||
const { closeHovers } = layoutStore;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
this.closeHovers();
|
||||||
buttons.loading("delete");
|
|
||||||
|
|
||||||
try {
|
if (this.selectedCount === 0) {
|
||||||
if (!isListing.value) {
|
return;
|
||||||
await api.remove(route.path);
|
}
|
||||||
buttons.success("delete");
|
|
||||||
|
|
||||||
currentPrompt.value?.confirm();
|
const promises = [];
|
||||||
closeHovers();
|
for (const index of this.selected) {
|
||||||
return;
|
promises.push(api.remove(this.req.items[index].url));
|
||||||
}
|
}
|
||||||
|
|
||||||
closeHovers();
|
await Promise.all(promises);
|
||||||
|
buttons.success("delete");
|
||||||
|
|
||||||
if (selectedCount.value === 0) {
|
const nearbyItem =
|
||||||
return;
|
this.req.items[Math.max(0, Math.min(this.selected) - 1)];
|
||||||
}
|
|
||||||
|
|
||||||
const promises = [];
|
this.preselect = nearbyItem?.path;
|
||||||
for (const index of selected.value) {
|
|
||||||
promises.push(api.remove(req.value!.items[index].url));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
this.reload = true;
|
||||||
buttons.success("delete");
|
} catch (e) {
|
||||||
|
buttons.done("delete");
|
||||||
const nearbyItem =
|
this.$showError(e);
|
||||||
req.value!.items[Math.max(0, Math.min(...selected.value) - 1)];
|
if (this.isListing) this.reload = true;
|
||||||
|
}
|
||||||
fileStore.preselect = nearbyItem?.path;
|
},
|
||||||
|
},
|
||||||
fileStore.reload = true;
|
|
||||||
} catch (e) {
|
|
||||||
buttons.done("delete");
|
|
||||||
$showError?.(e);
|
|
||||||
if (isListing.value) fileStore.reload = true;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -33,4 +33,8 @@ import { useI18n } from "vue-i18n";
|
|||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// const emit = defineEmits<{
|
||||||
|
// (e: "confirm"): void;
|
||||||
|
// }>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="button button--flat button--blue"
|
class="button button--flat button--blue"
|
||||||
@click="currentPrompt?.saveAction"
|
@click="currentPrompt.saveAction"
|
||||||
:aria-label="$t('buttons.saveChanges')"
|
:aria-label="$t('buttons.saveChanges')"
|
||||||
:title="$t('buttons.saveChanges')"
|
:title="$t('buttons.saveChanges')"
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</button>
|
</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')"
|
||||||
@@ -38,11 +38,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { mapActions, mapState } from "pinia";
|
||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
export default {
|
||||||
const { currentPrompt } = storeToRefs(layoutStore);
|
name: "discardEditorChanges",
|
||||||
const { closeHovers } = layoutStore;
|
computed: {
|
||||||
|
...mapState(useLayoutStore, ["currentPrompt"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -24,10 +24,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { ref, computed, inject, onMounted, onUnmounted } from "vue";
|
import { mapState, mapActions } from "pinia";
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
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 { useLayoutStore } from "@/stores/layout";
|
||||||
@@ -36,162 +34,153 @@ import url from "@/utils/url";
|
|||||||
import { files } from "@/api";
|
import { files } from "@/api";
|
||||||
import { StatusError } from "@/api/utils.js";
|
import { StatusError } from "@/api/utils.js";
|
||||||
|
|
||||||
const props = defineProps<{
|
export default {
|
||||||
exclude?: string[];
|
name: "file-list",
|
||||||
}>();
|
props: {
|
||||||
|
exclude: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
touches: {
|
||||||
|
id: "",
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
|
selected: null,
|
||||||
|
current: window.location.pathname,
|
||||||
|
nextAbortController: new AbortController(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: ["$showError"],
|
||||||
|
computed: {
|
||||||
|
...mapState(useAuthStore, ["user"]),
|
||||||
|
...mapState(useFileStore, ["req"]),
|
||||||
|
nav() {
|
||||||
|
return decodeURIComponent(this.current);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fillOptions(this.req);
|
||||||
|
},
|
||||||
|
unmounted() {
|
||||||
|
this.abortOngoingNext();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["showHover"]),
|
||||||
|
abortOngoingNext() {
|
||||||
|
this.nextAbortController.abort();
|
||||||
|
},
|
||||||
|
fillOptions(req) {
|
||||||
|
// Sets the current path and resets
|
||||||
|
// the current items.
|
||||||
|
this.current = req.url;
|
||||||
|
this.items = [];
|
||||||
|
|
||||||
const emit = defineEmits<{
|
this.$emit("update:selected", this.current);
|
||||||
"update:selected": [value: string];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const route = useRoute();
|
// If the path isn't the root path,
|
||||||
const $showError = inject<(error: unknown) => void>("$showError");
|
// show a button to navigate to the previous
|
||||||
|
// directory.
|
||||||
|
if (req.url !== "/files/") {
|
||||||
|
this.items.push({
|
||||||
|
name: "..",
|
||||||
|
url: url.removeLastDir(req.url) + "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
// If this folder is empty, finish here.
|
||||||
const fileStore = useFileStore();
|
if (req.items === null) return;
|
||||||
const layoutStore = useLayoutStore();
|
|
||||||
|
|
||||||
const { user } = storeToRefs(authStore);
|
// Otherwise we add every directory to the
|
||||||
const { req } = storeToRefs(fileStore);
|
// move options.
|
||||||
const { showHover } = layoutStore;
|
for (const item of req.items) {
|
||||||
|
if (!item.isDir) continue;
|
||||||
|
if (this.exclude?.includes(item.url)) continue;
|
||||||
|
|
||||||
const items = ref<Array<{ name: string; url: string }>>([]);
|
this.items.push({
|
||||||
const touches = ref({
|
name: item.name,
|
||||||
id: "",
|
url: item.url,
|
||||||
count: 0,
|
});
|
||||||
});
|
}
|
||||||
const selected = ref<string | null>(null);
|
},
|
||||||
const current = ref(window.location.pathname);
|
next: function (event) {
|
||||||
const nextAbortController = ref(new AbortController());
|
// Retrieves the URL of the directory the user
|
||||||
|
// just clicked in and fill the options with its
|
||||||
|
// content.
|
||||||
|
const uri = event.currentTarget.dataset.url;
|
||||||
|
this.abortOngoingNext();
|
||||||
|
this.nextAbortController = new AbortController();
|
||||||
|
files
|
||||||
|
.fetch(uri, this.nextAbortController.signal)
|
||||||
|
.then(this.fillOptions)
|
||||||
|
.catch((e) => {
|
||||||
|
if (e instanceof StatusError && e.is_canceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$showError(e);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
touchstart(event) {
|
||||||
|
const url = event.currentTarget.dataset.url;
|
||||||
|
|
||||||
const nav = computed(() => {
|
// In 300 milliseconds, we shall reset the count.
|
||||||
return decodeURIComponent(current.value);
|
setTimeout(() => {
|
||||||
});
|
this.touches.count = 0;
|
||||||
|
}, 300);
|
||||||
|
|
||||||
const abortOngoingNext = () => {
|
// If the element the user is touching
|
||||||
nextAbortController.value.abort();
|
// is different from the last one he touched,
|
||||||
};
|
// reset the count.
|
||||||
|
if (this.touches.id !== url) {
|
||||||
const fillOptions = (reqData: any) => {
|
this.touches.id = url;
|
||||||
// Sets the current path and resets
|
this.touches.count = 1;
|
||||||
// the current items.
|
|
||||||
current.value = reqData.url;
|
|
||||||
items.value = [];
|
|
||||||
|
|
||||||
emit("update:selected", current.value);
|
|
||||||
|
|
||||||
// If the path isn't the root path,
|
|
||||||
// show a button to navigate to the previous
|
|
||||||
// directory.
|
|
||||||
if (reqData.url !== "/files/") {
|
|
||||||
items.value.push({
|
|
||||||
name: "..",
|
|
||||||
url: url.removeLastDir(reqData.url) + "/",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this folder is empty, finish here.
|
|
||||||
if (reqData.items === null) return;
|
|
||||||
|
|
||||||
// Otherwise we add every directory to the
|
|
||||||
// move options.
|
|
||||||
for (const item of reqData.items) {
|
|
||||||
if (!item.isDir) continue;
|
|
||||||
if (props.exclude?.includes(item.url)) continue;
|
|
||||||
|
|
||||||
items.value.push({
|
|
||||||
name: item.name,
|
|
||||||
url: item.url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const next = (event: Event) => {
|
|
||||||
// Retrieves the URL of the directory the user
|
|
||||||
// just clicked in and fill the options with its
|
|
||||||
// content.
|
|
||||||
const uri = (event.currentTarget as HTMLElement).dataset.url!;
|
|
||||||
abortOngoingNext();
|
|
||||||
nextAbortController.value = new AbortController();
|
|
||||||
files
|
|
||||||
.fetch(uri, nextAbortController.value.signal)
|
|
||||||
.then(fillOptions)
|
|
||||||
.catch((e) => {
|
|
||||||
if (e instanceof StatusError && e.is_canceled) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$showError?.(e);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const touchstart = (event: Event) => {
|
this.touches.count++;
|
||||||
const urlValue = (event.currentTarget as HTMLElement).dataset.url!;
|
|
||||||
|
|
||||||
// In 300 milliseconds, we shall reset the count.
|
// If there is more than one touch already,
|
||||||
setTimeout(() => {
|
// open the next screen.
|
||||||
touches.value.count = 0;
|
if (this.touches.count > 1) {
|
||||||
}, 300);
|
this.next(event);
|
||||||
|
}
|
||||||
// If the element the user is touching
|
|
||||||
// is different from the last one he touched,
|
|
||||||
// reset the count.
|
|
||||||
if (touches.value.id !== urlValue) {
|
|
||||||
touches.value.id = urlValue;
|
|
||||||
touches.value.count = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
touches.value.count++;
|
|
||||||
|
|
||||||
// If there is more than one touch already,
|
|
||||||
// open the next screen.
|
|
||||||
if (touches.value.count > 1) {
|
|
||||||
next(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemClick = (event: Event) => {
|
|
||||||
if (user.value?.singleClick) next(event);
|
|
||||||
else select(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
const select = (event: Event) => {
|
|
||||||
const urlValue = (event.currentTarget as HTMLElement).dataset.url!;
|
|
||||||
// If the element is already selected, unselect it.
|
|
||||||
if (selected.value === urlValue) {
|
|
||||||
selected.value = null;
|
|
||||||
emit("update:selected", current.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise select the element.
|
|
||||||
selected.value = urlValue;
|
|
||||||
emit("update:selected", selected.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createDir = async () => {
|
|
||||||
showHover({
|
|
||||||
prompt: "newDir",
|
|
||||||
action: undefined,
|
|
||||||
confirm: undefined,
|
|
||||||
props: {
|
|
||||||
redirect: false,
|
|
||||||
base: current.value === route.path ? null : current.value,
|
|
||||||
},
|
},
|
||||||
});
|
itemClick: function (event) {
|
||||||
|
if (this.user.singleClick) this.next(event);
|
||||||
|
else this.select(event);
|
||||||
|
},
|
||||||
|
select: function (event) {
|
||||||
|
// If the element is already selected, unselect it.
|
||||||
|
if (this.selected === event.currentTarget.dataset.url) {
|
||||||
|
this.selected = null;
|
||||||
|
this.$emit("update:selected", this.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise select the element.
|
||||||
|
this.selected = event.currentTarget.dataset.url;
|
||||||
|
this.$emit("update:selected", this.selected);
|
||||||
|
},
|
||||||
|
createDir: async function () {
|
||||||
|
this.showHover({
|
||||||
|
prompt: "newDir",
|
||||||
|
action: null,
|
||||||
|
confirm: (url) => {
|
||||||
|
const paths = url.split("/");
|
||||||
|
this.items.push({
|
||||||
|
name: paths[paths.length - 2],
|
||||||
|
url: url,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
redirect: false,
|
||||||
|
base: this.current === this.$route.path ? null : this.current,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (req.value) {
|
|
||||||
fillOptions(req.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
abortOngoingNext();
|
|
||||||
});
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
createDir,
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,8 +34,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
|
import { mapActions } from "pinia";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
|
||||||
const { closeHovers } = useLayoutStore();
|
export default {
|
||||||
|
name: "help",
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -29,10 +29,10 @@
|
|||||||
|
|
||||||
<template v-if="dir && selected.length === 0">
|
<template v-if="dir && selected.length === 0">
|
||||||
<p>
|
<p>
|
||||||
<strong>{{ $t("prompts.numberFiles") }}:</strong> {{ req?.numFiles }}
|
<strong>{{ $t("prompts.numberFiles") }}:</strong> {{ req.numFiles }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>{{ $t("prompts.numberDirs") }}:</strong> {{ req?.numDirs }}
|
<strong>{{ $t("prompts.numberDirs") }}:</strong> {{ req.numDirs }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -99,100 +99,98 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { computed, inject } from "vue";
|
import { mapActions, mapState } from "pinia";
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
import { useFileStore } from "@/stores/file";
|
import { useFileStore } from "@/stores/file";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
import { filesize } from "@/utils";
|
import { filesize } from "@/utils";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { files as api } from "@/api";
|
import { files as api } from "@/api";
|
||||||
|
|
||||||
const route = useRoute();
|
export default {
|
||||||
const $showError = inject<(error: unknown) => void>("$showError");
|
name: "info",
|
||||||
|
inject: ["$showError"],
|
||||||
|
computed: {
|
||||||
|
...mapState(useFileStore, [
|
||||||
|
"req",
|
||||||
|
"selected",
|
||||||
|
"selectedCount",
|
||||||
|
"isListing",
|
||||||
|
]),
|
||||||
|
humanSize: function () {
|
||||||
|
if (this.selectedCount === 0 || !this.isListing) {
|
||||||
|
return filesize(this.req.size);
|
||||||
|
}
|
||||||
|
|
||||||
const fileStore = useFileStore();
|
let sum = 0;
|
||||||
const layoutStore = useLayoutStore();
|
|
||||||
|
|
||||||
const { req, selected, selectedCount, isListing } = storeToRefs(fileStore);
|
for (const selected of this.selected) {
|
||||||
const { closeHovers } = layoutStore;
|
sum += this.req.items[selected].size;
|
||||||
|
}
|
||||||
|
|
||||||
const humanSize = computed(() => {
|
return filesize(sum);
|
||||||
if (selectedCount.value === 0 || !isListing.value) {
|
},
|
||||||
return filesize(req.value?.size ?? 0);
|
humanTime: function () {
|
||||||
}
|
if (this.selectedCount === 0) {
|
||||||
|
return dayjs(this.req.modified).fromNow();
|
||||||
|
}
|
||||||
|
|
||||||
let sum = 0;
|
return dayjs(this.req.items[this.selected[0]].modified).fromNow();
|
||||||
|
},
|
||||||
|
modTime: function () {
|
||||||
|
if (this.selectedCount === 0) {
|
||||||
|
return new Date(Date.parse(this.req.modified)).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
for (const selectedIdx of selected.value) {
|
return new Date(
|
||||||
sum += req.value?.items[selectedIdx]?.size ?? 0;
|
Date.parse(this.req.items[this.selected[0]].modified)
|
||||||
}
|
).toLocaleString();
|
||||||
|
},
|
||||||
|
name: function () {
|
||||||
|
return this.selectedCount === 0
|
||||||
|
? this.req.name
|
||||||
|
: this.req.items[this.selected[0]].name;
|
||||||
|
},
|
||||||
|
dir: function () {
|
||||||
|
return (
|
||||||
|
this.selectedCount > 1 ||
|
||||||
|
(this.selectedCount === 0
|
||||||
|
? this.req.isDir
|
||||||
|
: this.req.items[this.selected[0]].isDir)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resolution: function () {
|
||||||
|
if (this.selectedCount === 1) {
|
||||||
|
const selectedItem = this.req.items[this.selected[0]];
|
||||||
|
if (selectedItem && selectedItem.type === "image") {
|
||||||
|
return selectedItem.resolution;
|
||||||
|
}
|
||||||
|
} else if (this.req && this.req.type === "image") {
|
||||||
|
return this.req.resolution;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
|
checksum: async function (event, algo) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
return filesize(sum);
|
let link;
|
||||||
});
|
|
||||||
|
|
||||||
const humanTime = computed(() => {
|
if (this.selectedCount) {
|
||||||
if (selectedCount.value === 0) {
|
link = this.req.items[this.selected[0]].url;
|
||||||
return dayjs(req.value?.modified).fromNow();
|
} else {
|
||||||
}
|
link = this.$route.path;
|
||||||
|
}
|
||||||
|
|
||||||
return dayjs(req.value?.items[selected.value[0]]?.modified).fromNow();
|
try {
|
||||||
});
|
const hash = await api.checksum(link, algo);
|
||||||
|
event.target.textContent = hash;
|
||||||
const modTime = computed(() => {
|
} catch (e) {
|
||||||
if (selectedCount.value === 0) {
|
this.$showError(e);
|
||||||
return new Date(Date.parse(req.value?.modified ?? "")).toLocaleString();
|
}
|
||||||
}
|
},
|
||||||
|
},
|
||||||
return new Date(
|
|
||||||
Date.parse(req.value?.items[selected.value[0]]?.modified ?? "")
|
|
||||||
).toLocaleString();
|
|
||||||
});
|
|
||||||
|
|
||||||
const name = computed(() => {
|
|
||||||
return selectedCount.value === 0
|
|
||||||
? (req.value?.name ?? "")
|
|
||||||
: (req.value?.items[selected.value[0]]?.name ?? "");
|
|
||||||
});
|
|
||||||
|
|
||||||
const dir = computed(() => {
|
|
||||||
return (
|
|
||||||
selectedCount.value > 1 ||
|
|
||||||
(selectedCount.value === 0
|
|
||||||
? (req.value?.isDir ?? false)
|
|
||||||
: (req.value?.items[selected.value[0]]?.isDir ?? false))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolution = computed(() => {
|
|
||||||
if (selectedCount.value === 1) {
|
|
||||||
const selectedItem = req.value?.items[selected.value[0]];
|
|
||||||
if (selectedItem && selectedItem.type === "image") {
|
|
||||||
return (selectedItem as any).resolution;
|
|
||||||
}
|
|
||||||
} else if (req.value && req.value.type === "image") {
|
|
||||||
return (req.value as any).resolution;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const checksum = async (event: Event, algo: string) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
let link;
|
|
||||||
|
|
||||||
if (selectedCount.value) {
|
|
||||||
link = req.value?.items[selected.value[0]]?.url ?? "";
|
|
||||||
} else {
|
|
||||||
link = route.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hash = await api.checksum(link, algo as any);
|
|
||||||
(event.target as HTMLElement).textContent = hash;
|
|
||||||
} catch (e) {
|
|
||||||
$showError?.(e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
</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: string) => (dest = val)"
|
@update:selected="(val) => (dest = val)"
|
||||||
:exclude="excludedFolders"
|
:exclude="excludedFolders"
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
/>
|
/>
|
||||||
@@ -17,10 +18,10 @@
|
|||||||
class="card-action"
|
class="card-action"
|
||||||
style="display: flex; align-items: center; justify-content: space-between"
|
style="display: flex; align-items: center; justify-content: space-between"
|
||||||
>
|
>
|
||||||
<template v-if="user?.perm.create">
|
<template v-if="user.perm.create">
|
||||||
<button
|
<button
|
||||||
class="button button--flat"
|
class="button button--flat"
|
||||||
@click="fileList?.createDir()"
|
@click="$refs.fileList.createDir()"
|
||||||
:aria-label="$t('sidebar.newFolder')"
|
:aria-label="$t('sidebar.newFolder')"
|
||||||
:title="$t('sidebar.newFolder')"
|
:title="$t('sidebar.newFolder')"
|
||||||
style="justify-self: left"
|
style="justify-self: left"
|
||||||
@@ -54,10 +55,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { ref, computed, inject } from "vue";
|
import { mapActions, mapState, mapWritableState } from "pinia";
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { 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 { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
@@ -67,76 +66,99 @@ import buttons from "@/utils/buttons";
|
|||||||
import * as upload from "@/utils/upload";
|
import * as upload from "@/utils/upload";
|
||||||
import { removePrefix } from "@/api/utils";
|
import { removePrefix } from "@/api/utils";
|
||||||
|
|
||||||
const router = useRouter();
|
export default {
|
||||||
const $showError = inject<(error: unknown) => void>("$showError");
|
name: "move",
|
||||||
|
components: { FileList },
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
current: window.location.pathname,
|
||||||
|
dest: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: ["$showError"],
|
||||||
|
computed: {
|
||||||
|
...mapState(useFileStore, ["req", "selected"]),
|
||||||
|
...mapState(useAuthStore, ["user"]),
|
||||||
|
...mapWritableState(useFileStore, ["reload", "preselect"]),
|
||||||
|
excludedFolders() {
|
||||||
|
return this.selected
|
||||||
|
.filter((idx) => this.req.items[idx].isDir)
|
||||||
|
.map((idx) => this.req.items[idx].url);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["showHover", "closeHovers"]),
|
||||||
|
move: async function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const items = [];
|
||||||
|
|
||||||
const fileStore = useFileStore();
|
for (const item of this.selected) {
|
||||||
const layoutStore = useLayoutStore();
|
items.push({
|
||||||
const authStore = useAuthStore();
|
from: this.req.items[item].url,
|
||||||
|
to: this.dest + encodeURIComponent(this.req.items[item].name),
|
||||||
|
name: this.req.items[item].name,
|
||||||
|
size: this.req.items[item].size,
|
||||||
|
isDir: this.req.items[item].isDir,
|
||||||
|
modified: this.req.items[item].modified,
|
||||||
|
overwrite: false,
|
||||||
|
rename: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { req, selected } = storeToRefs(fileStore);
|
const action = async (overwrite, rename) => {
|
||||||
const { user } = storeToRefs(authStore);
|
buttons.loading("move");
|
||||||
const { showHover, closeHovers } = layoutStore;
|
|
||||||
|
|
||||||
const fileList = ref<InstanceType<typeof FileList> | null>(null);
|
await api
|
||||||
const dest = ref<string | null>(null);
|
.move(items, overwrite, rename)
|
||||||
|
.then(() => {
|
||||||
|
buttons.success("move");
|
||||||
|
this.preselect = removePrefix(items[0].to);
|
||||||
|
if (this.user.redirectAfterCopyMove)
|
||||||
|
this.$router.push({ path: this.dest });
|
||||||
|
else this.reload = true;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
buttons.done("move");
|
||||||
|
this.$showError(e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const excludedFolders = computed(() => {
|
const conflict = await upload.checkConflict(items, this.dest, true);
|
||||||
return selected.value
|
|
||||||
.filter((idx) => req.value!.items[idx].isDir)
|
|
||||||
.map((idx) => req.value!.items[idx].url);
|
|
||||||
});
|
|
||||||
|
|
||||||
const move = async (event: Event) => {
|
if (conflict.length > 0) {
|
||||||
event.preventDefault();
|
this.showHover({
|
||||||
const items: Array<{ from: string; to: string; name: string }> = [];
|
prompt: "resolve-conflict",
|
||||||
|
props: {
|
||||||
|
conflict: conflict,
|
||||||
|
files: items,
|
||||||
|
},
|
||||||
|
confirm: (event, result) => {
|
||||||
|
event.preventDefault();
|
||||||
|
this.closeHovers();
|
||||||
|
for (let i = result.length - 1; i >= 0; i--) {
|
||||||
|
const item = result[i];
|
||||||
|
if (item.checked.length == 2) {
|
||||||
|
items[item.index].rename = true;
|
||||||
|
} else if (
|
||||||
|
item.checked.length == 1 &&
|
||||||
|
item.checked[0] == "origin"
|
||||||
|
) {
|
||||||
|
items[item.index].overwrite = true;
|
||||||
|
} else {
|
||||||
|
items.splice(item.index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
for (const item of selected.value) {
|
return;
|
||||||
items.push({
|
}
|
||||||
from: req.value!.items[item].url,
|
|
||||||
to: dest.value! + encodeURIComponent(req.value!.items[item].name),
|
|
||||||
name: req.value!.items[item].name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = async (overwrite: boolean, rename: boolean) => {
|
action(false, false);
|
||||||
buttons.loading("move");
|
},
|
||||||
|
},
|
||||||
await api
|
|
||||||
.move(items, overwrite, rename)
|
|
||||||
.then(() => {
|
|
||||||
buttons.success("move");
|
|
||||||
fileStore.preselect = removePrefix(items[0].to);
|
|
||||||
router.push({ path: dest.value! });
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
buttons.done("move");
|
|
||||||
$showError?.(e);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const dstItems = (await api.fetch(dest.value!)).items;
|
|
||||||
const conflict = upload.checkConflict(items as any, dstItems);
|
|
||||||
|
|
||||||
let overwrite = false;
|
|
||||||
let rename = false;
|
|
||||||
|
|
||||||
if (conflict) {
|
|
||||||
showHover({
|
|
||||||
prompt: "replace-rename",
|
|
||||||
confirm: (event: Event, option: string) => {
|
|
||||||
overwrite = option == "overwrite";
|
|
||||||
rename = option == "rename";
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
closeHovers();
|
|
||||||
action(overwrite, rename);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
action(overwrite, rename);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
v-model.trim="name"
|
v-model.trim="name"
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
/>
|
/>
|
||||||
<CreateFilePath :name="name" :is-dir="true" />
|
<CreateFilePath :name="name" :is-dir="true" :path="base" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-action">
|
<div class="card-action">
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, ref } from "vue";
|
import { computed, inject, ref } from "vue";
|
||||||
import { useFileStore } from "@/stores/file";
|
import { useFileStore } from "@/stores/file";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
|
||||||
@@ -53,17 +53,13 @@ import CreateFilePath from "@/components/prompts/CreateFilePath.vue";
|
|||||||
|
|
||||||
const $showError = inject<IToastError>("$showError")!;
|
const $showError = inject<IToastError>("$showError")!;
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
base: String,
|
|
||||||
redirect: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileStore = useFileStore();
|
const fileStore = useFileStore();
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
|
|
||||||
|
const base = computed(() => {
|
||||||
|
return layoutStore.currentPrompt?.props?.base;
|
||||||
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -76,7 +72,7 @@ const submit = async (event: Event) => {
|
|||||||
|
|
||||||
// Build the path of the new directory.
|
// Build the path of the new directory.
|
||||||
let uri: string;
|
let uri: string;
|
||||||
if (props.base) uri = props.base;
|
if (base.value) uri = base.value;
|
||||||
else if (fileStore.isFiles) uri = route.path + "/";
|
else if (fileStore.isFiles) uri = route.path + "/";
|
||||||
else uri = "/";
|
else uri = "/";
|
||||||
|
|
||||||
@@ -89,12 +85,15 @@ const submit = async (event: Event) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post(uri);
|
await api.post(uri);
|
||||||
if (props.redirect) {
|
if (layoutStore.currentPrompt?.props?.redirect) {
|
||||||
router.push({ path: uri });
|
router.push({ path: uri });
|
||||||
} else if (!props.base) {
|
} else if (!base.value) {
|
||||||
const res = await api.fetch(url.removeLastDir(uri) + "/");
|
const res = await api.fetch(url.removeLastDir(uri) + "/");
|
||||||
fileStore.updateRequest(res);
|
fileStore.updateRequest(res);
|
||||||
}
|
}
|
||||||
|
if (layoutStore.currentPrompt?.confirm) {
|
||||||
|
layoutStore.currentPrompt?.confirm(uri);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
$showError(e);
|
$showError(e);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<ModalsContainer />
|
<base-modal v-if="modal != null" :prompt="currentPromptName" @closed="close">
|
||||||
|
<keep-alive>
|
||||||
|
<component :is="modal" />
|
||||||
|
</keep-alive>
|
||||||
|
</base-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from "vue";
|
import { computed } from "vue";
|
||||||
import { ModalsContainer, useModal } from "vue-final-modal";
|
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
|
||||||
@@ -20,11 +23,12 @@ import Copy from "./Copy.vue";
|
|||||||
import NewFile from "./NewFile.vue";
|
import NewFile from "./NewFile.vue";
|
||||||
import NewDir from "./NewDir.vue";
|
import NewDir from "./NewDir.vue";
|
||||||
import Replace from "./Replace.vue";
|
import Replace from "./Replace.vue";
|
||||||
import ReplaceRename from "./ReplaceRename.vue";
|
|
||||||
import Share from "./Share.vue";
|
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 ResolveConflict from "./ResolveConflict.vue";
|
||||||
|
import CurrentPassword from "./CurrentPassword.vue";
|
||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
|
|
||||||
@@ -41,35 +45,24 @@ const components = new Map<string, any>([
|
|||||||
["newDir", NewDir],
|
["newDir", NewDir],
|
||||||
["download", Download],
|
["download", Download],
|
||||||
["replace", Replace],
|
["replace", Replace],
|
||||||
["replace-rename", ReplaceRename],
|
|
||||||
["share", Share],
|
["share", Share],
|
||||||
["upload", Upload],
|
["upload", Upload],
|
||||||
["share-delete", ShareDelete],
|
["share-delete", ShareDelete],
|
||||||
["deleteUser", DeleteUser],
|
["deleteUser", DeleteUser],
|
||||||
["discardEditorChanges", DiscardEditorChanges],
|
["discardEditorChanges", DiscardEditorChanges],
|
||||||
|
["resolve-conflict", ResolveConflict],
|
||||||
|
["current-password", CurrentPassword],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
watch(currentPromptName, (newValue) => {
|
const modal = computed(() => {
|
||||||
const modal = components.get(newValue!);
|
const modal = components.get(currentPromptName.value!);
|
||||||
if (!modal) return;
|
if (!modal) null;
|
||||||
|
|
||||||
const { open, close } = useModal({
|
return modal;
|
||||||
component: BaseModal,
|
|
||||||
slots: {
|
|
||||||
default: modal,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
layoutStore.setCloseOnPrompt(close, newValue!);
|
|
||||||
open();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("keydown", (event) => {
|
const close = () => {
|
||||||
if (!layoutStore.currentPrompt) return;
|
if (!layoutStore.currentPrompt) return;
|
||||||
|
layoutStore.closeHovers();
|
||||||
if (event.key === "Escape") {
|
};
|
||||||
event.stopImmediatePropagation();
|
|
||||||
layoutStore.closeHovers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<p>
|
<p>
|
||||||
{{ $t("prompts.renameMessage") }} <code>{{ oldName() }}</code
|
{{ $t("prompts.renameMessage") }} <code>{{ oldName }}</code
|
||||||
>:
|
>:
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
:aria-label="$t('buttons.rename')"
|
:aria-label="$t('buttons.rename')"
|
||||||
:title="$t('buttons.rename')"
|
:title="$t('buttons.rename')"
|
||||||
|
:disabled="name === '' || name === oldName"
|
||||||
>
|
>
|
||||||
{{ $t("buttons.rename") }}
|
{{ $t("buttons.rename") }}
|
||||||
</button>
|
</button>
|
||||||
@@ -40,74 +41,83 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { ref, onMounted, inject } from "vue";
|
import { mapActions, mapState, mapWritableState } from "pinia";
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { 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 url from "@/utils/url";
|
import url from "@/utils/url";
|
||||||
import { files as api } from "@/api";
|
import { files as api } from "@/api";
|
||||||
import { removePrefix } from "@/api/utils";
|
import { removePrefix } from "@/api/utils";
|
||||||
|
|
||||||
const router = useRouter();
|
export default {
|
||||||
const $showError = inject<(error: unknown) => void>("$showError");
|
name: "rename",
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.name = this.oldName;
|
||||||
|
},
|
||||||
|
inject: ["$showError"],
|
||||||
|
computed: {
|
||||||
|
...mapState(useFileStore, [
|
||||||
|
"req",
|
||||||
|
"selected",
|
||||||
|
"selectedCount",
|
||||||
|
"isListing",
|
||||||
|
]),
|
||||||
|
...mapWritableState(useFileStore, ["reload", "preselect"]),
|
||||||
|
oldName() {
|
||||||
|
if (!this.isListing) {
|
||||||
|
return this.req.name;
|
||||||
|
}
|
||||||
|
|
||||||
const fileStore = useFileStore();
|
if (this.selectedCount === 0 || this.selectedCount > 1) {
|
||||||
const layoutStore = useLayoutStore();
|
// This shouldn't happen.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
const { req, selected, selectedCount, isListing } = storeToRefs(fileStore);
|
return this.req.items[this.selected[0]].name;
|
||||||
const { closeHovers } = layoutStore;
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
|
cancel: function () {
|
||||||
|
this.closeHovers();
|
||||||
|
},
|
||||||
|
submit: async function () {
|
||||||
|
if (this.name === "" || this.name === this.oldName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let oldLink = "";
|
||||||
|
let newLink = "";
|
||||||
|
|
||||||
const name = ref("");
|
if (!this.isListing) {
|
||||||
|
oldLink = this.req.url;
|
||||||
|
} else {
|
||||||
|
oldLink = this.req.items[this.selected[0]].url;
|
||||||
|
}
|
||||||
|
|
||||||
const oldName = (): string => {
|
newLink =
|
||||||
if (!isListing.value) {
|
url.removeLastDir(oldLink) + "/" + encodeURIComponent(this.name);
|
||||||
return req.value?.name ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedCount.value === 0 || selectedCount.value > 1) {
|
try {
|
||||||
// This shouldn't happen.
|
await api.move([{ from: oldLink, to: newLink }]);
|
||||||
return "";
|
if (!this.isListing) {
|
||||||
}
|
this.$router.push({ path: newLink });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return req.value?.items[selected.value[0]].name ?? "";
|
this.preselect = removePrefix(newLink);
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
this.reload = true;
|
||||||
name.value = oldName();
|
} catch (e) {
|
||||||
});
|
this.$showError(e);
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
this.closeHovers();
|
||||||
let oldLink = "";
|
},
|
||||||
let newLink = "";
|
},
|
||||||
|
|
||||||
if (!req.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isListing.value) {
|
|
||||||
oldLink = req.value.url;
|
|
||||||
} else {
|
|
||||||
oldLink = req.value.items[selected.value[0]].url;
|
|
||||||
}
|
|
||||||
|
|
||||||
newLink = url.removeLastDir(oldLink) + "/" + encodeURIComponent(name.value);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.move([{ from: oldLink, to: newLink }]);
|
|
||||||
if (!isListing.value) {
|
|
||||||
router.push({ path: newLink });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fileStore.preselect = removePrefix(newLink);
|
|
||||||
|
|
||||||
fileStore.reload = true;
|
|
||||||
} catch (e) {
|
|
||||||
$showError?.(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeHovers();
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="button button--flat button--blue"
|
class="button button--flat button--blue"
|
||||||
@click="currentPrompt?.action"
|
@click="currentPrompt.action"
|
||||||
:aria-label="$t('buttons.continue')"
|
:aria-label="$t('buttons.continue')"
|
||||||
:title="$t('buttons.continue')"
|
:title="$t('buttons.continue')"
|
||||||
tabindex="2"
|
tabindex="2"
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<button
|
<button
|
||||||
id="focus-prompt"
|
id="focus-prompt"
|
||||||
class="button button--flat button--red"
|
class="button button--flat button--red"
|
||||||
@click="currentPrompt?.confirm"
|
@click="currentPrompt.confirm"
|
||||||
:aria-label="$t('buttons.replace')"
|
:aria-label="$t('buttons.replace')"
|
||||||
:title="$t('buttons.replace')"
|
:title="$t('buttons.replace')"
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
@@ -41,11 +41,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { storeToRefs } from "pinia";
|
import { mapActions, mapState } from "pinia";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
export default {
|
||||||
const { currentPrompt } = storeToRefs(layoutStore);
|
name: "replace",
|
||||||
const { closeHovers } = layoutStore;
|
computed: {
|
||||||
|
...mapState(useLayoutStore, ["currentPrompt"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card floating">
|
|
||||||
<div class="card-title">
|
|
||||||
<h2>{{ $t("prompts.replace") }}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-content">
|
|
||||||
<p>{{ $t("prompts.replaceMessage") }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-action">
|
|
||||||
<button
|
|
||||||
class="button button--flat button--grey"
|
|
||||||
@click="closeHovers"
|
|
||||||
:aria-label="$t('buttons.cancel')"
|
|
||||||
:title="$t('buttons.cancel')"
|
|
||||||
tabindex="3"
|
|
||||||
>
|
|
||||||
{{ $t("buttons.cancel") }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="button button--flat button--blue"
|
|
||||||
@click="(event) => currentPrompt?.confirm(event, 'rename')"
|
|
||||||
:aria-label="$t('buttons.rename')"
|
|
||||||
:title="$t('buttons.rename')"
|
|
||||||
tabindex="2"
|
|
||||||
>
|
|
||||||
{{ $t("buttons.rename") }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="focus-prompt"
|
|
||||||
class="button button--flat button--red"
|
|
||||||
@click="(event) => currentPrompt?.confirm(event, 'overwrite')"
|
|
||||||
:aria-label="$t('buttons.replace')"
|
|
||||||
:title="$t('buttons.replace')"
|
|
||||||
tabindex="1"
|
|
||||||
>
|
|
||||||
{{ $t("buttons.replace") }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
|
||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
|
||||||
const { currentPrompt } = storeToRefs(layoutStore);
|
|
||||||
const { closeHovers } = layoutStore;
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card floating">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>
|
||||||
|
{{
|
||||||
|
personalized
|
||||||
|
? $t("prompts.resolveConflict")
|
||||||
|
: $t("prompts.replaceOrSkip")
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<template v-if="personalized">
|
||||||
|
<p v-if="isUploadAction != true">
|
||||||
|
{{ $t("prompts.singleConflictResolve") }}
|
||||||
|
</p>
|
||||||
|
<div class="conflict-list-container">
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<input
|
||||||
|
@change="toogleCheckAll"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="originAllChecked"
|
||||||
|
value="origin"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
isUploadAction != true
|
||||||
|
? $t("prompts.filesInOrigin")
|
||||||
|
: $t("prompts.uploadingFiles")
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<input
|
||||||
|
@change="toogleCheckAll"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="destAllChecked"
|
||||||
|
value="dest"
|
||||||
|
/>
|
||||||
|
{{ $t("prompts.filesInDest") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<template v-for="(item, index) in conflict" :key="index">
|
||||||
|
<div class="conflict-file-name">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
|
||||||
|
<template v-if="item.checked.length == 2">
|
||||||
|
<span v-if="isUploadAction != true" class="result-rename">
|
||||||
|
{{ $t("prompts.rename") }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="result-error">
|
||||||
|
{{ $t("prompts.forbiddenError") }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-else-if="
|
||||||
|
item.checked.length == 1 && item.checked[0] == 'origin'
|
||||||
|
"
|
||||||
|
class="result-override"
|
||||||
|
>
|
||||||
|
{{ $t("prompts.override") }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="result-skip">
|
||||||
|
{{ $t("prompts.skip") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input v-model="item.checked" type="checkbox" value="origin" />
|
||||||
|
<div>
|
||||||
|
<p class="conflict-file-value">
|
||||||
|
{{ humanTime(item.origin.lastModified) }}
|
||||||
|
</p>
|
||||||
|
<p class="conflict-file-value">
|
||||||
|
{{ humanSize(item.origin.size) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input v-model="item.checked" type="checkbox" value="dest" />
|
||||||
|
<div>
|
||||||
|
<p class="conflict-file-value">
|
||||||
|
{{ humanTime(item.dest.lastModified) }}
|
||||||
|
</p>
|
||||||
|
<p class="conflict-file-value">
|
||||||
|
{{ humanSize(item.dest.size) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p>
|
||||||
|
{{ $t("prompts.fastConflictResolve", { count: conflict.length }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="result-buttons">
|
||||||
|
<button @click="(e) => resolve(e, ['origin'])">
|
||||||
|
<i class="material-icons">done_all</i>
|
||||||
|
{{ $t("buttons.overrideAll") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isUploadAction != true"
|
||||||
|
@click="(e) => resolve(e, ['origin', 'dest'])"
|
||||||
|
>
|
||||||
|
<i class="material-icons">folder_copy</i>
|
||||||
|
{{ $t("buttons.renameAll") }}
|
||||||
|
</button>
|
||||||
|
<button @click="(e) => resolve(e, ['dest'])">
|
||||||
|
<i class="material-icons">undo</i>
|
||||||
|
{{ $t("buttons.skipAll") }}
|
||||||
|
</button>
|
||||||
|
<button @click="(e) => resume(e)">
|
||||||
|
<i class="material-icons">replay</i>
|
||||||
|
{{ $t("buttons.resumeTransfer") }}
|
||||||
|
<span class="info-tooltip" @click.stop="() => {}">
|
||||||
|
<i class="material-icons info-icon">info_outline</i>
|
||||||
|
<span class="info-tooltip-text">
|
||||||
|
{{ $t("buttons.resumeTransferTooltip") }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button @click="personalized = true">
|
||||||
|
<i class="material-icons">checklist</i>
|
||||||
|
{{ $t("buttons.singleDecision") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-action" style="display: flex; justify-content: end">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="button button--flat button--grey"
|
||||||
|
@click="close"
|
||||||
|
:aria-label="$t('buttons.cancel')"
|
||||||
|
:title="$t('buttons.cancel')"
|
||||||
|
tabindex="4"
|
||||||
|
>
|
||||||
|
{{ $t("buttons.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="personalized"
|
||||||
|
id="focus-prompt"
|
||||||
|
class="button button--flat"
|
||||||
|
@click="(event) => currentPrompt?.confirm(event, conflict)"
|
||||||
|
:aria-label="$t('buttons.ok')"
|
||||||
|
:title="$t('buttons.ok')"
|
||||||
|
tabindex="1"
|
||||||
|
>
|
||||||
|
{{ $t("buttons.ok") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { filesize } from "@/utils";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
|
const { currentPrompt } = layoutStore;
|
||||||
|
|
||||||
|
const conflict = ref<ConflictingResource[]>(currentPrompt?.props.conflict);
|
||||||
|
|
||||||
|
const isUploadAction = ref<boolean | undefined>(
|
||||||
|
currentPrompt?.props.isUploadAction
|
||||||
|
);
|
||||||
|
|
||||||
|
const personalized = ref(false);
|
||||||
|
|
||||||
|
const originAllChecked = computed(() => {
|
||||||
|
for (const item of conflict.value) {
|
||||||
|
if (!item.checked.includes("origin")) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const destAllChecked = computed(() => {
|
||||||
|
for (const item of conflict.value) {
|
||||||
|
if (!item.checked.includes("dest")) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
layoutStore.closeHovers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const humanSize = (size: number | undefined) => {
|
||||||
|
return size == undefined ? "Unknown size" : filesize(size);
|
||||||
|
};
|
||||||
|
|
||||||
|
const humanTime = (modified: string | number | undefined) => {
|
||||||
|
if (modified == undefined) return "Unknown date";
|
||||||
|
|
||||||
|
return dayjs(modified).format("L LT");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resume = (event: Event) => {
|
||||||
|
conflict.value.forEach((item) => {
|
||||||
|
if (item.isSmallerOnServer) {
|
||||||
|
item.checked = ["origin"];
|
||||||
|
} else {
|
||||||
|
item.checked = ["dest"];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
currentPrompt?.confirm(event, conflict.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolve = (event: Event, result: Array<"origin" | "dest">) => {
|
||||||
|
for (const item of conflict.value) {
|
||||||
|
item.checked = result;
|
||||||
|
}
|
||||||
|
currentPrompt?.confirm(event, conflict.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toogleCheckAll = (e: Event) => {
|
||||||
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
|
const value = target.value as "origin" | "dest" | "both";
|
||||||
|
const checked = target.checked;
|
||||||
|
|
||||||
|
for (const item of conflict.value) {
|
||||||
|
if (value == "both") {
|
||||||
|
item.checked = ["origin", "dest"];
|
||||||
|
} else {
|
||||||
|
if (!item.checked.includes(value)) {
|
||||||
|
if (checked) {
|
||||||
|
item.checked.push(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!checked) {
|
||||||
|
item.checked = value == "dest" ? ["origin"] : ["dest"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.conflict-list-container {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-list-container > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-bottom: solid 1px var(--textPrimary);
|
||||||
|
gap: 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-list-container > div:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-list-container > div > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-file-name {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--textPrimary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-file-value {
|
||||||
|
color: var(--textPrimary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-rename,
|
||||||
|
.result-override,
|
||||||
|
.result-error,
|
||||||
|
.result-skip {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-override {
|
||||||
|
background-color: var(--input-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-error {
|
||||||
|
background-color: var(--icon-red);
|
||||||
|
}
|
||||||
|
.result-rename {
|
||||||
|
background-color: var(--icon-orange);
|
||||||
|
}
|
||||||
|
.result-skip {
|
||||||
|
background-color: var(--icon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-buttons > button {
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--textPrimary);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: solid 1px transparent;
|
||||||
|
width: 100%;
|
||||||
|
transition: all ease-in-out 200ms;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-buttons > button:hover {
|
||||||
|
border: solid 1px var(--icon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tooltip {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--icon-blue);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tooltip-text {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: var(--surfacePrimary, #333);
|
||||||
|
color: var(--textPrimary, #fff);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
width: 220px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: opacity 200ms ease-in-out;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tooltip:hover .info-tooltip-text {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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.hasPassword"
|
||||||
|
@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"
|
||||||
@@ -129,146 +141,152 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { ref, computed, inject, onBeforeMount } from "vue";
|
import { mapActions, mapState } from "pinia";
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
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";
|
||||||
|
|
||||||
const route = useRoute();
|
export default {
|
||||||
const { t } = useI18n();
|
name: "share",
|
||||||
const $showError = inject<(error: unknown) => void>("$showError");
|
data: function () {
|
||||||
const $showSuccess = inject<(message: string) => void>("$showSuccess");
|
return {
|
||||||
|
time: 0,
|
||||||
|
unit: "hours",
|
||||||
|
links: [],
|
||||||
|
clip: null,
|
||||||
|
password: "",
|
||||||
|
listing: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: ["$showError", "$showSuccess"],
|
||||||
|
computed: {
|
||||||
|
...mapState(useFileStore, [
|
||||||
|
"req",
|
||||||
|
"selected",
|
||||||
|
"selectedCount",
|
||||||
|
"isListing",
|
||||||
|
]),
|
||||||
|
url() {
|
||||||
|
if (!this.isListing) {
|
||||||
|
return this.$route.path;
|
||||||
|
}
|
||||||
|
|
||||||
const fileStore = useFileStore();
|
if (this.selectedCount === 0 || this.selectedCount > 1) {
|
||||||
const layoutStore = useLayoutStore();
|
// This shouldn't happen.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { req, selected, selectedCount, isListing } = storeToRefs(fileStore);
|
return this.req.items[this.selected[0]].url;
|
||||||
const { closeHovers } = layoutStore;
|
|
||||||
|
|
||||||
const time = ref(0);
|
|
||||||
const unit = ref("hours");
|
|
||||||
const links = ref<any[]>([]);
|
|
||||||
const password = ref("");
|
|
||||||
const listing = ref(true);
|
|
||||||
|
|
||||||
const url = computed(() => {
|
|
||||||
if (!isListing.value) {
|
|
||||||
return route.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedCount.value === 0 || selectedCount.value > 1) {
|
|
||||||
// This shouldn't happen.
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return req.value?.items[selected.value[0]].url ?? "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
|
||||||
copy({ text }).then(
|
|
||||||
() => {
|
|
||||||
// clipboard successfully set
|
|
||||||
$showSuccess?.(t("success.linkCopied"));
|
|
||||||
},
|
},
|
||||||
() => {
|
},
|
||||||
// clipboard write failed
|
async beforeMount() {
|
||||||
copy({ text }, { permission: true }).then(
|
try {
|
||||||
|
const links = await api.share.get(this.url);
|
||||||
|
this.links = links;
|
||||||
|
this.sort();
|
||||||
|
|
||||||
|
if (this.links.length == 0) {
|
||||||
|
this.listing = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.$showError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
|
copyToClipboard: function (text) {
|
||||||
|
copy({ text }).then(
|
||||||
() => {
|
() => {
|
||||||
// clipboard successfully set
|
// clipboard successfully set
|
||||||
$showSuccess?.(t("success.linkCopied"));
|
this.$showSuccess(this.$t("success.linkCopied"));
|
||||||
},
|
},
|
||||||
(e) => {
|
() => {
|
||||||
// clipboard write failed
|
// clipboard write failed
|
||||||
$showError?.(e);
|
copy({ text }, { permission: true }).then(
|
||||||
|
() => {
|
||||||
|
// clipboard successfully set
|
||||||
|
this.$showSuccess(this.$t("success.linkCopied"));
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
// clipboard write failed
|
||||||
|
this.$showError(e);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
submit: async function () {
|
||||||
};
|
try {
|
||||||
|
let res = null;
|
||||||
|
|
||||||
const submit = async () => {
|
if (!this.time) {
|
||||||
try {
|
res = await api.share.create(this.url, this.password);
|
||||||
let res = null;
|
} else {
|
||||||
|
res = await api.share.create(
|
||||||
|
this.url,
|
||||||
|
this.password,
|
||||||
|
this.time,
|
||||||
|
this.unit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!time.value) {
|
this.links.push(res);
|
||||||
res = await api.create(url.value, password.value);
|
this.sort();
|
||||||
} else {
|
|
||||||
res = await api.create(
|
this.time = 0;
|
||||||
url.value,
|
this.unit = "hours";
|
||||||
password.value,
|
this.password = "";
|
||||||
String(time.value),
|
|
||||||
unit.value
|
this.listing = true;
|
||||||
|
} catch (e) {
|
||||||
|
this.$showError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteLink: async function (event, link) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await api.share.remove(link.hash);
|
||||||
|
this.links = this.links.filter((item) => item.hash !== link.hash);
|
||||||
|
|
||||||
|
if (this.links.length == 0) {
|
||||||
|
this.listing = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.$showError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
humanTime(time) {
|
||||||
|
return dayjs(time * 1000).fromNow();
|
||||||
|
},
|
||||||
|
buildLink(share) {
|
||||||
|
return api.share.getShareURL(share);
|
||||||
|
},
|
||||||
|
buildDownloadLink(share) {
|
||||||
|
return api.pub.getDownloadURL(
|
||||||
|
{
|
||||||
|
hash: share.hash,
|
||||||
|
path: "",
|
||||||
|
},
|
||||||
|
true
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
sort() {
|
||||||
|
this.links = this.links.sort((a, b) => {
|
||||||
|
if (a.expire === 0) return -1;
|
||||||
|
if (b.expire === 0) return 1;
|
||||||
|
return new Date(a.expire) - new Date(b.expire);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
switchListing() {
|
||||||
|
if (this.links.length == 0 && !this.listing) {
|
||||||
|
this.closeHovers();
|
||||||
|
}
|
||||||
|
|
||||||
links.value.push(res);
|
this.listing = !this.listing;
|
||||||
sort();
|
},
|
||||||
|
},
|
||||||
time.value = 0;
|
|
||||||
unit.value = "hours";
|
|
||||||
password.value = "";
|
|
||||||
|
|
||||||
listing.value = true;
|
|
||||||
} catch (e) {
|
|
||||||
$showError?.(e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteLink = async (event: Event, link: any) => {
|
|
||||||
event.preventDefault();
|
|
||||||
try {
|
|
||||||
await api.remove(link.hash);
|
|
||||||
links.value = links.value.filter((item) => item.hash !== link.hash);
|
|
||||||
|
|
||||||
if (links.value.length == 0) {
|
|
||||||
listing.value = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
$showError?.(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const humanTime = (time: number) => {
|
|
||||||
return dayjs(time * 1000).fromNow();
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildLink = (share: any) => {
|
|
||||||
return api.getShareURL(share);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sort = () => {
|
|
||||||
links.value = links.value.sort((a, b) => {
|
|
||||||
if (a.expire === 0) return -1;
|
|
||||||
if (b.expire === 0) return 1;
|
|
||||||
return new Date(a.expire).getTime() - new Date(b.expire).getTime();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const switchListing = () => {
|
|
||||||
if (links.value.length == 0 && !listing.value) {
|
|
||||||
closeHovers();
|
|
||||||
}
|
|
||||||
|
|
||||||
listing.value = !listing.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
|
||||||
try {
|
|
||||||
const fetchedLinks = await api.get(url.value);
|
|
||||||
links.value = Array.isArray(fetchedLinks) ? fetchedLinks : [fetchedLinks];
|
|
||||||
sort();
|
|
||||||
|
|
||||||
if (links.value.length == 0) {
|
|
||||||
listing.value = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
$showError?.(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -27,15 +27,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { storeToRefs } from "pinia";
|
import { mapActions, mapState } from "pinia";
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
export default {
|
||||||
const { currentPrompt } = storeToRefs(layoutStore);
|
name: "share-delete",
|
||||||
const { closeHovers } = layoutStore;
|
computed: {
|
||||||
|
...mapState(useLayoutStore, ["currentPrompt"]),
|
||||||
const submit = () => {
|
},
|
||||||
currentPrompt.value?.confirm();
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
|
submit: function () {
|
||||||
|
this.currentPrompt?.confirm();
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { useFileStore } from "@/stores/file";
|
|
||||||
import { useLayoutStore } from "@/stores/layout";
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
|
||||||
import * as upload from "@/utils/upload";
|
import * as upload from "@/utils/upload";
|
||||||
@@ -43,11 +42,10 @@ import * as upload from "@/utils/upload";
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const fileStore = useFileStore();
|
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
|
|
||||||
// TODO: this is a copy of the same function in FileListing.vue
|
// TODO: this is a copy of the same function in FileListing.vue
|
||||||
const uploadInput = (event: Event) => {
|
const uploadInput = async (event: Event) => {
|
||||||
const files = (event.currentTarget as HTMLInputElement)?.files;
|
const files = (event.currentTarget as HTMLInputElement)?.files;
|
||||||
if (files === null) return;
|
if (files === null) return;
|
||||||
|
|
||||||
@@ -67,20 +65,32 @@ const uploadInput = (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = route.path.endsWith("/") ? route.path : route.path + "/";
|
const path = route.path.endsWith("/") ? route.path : route.path + "/";
|
||||||
const conflict = upload.checkConflict(uploadFiles, fileStore.req!.items);
|
|
||||||
|
|
||||||
if (conflict) {
|
const conflict = await upload.checkConflict(uploadFiles, path);
|
||||||
|
|
||||||
|
if (conflict.length > 0) {
|
||||||
layoutStore.showHover({
|
layoutStore.showHover({
|
||||||
prompt: "replace",
|
prompt: "resolve-conflict",
|
||||||
action: (event: Event) => {
|
props: {
|
||||||
event.preventDefault();
|
conflict: conflict,
|
||||||
layoutStore.closeHovers();
|
isUploadAction: true,
|
||||||
upload.handleFiles(uploadFiles, path, false);
|
|
||||||
},
|
},
|
||||||
confirm: (event: Event) => {
|
confirm: (event: Event, result: Array<ConflictingResource>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
layoutStore.closeHovers();
|
layoutStore.closeHovers();
|
||||||
upload.handleFiles(uploadFiles, path, true);
|
for (let i = result.length - 1; i >= 0; i--) {
|
||||||
|
const item = result[i];
|
||||||
|
if (item.checked.length == 2) {
|
||||||
|
continue;
|
||||||
|
} else if (item.checked.length == 1 && item.checked[0] == "origin") {
|
||||||
|
uploadFiles[item.index].overwrite = true;
|
||||||
|
} else {
|
||||||
|
uploadFiles.splice(item.index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uploadFiles.length > 0) {
|
||||||
|
upload.handleFiles(uploadFiles, path);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import CopyPrompt from "@/components/prompts/Copy.vue";
|
||||||
|
import MovePrompt from "@/components/prompts/Move.vue";
|
||||||
|
import { files as api } from "@/api";
|
||||||
|
import { checkConflict } from "@/utils/upload";
|
||||||
|
|
||||||
|
vi.mock("@/api", () => ({
|
||||||
|
files: {
|
||||||
|
copy: vi.fn().mockResolvedValue(undefined),
|
||||||
|
move: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/api/utils", () => ({
|
||||||
|
removePrefix: (value: string) => value.replace(/^\/files/, ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/buttons", () => ({
|
||||||
|
default: {
|
||||||
|
loading: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
done: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/upload", () => ({
|
||||||
|
checkConflict: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const conflict = [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
name: "/target/file.txt",
|
||||||
|
origin: { size: 12 },
|
||||||
|
dest: { size: 10 },
|
||||||
|
checked: ["origin"],
|
||||||
|
isSmallerOnServer: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeContext() {
|
||||||
|
return {
|
||||||
|
selected: [0],
|
||||||
|
req: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
url: "/files/source/file.txt",
|
||||||
|
name: "file.txt",
|
||||||
|
size: 12,
|
||||||
|
isDir: false,
|
||||||
|
modified: "2026-06-04T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dest: "/files/target/",
|
||||||
|
user: { redirectAfterCopyMove: false },
|
||||||
|
$route: { path: "/files/source/" },
|
||||||
|
$router: { push: vi.fn() },
|
||||||
|
reload: false,
|
||||||
|
preselect: "",
|
||||||
|
showHover: vi.fn(),
|
||||||
|
closeHovers: vi.fn(),
|
||||||
|
$showError: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("copy and move conflict prompts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(checkConflict).mockResolvedValue(
|
||||||
|
conflict as ConflictingResource[]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for copy conflict detection before calling the copy API", async () => {
|
||||||
|
const context = makeContext();
|
||||||
|
|
||||||
|
await (CopyPrompt as any).methods.copy.call(context, event);
|
||||||
|
|
||||||
|
expect(checkConflict).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "/files/target/file.txt",
|
||||||
|
isDir: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
"/files/target/",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(context.showHover).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
prompt: "resolve-conflict",
|
||||||
|
props: { conflict },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(api.copy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for move conflict detection before calling the move API", async () => {
|
||||||
|
const context = makeContext();
|
||||||
|
|
||||||
|
await (MovePrompt as any).methods.move.call(context, event);
|
||||||
|
|
||||||
|
expect(checkConflict).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "/files/target/file.txt",
|
||||||
|
isDir: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
"/files/target/",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(context.showHover).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
prompt: "resolve-conflict",
|
||||||
|
props: expect.objectContaining({
|
||||||
|
conflict,
|
||||||
|
files: expect.any(Array),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(api.move).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,27 +8,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { computed } from "vue";
|
export default {
|
||||||
|
name: "permissions",
|
||||||
const props = defineProps<{
|
props: ["commands"],
|
||||||
commands: string[];
|
computed: {
|
||||||
}>();
|
raw: {
|
||||||
|
get() {
|
||||||
const emit = defineEmits<{
|
return this.commands.join(" ");
|
||||||
"update:commands": [commands: string[]];
|
},
|
||||||
}>();
|
set(value) {
|
||||||
|
if (value !== "") {
|
||||||
const raw = computed({
|
this.$emit("update:commands", value.split(" "));
|
||||||
get() {
|
} else {
|
||||||
return props.commands.join(" ");
|
this.$emit("update:commands", []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
set(value: string) {
|
};
|
||||||
if (value !== "") {
|
|
||||||
emit("update:commands", value.split(" "));
|
|
||||||
} else {
|
|
||||||
emit("update:commands", []);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,50 +6,64 @@
|
|||||||
</select>
|
</select>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { markRaw } from "vue";
|
import { markRaw } from "vue";
|
||||||
|
|
||||||
defineProps<{
|
export default {
|
||||||
locale: string;
|
name: "languages",
|
||||||
}>();
|
props: ["locale"],
|
||||||
|
data() {
|
||||||
|
const dataObj = {};
|
||||||
|
const locales = {
|
||||||
|
ar: "العربية",
|
||||||
|
bg: "български език",
|
||||||
|
ca: "Català",
|
||||||
|
cs: "Čeština",
|
||||||
|
de: "Deutsch",
|
||||||
|
el: "Ελληνικά",
|
||||||
|
en: "English",
|
||||||
|
es: "Español",
|
||||||
|
fr: "Français",
|
||||||
|
he: "עברית",
|
||||||
|
hr: "Hrvatski",
|
||||||
|
hu: "Magyar",
|
||||||
|
is: "Icelandic",
|
||||||
|
it: "Italiano",
|
||||||
|
ja: "日本語",
|
||||||
|
ko: "한국어",
|
||||||
|
no: "Norsk",
|
||||||
|
nl: "Nederlands (Nederland)",
|
||||||
|
"nl-be": "Nederlands (België)",
|
||||||
|
lv: "Latviešu",
|
||||||
|
pl: "Polski",
|
||||||
|
"pt-br": "Português (Brasil)",
|
||||||
|
"pt-pt": "Português (Portugal)",
|
||||||
|
ro: "Romanian",
|
||||||
|
ru: "Русский",
|
||||||
|
sk: "Slovenčina",
|
||||||
|
"sv-se": "Swedish (Sweden)",
|
||||||
|
tr: "Türkçe",
|
||||||
|
uk: "Українська",
|
||||||
|
vi: "Tiếng Việt",
|
||||||
|
"zh-cn": "中文 (简体)",
|
||||||
|
"zh-tw": "中文 (繁體)",
|
||||||
|
};
|
||||||
|
|
||||||
const emit = defineEmits<{
|
// Vue3 reactivity breaks with this configuration
|
||||||
"update:locale": [locale: string];
|
// so we need to use markRaw as a workaround
|
||||||
}>();
|
// https://github.com/vuejs/core/issues/3024
|
||||||
|
Object.defineProperty(dataObj, "locales", {
|
||||||
|
value: markRaw(locales),
|
||||||
|
configurable: false,
|
||||||
|
writable: false,
|
||||||
|
});
|
||||||
|
|
||||||
const locales = markRaw({
|
return dataObj;
|
||||||
he: "עברית",
|
},
|
||||||
hr: "Hrvatski",
|
methods: {
|
||||||
hu: "Magyar",
|
change(event) {
|
||||||
ar: "العربية",
|
this.$emit("update:locale", event.target.value);
|
||||||
ca: "Català",
|
},
|
||||||
cs: "Čeština",
|
},
|
||||||
de: "Deutsch",
|
|
||||||
el: "Ελληνικά",
|
|
||||||
en: "English",
|
|
||||||
es: "Español",
|
|
||||||
fr: "Français",
|
|
||||||
is: "Icelandic",
|
|
||||||
it: "Italiano",
|
|
||||||
ja: "日本語",
|
|
||||||
ko: "한국어",
|
|
||||||
"nl-be": "Dutch (Belgium)",
|
|
||||||
no: "Norsk",
|
|
||||||
pl: "Polski",
|
|
||||||
"pt-br": "Português",
|
|
||||||
pt: "Português (Brasil)",
|
|
||||||
ro: "Romanian",
|
|
||||||
ru: "Русский",
|
|
||||||
sk: "Slovenčina",
|
|
||||||
"sv-se": "Swedish (Sweden)",
|
|
||||||
tr: "Türkçe",
|
|
||||||
uk: "Українська",
|
|
||||||
vi: "Tiếng Việt",
|
|
||||||
"zh-cn": "中文 (简体)",
|
|
||||||
"zh-tw": "中文 (繁體)",
|
|
||||||
});
|
|
||||||
|
|
||||||
const change = (event: Event) => {
|
|
||||||
emit("update:locale", (event.target as HTMLSelectElement).value);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user