mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-08 10:25:32 -05:00
Compare commits
222
Commits
v0.3.2
...
v2.0.7-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6c872ee7 | ||
|
|
79de9204b5 | ||
|
|
07a297a937 | ||
|
|
012e514017 | ||
|
|
f654699b99 | ||
|
|
ae7b2db598 | ||
|
|
214683ae2e | ||
|
|
f03534db06 | ||
|
|
8488c42f5f | ||
|
|
b07300a57d | ||
|
|
42f82811e7 | ||
|
|
6fffdd3840 | ||
|
|
7aebbfe04c | ||
|
|
fa58eac9c0 | ||
|
|
752ff247d3 | ||
|
|
1d08a4549b | ||
|
|
b12add4317 | ||
|
|
5610cdd2bf | ||
|
|
ea85c50946 | ||
|
|
be47c7a1e7 | ||
|
|
609e1d6482 | ||
|
|
3c8c02414e | ||
|
|
29f9055db9 | ||
|
+10 |
3c02eedca6 | ||
|
|
8c5c92bef0 | ||
|
|
b6c9cc9762 | ||
|
|
42c71c75cb | ||
|
|
9503b532e1 | ||
|
|
a825ddd301 | ||
|
|
da085bca69 | ||
|
|
64250ffff0 | ||
|
|
0bc8889c00 | ||
|
|
6eaead073a | ||
|
|
826b060bb1 | ||
|
|
21dfc6169e | ||
|
|
4708faec5f | ||
|
|
fa5abc8c67 | ||
|
|
687dc01293 | ||
|
|
bfde65a41e | ||
|
|
1802e12811 | ||
|
|
0f215333ee | ||
|
|
6d81d1973c | ||
|
|
869b640a99 | ||
|
|
3b8d179774 | ||
|
|
569314cdf4 | ||
|
|
668efec4d7 | ||
|
|
a5c81c8e2e | ||
|
|
88aa337696 | ||
|
|
9b48ce414e | ||
|
|
0723edf660 | ||
|
|
d2165ec0c3 | ||
|
|
78c554719f | ||
|
|
aa8f9e2923 | ||
|
|
3bfd9d9be0 | ||
|
|
ce5269e8e9 | ||
|
|
6d21c7918e | ||
|
|
2693026f6c | ||
|
|
eb4dfa5710 | ||
|
|
363619bcb4 | ||
|
|
ab9790427f | ||
|
|
eabdfd9aa2 | ||
|
|
af08800667 | ||
|
|
319b2d06e6 | ||
|
|
2d2b3ade62 | ||
|
|
513946bdbf | ||
|
|
86ed0891e0 | ||
|
|
d8bf9422c2 | ||
|
|
a7169e8dad | ||
|
|
72f9fcbd64 | ||
|
|
d192ede484 | ||
|
|
da0c8bd0f1 | ||
|
|
5108330405 | ||
|
|
1c7e3c2ad2 | ||
|
|
2eb352c2d3 | ||
|
|
43bb4a36b4 | ||
|
|
c67abefc7b | ||
|
|
2ecb9036aa | ||
|
|
dbcfba993b | ||
|
|
ed22ce9421 | ||
|
|
8caa6d6a60 | ||
|
|
fb1af87048 | ||
|
+4 |
f73ca51f94 | ||
|
+4 |
ad839f92ac | ||
|
|
eb47df7172 | ||
|
+4 |
f2f5d776ca | ||
|
|
4f73b4cc6b | ||
|
|
5d7d94d86a | ||
|
|
9723799e4f | ||
|
|
3e5f0312f3 | ||
|
|
131c3a3686 | ||
|
|
7e5a579dde | ||
|
|
cb2d4dc518 | ||
|
|
8587ffc092 | ||
|
|
de6fefad4e | ||
|
|
9d18a369c7 | ||
|
|
9b599347f7 | ||
|
|
37596b1e23 | ||
|
|
d35fb56587 | ||
|
|
5dab4c7b30 | ||
|
|
a33dbfc464 | ||
|
|
18738b3273 | ||
|
|
1d8c74dbd9 | ||
|
|
0ab22e3a89 | ||
|
|
8b1bf3cebf | ||
|
|
6d11e01054 | ||
|
|
3450149d24 | ||
|
|
54658308f2 | ||
|
|
e34b65918c | ||
|
|
eca9086a62 | ||
|
|
39514169f1 | ||
|
|
fbc29056aa | ||
|
|
3355306bd7 | ||
|
|
9ff92e079f | ||
|
|
e398f1f5af | ||
|
|
aed5c7bed7 | ||
|
|
30503683c6 | ||
|
|
256f4ca0b8 | ||
|
|
831708fe65 | ||
|
|
50bd853266 | ||
|
|
206180de0d | ||
|
|
5b0f388fa5 | ||
|
|
7d0707ebe0 | ||
|
|
41cb97a1c5 | ||
|
|
0b212ce1b4 | ||
|
|
7f0a0209af | ||
|
|
642333ec68 | ||
|
|
ec43926d3c | ||
|
|
9202043000 | ||
|
|
4d04e02c31 | ||
|
|
04d72998b6 | ||
|
|
f836a2c4df | ||
|
|
cc0212b754 | ||
|
|
f66a4d9313 | ||
|
|
3e59bf90f7 | ||
|
|
812f30cccd | ||
|
|
93a2e3e05e | ||
|
|
1f1d1c5b44 | ||
|
|
c581ea760e | ||
|
|
e3dc484c28 | ||
|
|
0ada9eeffa | ||
|
|
412c68232c | ||
|
|
5abbe1d64b | ||
|
|
80b1259dd1 | ||
|
|
96cf2d6a5e | ||
|
|
a69ad4b879 | ||
|
|
f40209751e | ||
|
|
9f156fdefb | ||
|
|
093acb91b1 | ||
|
|
36e826af7f | ||
|
|
02650e62c4 | ||
|
|
b1f97d3afa | ||
|
|
8faaeb39db | ||
|
|
04911ca7b0 | ||
|
|
0a9af58b07 | ||
|
|
40374752a3 | ||
|
|
c667218e30 | ||
|
|
183d2d89e2 | ||
|
|
9c1d1a58cd | ||
|
|
c4a5b0dcf9 | ||
|
|
7ae5a0fdcb | ||
|
|
f73ab72d46 | ||
|
|
1ba5d35ba8 | ||
|
|
5adbc065ed | ||
|
|
ad06eaed3e | ||
|
|
d2ed17e4d9 | ||
|
|
3813ced6bd | ||
|
|
5c41bbd7a7 | ||
|
|
7da6115efe | ||
|
|
fb5bab79e1 | ||
|
|
2e2fe5200c | ||
|
|
418b187acf | ||
|
|
2972772dda | ||
|
|
a83f39d41f | ||
|
|
2de5dbaf68 | ||
|
|
db77552bd8 | ||
|
|
2c26b6fc45 | ||
|
|
948add54fc | ||
|
|
f56fa85fa4 | ||
|
|
23db04bacb | ||
|
|
dfe024ab62 | ||
|
|
ce292c7be1 | ||
|
|
e39cf2ddf9 | ||
|
|
6909df0071 | ||
|
|
8460ddbc13 | ||
|
|
be374d75f5 | ||
|
|
c153a39543 | ||
|
|
3d51a5af7a | ||
|
|
e6850feddc | ||
|
|
a6f7883e64 | ||
|
|
f28115afc3 | ||
|
|
c4ccfd48f5 | ||
|
|
c84a3b0d41 | ||
|
|
545a831c66 | ||
|
|
e269fc8c0a | ||
|
|
0bc789f9bd | ||
|
|
02482ab8d4 | ||
|
|
087c72507f | ||
|
|
7f9e6ac184 | ||
|
|
c168599c91 | ||
|
|
a30bfcf7d0 | ||
|
|
b273f44cac | ||
|
|
f9a31f08c3 | ||
|
|
09493392db | ||
|
|
7e68db9858 | ||
|
|
34f6bea461 | ||
|
|
1e35473998 | ||
|
|
bd4d108b58 | ||
|
|
288ea8f59b | ||
|
|
f18c388c88 | ||
|
|
e922a92aa6 | ||
|
|
237769fa9e | ||
|
|
9a33e7b379 | ||
|
|
cd3e111e39 | ||
|
|
9cc30f25e4 | ||
|
|
7ab400eb7f | ||
|
|
4a4eb7ce31 | ||
|
|
f864790175 | ||
|
|
6494ca1991 | ||
|
|
93925d8430 | ||
|
|
0270711a22 | ||
|
|
d2a3e50d37 | ||
|
|
266a76459d |
No files matched your search
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "filebrowser",
|
||||
"image": "mcr.microsoft.com/devcontainers/go:1.25-bookworm",
|
||||
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "22"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
|
||||
"containerEnv": {
|
||||
"CGO_ENABLED": "1",
|
||||
"FILEBROWSER_DEVMODE": "true"
|
||||
},
|
||||
|
||||
"forwardPorts": [80],
|
||||
"portsAttributes": {
|
||||
"80": {
|
||||
"label": "filebrowser app",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
|
||||
"postCreateCommand": "make setup",
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-azuretools.vscode-docker"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -1,4 +1,17 @@
|
||||
backend/**.db
|
||||
backend/*.sqlite
|
||||
backend/*.sqlite.*
|
||||
**/*.sqlite
|
||||
**/*.sqlite.*
|
||||
backend/filebrowser.sqlite*
|
||||
**.crt
|
||||
**.key
|
||||
backend/tmp
|
||||
backend/dist
|
||||
backend/backend*
|
||||
backend/internal/web/dist/**
|
||||
backend/vendor/**
|
||||
|
||||
frontend/dist/**
|
||||
frontend/node_modules/**
|
||||
frontend/node_modules/**
|
||||
frontend/pkg/**
|
||||
@@ -1,20 +1,10 @@
|
||||
**Description**
|
||||
<!--
|
||||
Please explain the changes you made here.
|
||||
If the feature changes current behaviour, explain why your solution is better.
|
||||
-->
|
||||
|
||||
:rotating_light: Before submitting your PR, please read [community](https://github.com/filebrowser/community), and indicate which issues (in any of the repos) are either fixed or closed by this PR. See [GitHub Help: Closing issues using keywords](https://help.github.com/articles/closing-issues-via-commit-messages/).
|
||||
According to the [contributing guide](https://github.com/gtsteffaniak/filebrowser/wiki/Contributing#contributing-as-an-unofficial-contributor), A PR should contain:
|
||||
|
||||
- [ ] DO make sure you are requesting to **pull a topic/feature/bugfix branch** (right side). Don't request your master!
|
||||
- [ ] DO make sure you are making a pull request against the **master branch** (left side). Also you should start *your branch* off *our master*.
|
||||
- [ ] DO make sure that File Browser can be successfully built. See [builds](https://github.com/filebrowser/community/blob/master/builds.md) and [development](https://github.com/filebrowser/community/blob/master/development.md).
|
||||
- [ ] DO make sure that related issues are opened in other repositories. I.e., the frontend, caddy plugins or the web page need to be updated accordingly.
|
||||
- [ ] AVOID breaking the continuous integration build.
|
||||
- [ ] A clear description of why it was opened.
|
||||
- [ ] A short title that best describes the change.
|
||||
- [ ] Must pass unit and integration tests, which can be run checked locally prior to opening a PR.
|
||||
- [ ] Any additional details for functionality not covered by tests.
|
||||
|
||||
**Further comments**
|
||||
<!--
|
||||
If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did, what alternatives you considered, etc.
|
||||
|
||||
:heart: Thank you!
|
||||
-->
|
||||
**Additional Details**
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Create Dev Version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to create (format: 0.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
create-dev-branch:
|
||||
name: Create Dev Branch
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }} # Uses the Personal Access Token
|
||||
|
||||
- name: Validate version format
|
||||
id: validate_version
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "❌ ERROR: Version must be in '0.0.0' format."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Version format validated: $VERSION"
|
||||
|
||||
- name: Check if branch already exists
|
||||
id: check_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
DEV_BRANCH="dev/v$VERSION"
|
||||
|
||||
if git ls-remote --exit-code origin "$DEV_BRANCH"; then
|
||||
echo "❌ ERROR: Branch '$DEV_BRANCH' already exists!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Branch '$DEV_BRANCH' does not exist."
|
||||
|
||||
- name: Create and push new dev branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
DEV_BRANCH="dev/v$VERSION"
|
||||
|
||||
# Fetch latest changes
|
||||
git fetch origin main
|
||||
|
||||
# Create new branch from main
|
||||
git checkout -b "$DEV_BRANCH" origin/main
|
||||
|
||||
# Push branch to remote
|
||||
git push origin "$DEV_BRANCH"
|
||||
|
||||
echo "✅ Successfully created branch '$DEV_BRANCH'!"
|
||||
+18
-27
@@ -5,42 +5,33 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
push_latest_to_registry:
|
||||
name: Push latest
|
||||
runs-on: ubuntu-latest
|
||||
test_playwright:
|
||||
name: Test Playwright
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Find latest tag
|
||||
run: |
|
||||
echo "LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`)" >> $GITHUB_ENV
|
||||
echo "latest tag is $LATEST_TAG"
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
images: gtstef/filebrowser
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
VERSION=${{ env.LATEST_TAG }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: 'gtstef/filebrowser:latest'
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
+119
-23
@@ -1,31 +1,132 @@
|
||||
name: pr-request
|
||||
name: PR Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
- "dev/v*.*.*"
|
||||
- "beta/v*.*.*"
|
||||
- "stable/v*.*.*"
|
||||
- "staging/v*.*.*"
|
||||
jobs:
|
||||
test_frontend:
|
||||
name: Push release
|
||||
runs-on: ubuntu-latest
|
||||
prime_docker_cache:
|
||||
name: Prime docker cache
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Build
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Prime Cache
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.playwright
|
||||
file: _docker/Dockerfile.primecache
|
||||
push: false
|
||||
load: false
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
|
||||
build_frontend:
|
||||
name: Build frontend
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- name: Upload frontend build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: backend/internal/web/dist
|
||||
build_backend:
|
||||
name: Build backend
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Build backend
|
||||
working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Upload backend build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: filebrowser
|
||||
path: backend/filebrowser
|
||||
test_playwright:
|
||||
needs: [build_backend, build_frontend, prime_docker_cache]
|
||||
name: Test Playwright - ${{ matrix.name }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: general
|
||||
dockerfile: ./_docker/Dockerfile.playwright-general
|
||||
- name: proxy
|
||||
dockerfile: ./_docker/Dockerfile.playwright-proxy
|
||||
- name: noauth
|
||||
dockerfile: ./_docker/Dockerfile.playwright-noauth
|
||||
- name: oidc
|
||||
dockerfile: ./_docker/Dockerfile.playwright-oidc
|
||||
- name: sharing
|
||||
dockerfile: ./_docker/Dockerfile.playwright-sharing
|
||||
- name: no-config
|
||||
dockerfile: ./_docker/Dockerfile.playwright-no-config
|
||||
- name: settings
|
||||
dockerfile: ./_docker/Dockerfile.playwright-settings
|
||||
- name: screenshots
|
||||
dockerfile: ./_docker/Dockerfile.playwright-screenshots
|
||||
- name: previews
|
||||
dockerfile: ./_docker/Dockerfile.playwright-previews
|
||||
- name: jwt
|
||||
dockerfile: ./_docker/Dockerfile.playwright-jwt
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Download frontend build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: frontend/dist
|
||||
- name: Download backend build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: filebrowser
|
||||
path: backend/
|
||||
- name: Make backend executable
|
||||
run: chmod +x backend/filebrowser
|
||||
- name: Copy frontend build to backend
|
||||
run: |
|
||||
mkdir -p backend/internal/web/embed
|
||||
cp -r frontend/dist/* backend/internal/web/embed
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Build and Test
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: false
|
||||
load: true
|
||||
cache-from: |
|
||||
type=gha,scope=${{ github.ref_name }}-playwright-base
|
||||
type=gha,scope=main-playwright-base
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
|
||||
push_pr_to_registry:
|
||||
name: Push PR
|
||||
runs-on: ubuntu-latest
|
||||
name: Build docker image
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -33,24 +134,19 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: gtstef/filebrowser
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
file: ./_docker/Dockerfile.slim
|
||||
push: false # Do not push the image for now
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
REVISION=${{ steps.meta.outputs.revision }}
|
||||
REVISION=${{ steps.meta.outputs.revision }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Promote Dev to Beta
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to promote (format: 0.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
promote-dev-to-beta:
|
||||
name: Promote Dev to Beta
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }} # Uses the Personal Access Token
|
||||
|
||||
- name: Validate version format
|
||||
id: validate_version
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "❌ ERROR: Version must be in 'v0.0.0' format."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Version format validated: $VERSION"
|
||||
|
||||
- name: Check if dev branch exists
|
||||
id: check_dev_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
DEV_BRANCH="dev/v$VERSION"
|
||||
|
||||
if ! git ls-remote --exit-code origin "$DEV_BRANCH"; then
|
||||
echo "❌ ERROR: Dev branch '$DEV_BRANCH' does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Dev branch '$DEV_BRANCH' exists."
|
||||
|
||||
- name: Check if beta branch already exists
|
||||
id: check_beta_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
|
||||
if git ls-remote --exit-code origin "$BETA_BRANCH"; then
|
||||
echo "❌ ERROR: Beta branch '$BETA_BRANCH' already exists!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Beta branch '$BETA_BRANCH' does not exist."
|
||||
|
||||
- name: Create and push new beta branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
DEV_BRANCH="dev/v$VERSION"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
|
||||
# Fetch latest changes
|
||||
git fetch origin "$DEV_BRANCH"
|
||||
|
||||
# Create new beta branch from dev branch
|
||||
git checkout -b "$BETA_BRANCH" origin/"$DEV_BRANCH"
|
||||
|
||||
# Push new beta branch to remote
|
||||
git push origin "$BETA_BRANCH"
|
||||
|
||||
echo "✅ Successfully created beta branch '$BETA_BRANCH' from '$DEV_BRANCH'."
|
||||
|
||||
- name: Merge dev into beta
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
DEV_BRANCH="dev/v$VERSION"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
|
||||
# Checkout beta branch
|
||||
git checkout "$BETA_BRANCH"
|
||||
|
||||
# Merge dev into beta
|
||||
git merge --no-ff "origin/$DEV_BRANCH" -m "Merge $DEV_BRANCH into $BETA_BRANCH"
|
||||
|
||||
# Push merge changes
|
||||
git push origin "$BETA_BRANCH"
|
||||
|
||||
echo "✅ Successfully merged '$DEV_BRANCH' into '$BETA_BRANCH'."
|
||||
@@ -1,33 +1,31 @@
|
||||
name: dev tests
|
||||
name: regular tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go test -race -v ./...
|
||||
run: go test -race -v ./... -timeout 30s
|
||||
lint-backend:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23.3'
|
||||
- uses: golangci/golangci-lint-action@v5
|
||||
with:
|
||||
version: v1.60
|
||||
working-directory: backend
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go tool golangci-lint run --path-prefix=backend
|
||||
format-backend:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -36,16 +34,47 @@ jobs:
|
||||
- working-directory: backend
|
||||
run: go fmt ./...
|
||||
lint-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run lint
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run test
|
||||
check-translations:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run i18n:check
|
||||
check-docs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- run: make generate-docs
|
||||
- run: git diff --exit-code
|
||||
check-icons:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run icons:check
|
||||
@@ -1,76 +0,0 @@
|
||||
name: version release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
create_release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
|
||||
id: extract_branch
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.22.2
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ steps.extract_branch.outputs.branch }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.branch }}
|
||||
prerelease: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.branch }}
|
||||
|
||||
push_release_to_registry:
|
||||
name: Push release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: gtstef/filebrowser
|
||||
- name: Strip v from version number
|
||||
id: modify-json
|
||||
run: |
|
||||
JSON="${{ steps.meta.outputs.tags }}"
|
||||
# Use jq to remove 'v' from the version field
|
||||
JSON=$(echo "$JSON" | sed 's/filebrowser:v/filebrowser:/')
|
||||
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,50 +0,0 @@
|
||||
name: dev release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "dev_v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
push_release_to_registry:
|
||||
name: Push dev release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: gtstef/filebrowser
|
||||
- name: Strip v from version number
|
||||
id: modify-json
|
||||
run: |
|
||||
JSON="${{ steps.meta.outputs.tags }}"
|
||||
# Use jq to remove 'v' from the version field
|
||||
JSON=$(echo "$JSON" | sed 's/filebrowser:v/filebrowser:/')
|
||||
echo "CLEANED_TAG=$JSON" >> $GITHUB_ENV
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.CLEANED_TAG }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
+177
-5
@@ -1,9 +1,9 @@
|
||||
name: tag
|
||||
name: tag update
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -11,17 +11,20 @@ permissions:
|
||||
jobs:
|
||||
update_tag:
|
||||
name: Update Release tag
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Install dependencies and build frontend
|
||||
run: npm i && npm run build
|
||||
working-directory: frontend
|
||||
- name: Install UPX
|
||||
run: sudo apt-get install -y upx
|
||||
- name: Install OS Requirements
|
||||
run: sudo apt update && sudo apt install -y upx build-essential musl-tools
|
||||
- uses: goto-bus-stop/setup-zig@v2
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -29,3 +32,172 @@ jobs:
|
||||
workdir: backend
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
push_release_to_registry:
|
||||
name: Push release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{github.actor}}
|
||||
password: ${{ secrets.PAT }}
|
||||
- name: Extract metadata (tags, labels) for Docker and GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
gtstef/filebrowser
|
||||
ghcr.io/gtsteffaniak/filebrowser
|
||||
- name: Modify tags (strip 'v' and add version tags)
|
||||
id: modify-json
|
||||
run: |
|
||||
JSON='${{ steps.meta.outputs.json }}'
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq '
|
||||
.tags |= map(
|
||||
sub(":v"; ":") # Strip the 'v'
|
||||
| select(test(":latest") | not)
|
||||
) |
|
||||
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor and major tags
|
||||
.tags += [
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
| ($version | split("-")) as $version_split
|
||||
| ($version_split[0] | split(".")) as $version_parts
|
||||
| ($version_split[1:] | join("-")) as $suffix
|
||||
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
|
||||
| ($version_parts[0]) as $major
|
||||
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
] |
|
||||
# Add suffix-only tags for beta (only if tag ends with -beta)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-beta")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:beta",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta"
|
||||
]
|
||||
else . end |
|
||||
# Add suffix-only tags for stable (and latest) (only if tag ends with -stable)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-stable")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable",
|
||||
"gtstef/filebrowser:latest",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:latest"
|
||||
]
|
||||
else . end
|
||||
')
|
||||
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
|
||||
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
|
||||
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: ./_docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
push_slim_release_to_registry:
|
||||
name: Push slim release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{github.actor}}
|
||||
password: ${{ secrets.PAT }}
|
||||
- name: Extract metadata (tags, labels) for Docker and GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
gtstef/filebrowser
|
||||
ghcr.io/gtsteffaniak/filebrowser
|
||||
- name: Modify tags (strip 'v' and add 'slim' if needed)
|
||||
id: modify-json-slim
|
||||
run: |
|
||||
JSON='${{ steps.meta.outputs.json }}'
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq '
|
||||
.tags |= map(
|
||||
sub(":v"; ":") # Strip the 'v'
|
||||
| select(test(":latest") | not) # Remove the "latest" tag
|
||||
| . + "-slim" # Append "-slim" to the versioned tag
|
||||
) |
|
||||
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor and major tags
|
||||
.tags += [
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
| ($version | split("-")) as $version_split
|
||||
| ($version_split[0] | split(".")) as $version_parts
|
||||
| ($version_split[1:] | join("-")) as $suffix
|
||||
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
|
||||
| ($version_parts[0]) as $major
|
||||
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
] |
|
||||
# Add suffix-only tags for beta-slim (only if tag ends with -beta-slim)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:beta-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
|
||||
]
|
||||
else . end |
|
||||
# Add suffix-only tags for stable-slim (only if tag ends with -stable-slim)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
|
||||
]
|
||||
else . end
|
||||
')
|
||||
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
|
||||
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
|
||||
- name: Build and push slim image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./_docker/Dockerfile.slim
|
||||
push: true
|
||||
tags: ${{ steps.modify-json-slim.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,41 @@
|
||||
|
||||
name: Update Build Images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish_playwright_base:
|
||||
name: Publish playwright-base to GHCR
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/gtsteffaniak/playwright-base
|
||||
tags: chromium
|
||||
- name: Build and push playwright-base
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: _docker/Dockerfile.playwright-base
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=playwright-base
|
||||
cache-to: type=gha,mode=max,scope=playwright-base
|
||||
+33
-5
@@ -1,19 +1,41 @@
|
||||
*.db
|
||||
*.db.old
|
||||
# Playwright settings migration fixture (BoltDB → SQLite tests + Docker settings image)
|
||||
!_docker/src/settings/backend/database.db.old
|
||||
*.sqlite**
|
||||
*.bak
|
||||
*.log
|
||||
*.mjs
|
||||
_old
|
||||
rice-box.go
|
||||
.idea/
|
||||
/filebrowser
|
||||
/filebrowser.exe
|
||||
/backend/backend
|
||||
/backend/filebrowser
|
||||
/backend/filebrowser.exe
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
/backend/backend.exe
|
||||
/backend/playwright-files.db
|
||||
/backend/tmp
|
||||
/backend/dist
|
||||
/frontend/dist
|
||||
/frontend/pkg
|
||||
/frontend/test-results
|
||||
/frontend/package-lock.json
|
||||
/frontend/playwright-files
|
||||
/frontend/screenshots
|
||||
/frontend/generated
|
||||
frontend/.venv-icons
|
||||
frontend/.tmp-icons-*
|
||||
/frontend/tests/playwright-files/demo*
|
||||
/frontend/loginAuth.json
|
||||
/frontend/sharePrepStorage.json
|
||||
/backend/vendor
|
||||
/backend/*.cov
|
||||
/backend/test_config.yaml
|
||||
/backend/*test_config.yaml
|
||||
/backend/generated.yaml
|
||||
/backend/srv
|
||||
/backend/http/dist
|
||||
/backend/internal/web/dist
|
||||
/backend/internal/web/embed
|
||||
/backend/debug
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
@@ -37,3 +59,9 @@ yarn-error.log*
|
||||
*.sw*
|
||||
bin/
|
||||
build/
|
||||
*__debug*
|
||||
backend/custom.css
|
||||
|
||||
# Runtime cache directories created by tests/application
|
||||
backend/preview/heic/
|
||||
backend/preview/thumbnails/
|
||||
+2091
-1
File diff suppressed because it is too large.
Load diff
+147
@@ -0,0 +1,147 @@
|
||||
# Contributing to FileBrowser Quantum
|
||||
|
||||
Thank you for your interest in contributing to FileBrowser Quantum! This guide will help you get started with development.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.25+** (see `backend/go.mod`)
|
||||
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
|
||||
- **Docker** (optional, for containerized development)
|
||||
- **Git** especially important on Windows -- needed for bash support.
|
||||
|
||||
### Optional Tools
|
||||
|
||||
- **ffmpeg/ffprobe**: For media features (subtitles, thumbnails, heic)
|
||||
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
|
||||
- macOS: `brew install ffmpeg`
|
||||
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
|
||||
|
||||
- **mupdf-tools**: For PDF preview generation
|
||||
- Ubuntu/Debian: `sudo apt-get install mupdf-tools`
|
||||
- macOS: `brew install mupdf-tools`
|
||||
- Windows: Download from [mupdf.com](https://mupdf.com/downloads/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/gtsteffaniak/filebrowser.git
|
||||
cd filebrowser
|
||||
|
||||
# 2. Initial setup - installs dependencies and creates test config
|
||||
make setup
|
||||
|
||||
# 3. Run in development mode with hot-reloading
|
||||
make dev
|
||||
```
|
||||
|
||||
`make setup` installs all dependencies and creates a test configuration file.
|
||||
`make run` starts the development server. Note: changes require ctrl+c and re-run.
|
||||
|
||||
## Project Architecture
|
||||
|
||||
### Backend (Go)
|
||||
- **Entry Point**: `backend/main.go` → `backend/cmd/`
|
||||
- **HTTP Server**: `backend/internal/web/` - API routes, middleware, auth
|
||||
- **Storage**: SQLite via `backend/internal/database/`
|
||||
- **Authentication**: Multiple providers in `backend/internal/auth/`
|
||||
- **Indexing**: Real-time search in `backend/pkg/indexing/`
|
||||
- **Previews**: Image/video/document generation in `backend/internal/preview/`
|
||||
- **Activity**: Query, recording, and export logic in `backend/internal/activity/`
|
||||
|
||||
### Frontend (Vue.js + TypeScript)
|
||||
- **Framework**: Vue 3 + Vite + TypeScript
|
||||
- **State**: Custom store in `frontend/src/store/`
|
||||
- **API Client**: Axios-based in `frontend/src/api/`
|
||||
- **i18n**: 25+ languages with English as main
|
||||
- **Components**: Feature-based organization
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Essential Commands
|
||||
```bash
|
||||
make dev # Start development server with hot-reloading
|
||||
make test # Run all tests
|
||||
make lint # Check code quality
|
||||
make check-all # Lint + tests
|
||||
|
||||
make build-frontend # Build frontend only
|
||||
make build-backend # Build backend only
|
||||
make build # Build Docker image
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
Note: consider using make commands above instead.
|
||||
```bash
|
||||
cd frontend
|
||||
npm run lint:fix # Auto-fix linting issues
|
||||
npm run i18n:sync # Sync translations changes
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
make test # All tests
|
||||
make test-backend # Go tests with race detection
|
||||
make test-frontend # Frontend unit tests
|
||||
make test-playwright # E2E tests in Docker
|
||||
```
|
||||
|
||||
E2E tests run with three authentication modes: standard auth, no auth, and proxy auth.
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (Go)
|
||||
- **Linting**: `backend/.golangci.yml` with 30+ checks
|
||||
- **Format**: Use `gofmt` (automated in CI)
|
||||
- **Testing**: Maintain 80%+ coverage
|
||||
- **Errors**: Handle all errors explicitly
|
||||
|
||||
### Frontend (Vue.js)
|
||||
- **Linting**: ESLint with Vue 3 + TypeScript rules
|
||||
- **i18n**: English is master locale, all text must use `$t('key')`
|
||||
- **Types**: Use TypeScript everywhere
|
||||
- **Fix**: Run `npm run lint:fix` before committing
|
||||
|
||||
## Build & Deployment
|
||||
|
||||
### Single Binary Build
|
||||
The project builds into a single binary with embedded frontend:
|
||||
|
||||
```bash
|
||||
make build-frontend # Build Vue.js app
|
||||
make build-backend # Build Go binary with embedded assets
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
make build # Full image with ffmpeg and muPDF
|
||||
```
|
||||
|
||||
### Configuration
|
||||
- **Test Config**: `backend/test_config.yaml` Auto-generated after running `make setup`
|
||||
|
||||
## Contributing
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. Fork and create a feature branch
|
||||
2. Make your changes following the code standards
|
||||
3. Run `make dev` to build and run with your changes. Supports hot-reloading frontend and backend changes.
|
||||
4. When ready, run `make check-all` to verify tests and linting
|
||||
5. Submit PR with a clear description
|
||||
|
||||
### PR Requirements
|
||||
- Clear description of changes
|
||||
- All tests must pass
|
||||
- Follow existing code patterns
|
||||
- Update documentation if needed
|
||||
|
||||
### Commit Format
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
Types: feat, fix, docs, refactor, test, chore
|
||||
```
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
FROM golang:1.23-alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
#RUN swag init --output swagger/docs
|
||||
RUN ln -s swagger /usr/local/go/src/
|
||||
RUN go build -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
|
||||
FROM node:slim AS nbuild
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
RUN npm i --maxsockets 1
|
||||
COPY ./frontend/ ./
|
||||
RUN npm run build-docker
|
||||
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_NO_EMBEDED="true"
|
||||
RUN apk --no-cache add ca-certificates mailcap
|
||||
COPY --from=base /app/filebrowser* ./
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
COPY --from=nbuild /app/dist/ ./http/dist/
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -1,19 +0,0 @@
|
||||
FROM golang:1.23-alpine AS base
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN go build -ldflags="-w -s" -o filebrowser .
|
||||
|
||||
FROM node:slim
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
RUN npm i --maxsockets 1
|
||||
RUN npx playwright install --with-deps firefox
|
||||
COPY [ "backend/filebrowser.yaml", "./" ]
|
||||
COPY ./frontend/ ./frontend
|
||||
WORKDIR /app/frontend
|
||||
RUN npm run build-docker
|
||||
WORKDIR /app
|
||||
COPY --from=base /app/filebrowser* ./
|
||||
RUN cp -R frontend/tests/ srv
|
||||
ENV FILEBROWSER_NO_EMBEDED="true"
|
||||
RUN ./filebrowser & sleep 2 && cd frontend && npx playwright test
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2018 File Browser contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,243 +1,137 @@
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/license/apache-2-0/"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg" alt="License: Apache-2.0"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="frontend/public/img/icons/favicon-256x256.png" width="100" title="Login With Custom URL">
|
||||
</p>
|
||||
<h3 align="center">FileBrowser Quantum - A modern web-based file manager</h3>
|
||||
<p align="center">
|
||||
<img width="800" src="https://github.com/user-attachments/assets/e4a47229-66f8-4838-9575-dd2413596688" title="Main Screenshot">
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
> [!WARNING]
|
||||
> Starting with `v0.3.0` API routes have been slightly altered for friendly usage outside of the UI. The resources api returns items in separate `files` and `folder` objects now.
|
||||
[](https://app.codacy.com/gh/gtsteffaniak/filebrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://hub.docker.com/r/gtstef/filebrowser)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
> [!WARNING]
|
||||
> If on windows, please use docker. The windows binary is unstable and may not work.
|
||||
[](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A#is-there-a-way-to-donate-or-support-this-project)
|
||||
|
||||
> [!WARNING]
|
||||
> There is no stable version yet. Always check release notes for bugfixes on functionality that may have been changed. If you notice any unexpected behavior -- please open an issue to have it fixed soon.
|
||||
<img width="150" alt="FileBrowser Quantum logo" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174">
|
||||
<h3>FileBrowser Quantum</h3>
|
||||
The best free self-hosted web-based file manager.
|
||||
<br/><br/>
|
||||
<img width="800" alt="FileBrowser Quantum file listing in dark mode" src="https://filebrowserquantum.com/images/generated/listing/view-mode-normal-dark.jpg">
|
||||
</div>
|
||||
|
||||
FileBrowser Quantum is a fork of the file browser opensource project with the following changes:
|
||||
## Pinned
|
||||
|
||||
1. [x] Indexes files efficiently. See [indexing readme](./docs/indexing.md)
|
||||
- Real-time search results as you type
|
||||
- Search supports file/folder sizes and many file type filters.
|
||||
- Enhanced interactive results that shows file/folder sizes.
|
||||
1. [x] Revamped and simplified GUI navbar and sidebar menu.
|
||||
- Additional compact view mode as well as refreshed view mode
|
||||
styles.
|
||||
- Many graphical and user experience improvements.
|
||||
- right-click context menu
|
||||
1. [x] Revamped and simplified configuration via `filebrowser.yml` config file.
|
||||
1. [x] Better listing browsing
|
||||
- Switching view modes is instant
|
||||
- Folder sizes are shown as well
|
||||
- Changing Sort order is instant
|
||||
- The entire directory is loaded in 1/3 the time
|
||||
1. Developer API support
|
||||
- Can create long-live API Tokens.
|
||||
- Helpful Swagger page available at `/swagger` endpoint.
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
Notable features that this fork *does not* have (removed):
|
||||
|
||||
- jobs/runners are not supported yet (planned).
|
||||
- shell commands are completely removed and will not be returning.
|
||||
- themes and branding are not fully supported yet (planned).
|
||||
- see feature matrix below for more.
|
||||
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
|
||||
|
||||
## About
|
||||
|
||||
FileBrowser Quantum provides a file-managing interface within a specified directory
|
||||
and can be used to upload, delete, preview, rename, and edit your files.
|
||||
It allows the creation of multiple users and each user can have its
|
||||
directory.
|
||||
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has a modern responsive interface that has many advanced features to manage users, access, sharing, and file preview and editing.
|
||||
|
||||
This repository is a fork of the original [filebrowser](https://github.com/filebrowser/filebrowser)
|
||||
with a collection of changes that make this program work better in terms of
|
||||
aesthetics and performance. Improved search, simplified UI
|
||||
(without removing features) and more secure and up-to-date
|
||||
build are just a few examples.
|
||||
This version is called "Quantum" because it packs tons of advanced features into a tiny and easy-to-run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
|
||||
|
||||
FileBrowser Quantum differs significantly from the original.
|
||||
There are hundreds of thousands of lines changed and they are generally
|
||||
no longer compatible with each other. This has been intentional -- the
|
||||
focus of this fork is on a few key principles:
|
||||
- Simplicity and improved user experience
|
||||
- Improving performance and faster feedback when making changes.
|
||||
- Minimize external dependencies and standard library usage.
|
||||
- Of course -- adding much-needed features.
|
||||
The goal for this repo is to become the best open-source self-hosted file browsing application that exists -- **all for free**. This repo will always be free and open-source.
|
||||
|
||||
## Look
|
||||
Ready to try it out? See [Getting Started Docs](https://filebrowserquantum.com/en/docs/getting-started/).
|
||||
|
||||
One way you can observe the improved user experience is how I changed
|
||||
the UI. The Navbar is simplified to a three-component system :
|
||||
## How its different
|
||||
|
||||
1. (Left) The slide-out action panel button
|
||||
2. (Middle) The powerful search bar.
|
||||
3. (Right) The view change toggle.
|
||||
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
|
||||
|
||||
All other functions are moved either into the action menu or popup menus.
|
||||
1. ✅ Better source configuration - multiple sources, include/exclude rules, and [more](https://filebrowserquantum.com/en/docs/configuration/sources/)
|
||||
2. ✅ Login support for OIDC, LDAP, JWT, password + 2FA, and proxy.
|
||||
3. ✅ Beautiful, Responsive, and Customizable user interface.
|
||||
4. ✅ Streamlined configuration via `config.yaml` config file.
|
||||
5. ✅ Efficient search
|
||||
- Real-time search results as you type.
|
||||
- Real-time monitoring and updates in the UI.
|
||||
- Search supports file and folder sizes, along with various filters.
|
||||
6. ✅ Better listing browsing
|
||||
- Thumbnails support includes **office**, **video**, and **album artwork**, and **3D models**.
|
||||
- Faster and more responsive views with animations.
|
||||
- **Folder sizes** are displayed and support thumbnails
|
||||
- Navigating remembers the last scroll position.
|
||||
- WebDAV support
|
||||
- Granular permissions
|
||||
7. ✅ Highly configurable and [customizable sharing options](https://filebrowserquantum.com/en/docs/shares/options/)
|
||||
- share expiration time
|
||||
- users who can access share (including anonymous)
|
||||
- styling and themes
|
||||
- file viewing, editing, and uploading permissions
|
||||
8. ✅ Access control that can be scoped to user or group and source path.
|
||||
9. ✅ Developer API support
|
||||
- Ability to create long-lived API Tokens.
|
||||
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
|
||||
|
||||
Notable features that this fork *does not* have (removed):
|
||||
- ❌ shell commands are completely removed and will not be returned.
|
||||
|
||||
FileBrowser Quantum differs significantly from the original version. Many of these changes required a significant overhaul. Creating a fork was a necessary process to make the program better. There have been many growing pains, but a stable release is planned and coming soon.
|
||||
|
||||
## The UI
|
||||
|
||||
The UI has a simple three-component navigation system:
|
||||
|
||||
1. (Left) Multi-action button with slide-out panel.
|
||||
2. (Middle) The powerful search bar / title
|
||||
3. (Right) The view change toggle / overflow menu
|
||||
|
||||
All other functions are moved either into the action menu or pop-up menus.
|
||||
If the action does not depend on context, it will exist in the slide-out
|
||||
action panel. If the action is available based on context, it will show up as
|
||||
a popup menu.
|
||||
a pop-up menu.
|
||||
|
||||
<p align="center">
|
||||
<img width="800" src="https://github.com/user-attachments/assets/2be7a6c5-0f95-4d9f-bc05-484ee71246d8" title="Search GIF">
|
||||
<img width="800" src="https://github.com/user-attachments/assets/f55a6f1f-b930-4399-98b5-94da6e90527a" title="Navigation GIF">
|
||||
<img width="800" src="https://github.com/user-attachments/assets/75226dc4-9802-46f0-9e3c-e4403d3275da" title="Main Screenshot">
|
||||
</p>
|
||||
<img width="1200" height="750" alt="Aug-07-2026 14-54-55" src="https://github.com/user-attachments/assets/fd1c1747-f30f-4e21-b991-f45744416d7e" />
|
||||
|
||||
## Install
|
||||
## Official Docs
|
||||
|
||||
Using docker:
|
||||
|
||||
1. docker run (no persistent db):
|
||||
|
||||
```
|
||||
docker run -it -v /path/to/folder:/srv -p 80:80 gtstef/filebrowser
|
||||
```
|
||||
|
||||
1. docker compose:
|
||||
|
||||
- with local storage
|
||||
|
||||
```
|
||||
services:
|
||||
filebrowser:
|
||||
volumes:
|
||||
- '/path/to/folder:/srv' # required (for now not configurable)
|
||||
- './database:/database' # optional if you want db to persist - configure a path under "database" dir in config file.
|
||||
- './filebrowser.yaml:/filebrowser.yaml' # required
|
||||
ports:
|
||||
- '80:80'
|
||||
image: gtstef/filebrowser
|
||||
restart: always
|
||||
```
|
||||
|
||||
- with network share
|
||||
|
||||
```
|
||||
services:
|
||||
filebrowser:
|
||||
volumes:
|
||||
- 'storage:/srv' # required (for now not configurable)
|
||||
- './database:/database' # optional if you want db to persist - configure a path under "database" dir in config file.
|
||||
- './filebrowser.yaml:/filebrowser.yaml' # required
|
||||
ports:
|
||||
- '80:80'
|
||||
image: gtstef/filebrowser
|
||||
restart: always
|
||||
volumes:
|
||||
storage:
|
||||
driver_opts:
|
||||
type: cifs
|
||||
o: "username=admin,password=password,rw" # enter valid info here
|
||||
device: "//192.168.1.100/share/" # enter valid info here
|
||||
|
||||
```
|
||||
|
||||
Not using docker (not recommended), download your binary from releases and run with your custom config file:
|
||||
|
||||
```
|
||||
./filebrowser -c <filebrowser.yml or other /path/to/config.yaml>
|
||||
```
|
||||
|
||||
## Command Line Usage
|
||||
|
||||
There are very few commands available. There are 3 actions done via the command line:
|
||||
|
||||
1. Running the program, as shown on the install step. The only argument used is the config file if you choose to override the default "filebrowser.yaml"
|
||||
2. Checking the version info via `./filebrowser version`
|
||||
3. Updating the DB, which currently only supports adding users via `./filebrowser set -u username,password [-a] [-s "example/scope"]`
|
||||
|
||||
## API Usage
|
||||
|
||||
FileBrowser Quantum comes with a swagger page that can be accessed from the API section of settings or by going to `/swagger` to see the full list:
|
||||
|
||||

|
||||
|
||||
You use the token as a bearer token. For example in Postman:
|
||||
|
||||
|
||||
Successful Request:
|
||||
|
||||
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4f18fa8a-8d87-4f40-9dc7-3d4407769b59"></p>
|
||||
|
||||
Failed Request
|
||||
|
||||
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4da0deae-f93d-4d94-83b1-68806afb343a"></p>
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is now done via a single configuration file:
|
||||
`filebrowser.yaml`, here is an example of minimal [configuration
|
||||
file](./backend/filebrowser.yaml).
|
||||
|
||||
View the [Configuration Help Page](./docs/configuration.md) for available
|
||||
configuration options and other help.
|
||||
|
||||
|
||||
## Migration from the original filebrowser
|
||||
|
||||
If you currently use the original filebrowser but want to try using this.
|
||||
I would recommend that you start fresh without reusing the database. However,
|
||||
If you want to migrate your existing database to FileBrowser Quantum,
|
||||
visit the [migration
|
||||
readme](./docs/migration.md)
|
||||
See the [Official Docs](https://filebrowserquantum.com/). Contributions are welcome and encouraged! See [FilebrowserDocs Github](https://github.com/quantumx-apps/filebrowserDocs).
|
||||
|
||||
## Comparison Chart
|
||||
|
||||
Application Name | <img width="48" src="frontend/public/img/icons/favicon-256x256.png" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
|
||||
Application Name | <img width="48" alt="" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" alt="" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" alt="" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" alt="" src="https://cdn.iconscout.com/icon/free/png-256/free-google-drive-logo-icon-svg-download-png-2476481.png" > Google_Drive | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
|
||||
--- | --- | --- | --- | --- | --- | --- |
|
||||
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
ftp support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple sources at once | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Docker image size | 31 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Min. Memory Requirements | 128 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
|
||||
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
|
||||
has standalone binary | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
price | free | free | free | free tier | free tier | $99+ |
|
||||
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Custom job support | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
|
||||
Upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Advanced Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Indexed Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Single sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
2FA sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Mobile App | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
|
||||
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
sharable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
||||
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
office file support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
Branding support | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
activity log | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
collaboration on same file | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
trash support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
Content preview icons | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||
Plugins support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
|
||||
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Starred/pinned files | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
## Roadmap
|
||||
|
||||
see [Roadmap Page](./docs/roadmap.md)
|
||||
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can browse archive files | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Can convert documents | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Can convert videos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Can convert photos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
@@ -0,0 +1,50 @@
|
||||
FROM gtstef/ffmpeg:9.0-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev
|
||||
RUN go mod download && go run ./scripts/setup-gofitz-cgo
|
||||
ENV CGO_ENABLED=1
|
||||
RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN cd frontend && npm ci --maxsockets 1
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
|
||||
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
# copy config to two locations for compatibility
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
RUN [ "ffmpeg", "-version" ]
|
||||
RUN [ "ffprobe", "-version" ]
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:80/health || exit 1
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM node:22-slim
|
||||
WORKDIR /app/frontend
|
||||
|
||||
RUN npm i @playwright/test oauth2-mock-server @types/node
|
||||
RUN npx playwright install --with-deps chromium
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/general/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/general/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# generate duplicate 1.1MB files
|
||||
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser user set testuser1 --password testuser1 && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
COPY [ "./_docker/src/jwt/", "./" ]
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./_docker/src/settings/backend/config.yaml", "./"]
|
||||
COPY [ "./_docker/src/settings/backend/database.db.old", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser user set testuser1 --password testuser1
|
||||
RUN ./filebrowser set rule -s access -p / -r user -v admin --allow -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p /excluded/showme.txt -r user -v admin --allow -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p /denied -r user -v admin -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/no-config/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/no-config/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/noauth/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
|
||||
COPY [ "./_docker/src/oidc/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./_docker/src/oidc/backend/", "./"]
|
||||
COPY ["./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
WORKDIR /app/frontend
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN node mock-oidc-server.js & sleep 2 && cd ../backend/ && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/previews/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
COPY [ "./_docker/src/proxy/", "./" ]
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
|
||||
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
COPY [ "./_docker/src/screenshots/frontend/", "./" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/settings/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/settings/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser user set testuser1 --password testuser1
|
||||
RUN ./filebrowser set rule -s access -p / -r user -v admin --allow -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p /excluded/showme.txt -r user -v admin --allow -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p /denied -r user -v admin -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/sharing/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/sharing/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
@@ -0,0 +1,42 @@
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add
|
||||
RUN go build -tags analytics -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN cd frontend && npm ci
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:80/health || exit 1
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -0,0 +1,51 @@
|
||||
services:
|
||||
local-playwright:
|
||||
container_name: local-playwright
|
||||
ports:
|
||||
- "8080:8080"
|
||||
build:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-local
|
||||
context: ../
|
||||
local-playwright-screenshots:
|
||||
container_name: local-playwright
|
||||
volumes:
|
||||
- ../frontend/generated:/app/frontend/generated
|
||||
entrypoint: ["/bin/sh", "-c", "./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots && npx playwright test --project light-screenshots"]
|
||||
build:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-screenshots
|
||||
context: ../
|
||||
nginx-proxy-auth:
|
||||
image: nginx
|
||||
container_name: nginx-proxy-auth
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
|
||||
nginx-proxy-jwt:
|
||||
image: nginx
|
||||
container_name: nginx-proxy-jwt
|
||||
ports:
|
||||
- "8081:8081" # Admin user
|
||||
- "8082:8082" # Regular user
|
||||
- "8083:8083" # Wrong key
|
||||
- "8084:8084" # No user
|
||||
volumes:
|
||||
- ./src/jwt/backend/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/jwt/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
|
||||
filebrowser-jwt:
|
||||
hostname: filebrowser
|
||||
volumes:
|
||||
- '../frontend:/home/frontend'
|
||||
- "./src/jwt/backend/config.yaml:/home/filebrowser/data/config.yaml"
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./_docker/Dockerfile.slim
|
||||
filebrowser:
|
||||
hostname: filebrowser
|
||||
volumes:
|
||||
- '../frontend:/home/frontend'
|
||||
- "./src/proxy/backend/config.yaml:/home/filebrowser/data/config.yaml"
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./_docker/Dockerfile.slim
|
||||
@@ -0,0 +1,19 @@
|
||||
server:
|
||||
<<: *server-main
|
||||
http:
|
||||
<<: *http-main
|
||||
frontend:
|
||||
<<: *frontend-main
|
||||
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
models: true
|
||||
office: true
|
||||
highQuality: true
|
||||
account:
|
||||
permissions:
|
||||
share: true
|
||||
@@ -0,0 +1,13 @@
|
||||
frontend-main: &frontend-main
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
customThemes:
|
||||
no-rounded:
|
||||
description: "No rounded corners" # The description of the theme to display in the UI.
|
||||
css: "no-rounded.css" # The css file path and filename to use for the theme.
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
@@ -0,0 +1,24 @@
|
||||
http-main: &http-main
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
server-main: &server-main
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
defaultPermissions:
|
||||
view: true
|
||||
download: true
|
||||
modify: true
|
||||
create: true
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
rules:
|
||||
- folderPath: "/"
|
||||
viewable: true
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/general",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
http:
|
||||
port: 8080
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: true
|
||||
defaultPermissions:
|
||||
view: true
|
||||
download: true
|
||||
modify: true
|
||||
create: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: false
|
||||
jwt:
|
||||
enabled: true
|
||||
secret: "test-jwt-secret-key-for-development-only-do-not-use-in-production"
|
||||
algorithm: "HS256"
|
||||
header: "X-JWT-Assertion"
|
||||
userIdentifier: "sub"
|
||||
groupsClaim: "mygroup"
|
||||
adminGroup: "admin"
|
||||
userDefaults:
|
||||
ui:
|
||||
darkMode: true
|
||||
account:
|
||||
disableSettings: false
|
||||
permissions:
|
||||
share: true
|
||||
listing:
|
||||
singleClick: false
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
@@ -0,0 +1,66 @@
|
||||
# Admin user (port 8081)
|
||||
server {
|
||||
listen 8081;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
set $admin_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NTc2NiwiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNTc2NiwibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDU3NjYsInN1YiI6InRlc3RhZG1pbiJ9.pxuKGyMmEe94m0xQ7y1QzcUZgU-lZSvqjoKUVoVd3F4";
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion $admin_token;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
|
||||
# Regular user (port 8082)
|
||||
server {
|
||||
listen 8082;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
set $regular_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJleHAiOjIwODc1NjU2OTIsImdyb3VwcyI6WyJteWdyb3VwIl0sImlhdCI6MTc3MjIwNTY5MiwibmFtZSI6IlRlc3QgVXNlciIsIm5iZiI6MTc3MjIwNTY5Miwic3ViIjoidGVzdHVzZXIifQ.X8fUYbhnZpgvP5CnGdms9eGo8yRLyNhRbj7xUSxEgl8";
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion $regular_token;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
|
||||
# Valid token but signed with different secret (port 8083)
|
||||
server {
|
||||
listen 8083;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
# Valid JWT token structure, but signed with wrong secret - should fail signature verification
|
||||
set $wrong_key "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NjAyMywiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNjAyMywibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDYwMjMsInN1YiI6InRlc3RhZG1pbiJ9.afBobbJ3VQ-fKnUebGr-gxFIqegyICi6E3EgkUSeFNw";
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion $wrong_key;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
|
||||
# No authentication (port 8084)
|
||||
server {
|
||||
listen 8084;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Simple JWT generator without external dependencies
|
||||
func main() {
|
||||
// Secret key - must match the one in your config file
|
||||
secret := "wrong-key"
|
||||
|
||||
// Check if custom secret provided via command line
|
||||
if len(os.Args) > 1 {
|
||||
secret = os.Args[1]
|
||||
}
|
||||
|
||||
// Create token claims with 10 year expiration
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(10 * 365 * 24 * time.Hour) // 10 years
|
||||
|
||||
header := map[string]interface{}{
|
||||
"alg": "HS256",
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "testadmin", // username (subject)
|
||||
"email": "testadmin@example.com", // email claim
|
||||
"name": "Test Admin", // full name
|
||||
"groups": []string{"admin"}, // groups - includes admin for testing
|
||||
"iat": now.Unix(), // issued at
|
||||
"exp": expiresAt.Unix(), // expires at
|
||||
"nbf": now.Unix(), // not before
|
||||
}
|
||||
|
||||
// Encode header
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error encoding header: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
|
||||
// Encode claims
|
||||
claimsJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error encoding claims: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
|
||||
// Create signature
|
||||
message := headerB64 + "." + claimsB64
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write([]byte(message))
|
||||
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Combine to form complete JWT
|
||||
tokenString := message + "." + signature
|
||||
|
||||
// Output the token
|
||||
fmt.Println("=== JWT Token Generated ===")
|
||||
fmt.Println()
|
||||
fmt.Println("Token:")
|
||||
fmt.Println(tokenString)
|
||||
fmt.Println()
|
||||
// Verify token can be decoded
|
||||
parts := strings.Split(tokenString, ".")
|
||||
if len(parts) == 3 {
|
||||
fmt.Println("✓ Token structure is valid (3 parts)")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "✗ Invalid token structure\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save to file for easy access
|
||||
filename := "test-jwt-token.txt"
|
||||
if err := os.WriteFile(filename, []byte(tokenString), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Could not write token to file: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✓ Token saved to: %s\n", filename)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Also save to shell script for easy sourcing
|
||||
scriptContent := fmt.Sprintf("#!/bin/bash\n# Source this file to set JWT token as environment variable\n# Usage: source jwt-env.sh\nexport TEST_JWT_TOKEN=\"%s\"\necho \"JWT token loaded into TEST_JWT_TOKEN\"\n", tokenString)
|
||||
if err := os.WriteFile("jwt-env.sh", []byte(scriptContent), 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Could not write shell script: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Shell script saved to: jwt-env.sh")
|
||||
fmt.Println(" Usage: source jwt-env.sh")
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module test
|
||||
|
||||
go 1.25.6
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/jwt-setup.ts",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/jwt",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for different JWT test scenarios */
|
||||
projects: [
|
||||
{
|
||||
name: "admin-user",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8081", // Admin user JWT
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "regular-user",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8082", // Regular user JWT
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong-key",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8083", // Wrong secret key
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no-user",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8084", // No JWT token
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/no-config-setup",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/no-config",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
http:
|
||||
port: 80
|
||||
baseURL: "/files/"
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "exclude"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
defaultPermissions:
|
||||
view: true
|
||||
download: true
|
||||
modify: true
|
||||
create: true
|
||||
rules:
|
||||
- folderPath: "excluded"
|
||||
- folderPath: "excludedButVisible"
|
||||
viewable: true
|
||||
- fileStartsWith: "startsWith"
|
||||
- fileName: "dontshow.txt"
|
||||
- path: "../frontend/tests/playwright-files2"
|
||||
name: "include"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
useLogicalSize: true
|
||||
rules:
|
||||
- includeRootItem: "myfolder"
|
||||
- includeRootItem: "subfolderExclusions"
|
||||
- includeRootItem: "folder#hash"
|
||||
- includeRootItem: "files"
|
||||
- filePath: "/subfolderExclusions/exclusionlist.sh"
|
||||
- folderPath: "/subfolderExclusions/folderItem"
|
||||
viewable: true
|
||||
- fileStartsWith: "startsWith"
|
||||
- folderEndsWith: "Folder"
|
||||
- folderName: "folderName"
|
||||
- fileName: "fileName"
|
||||
- fileName: "fileNames"
|
||||
- folderName: ".hiddenDir"
|
||||
- fileName: ".hidden"
|
||||
|
||||
auth:
|
||||
methods:
|
||||
noauth: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
|
||||
userDefaults:
|
||||
listing:
|
||||
showHidden: true
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/noauth-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/noauth",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
storageState: "noauth.json",
|
||||
actionTimeout: 5000,
|
||||
//storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: true
|
||||
defaultPermissions:
|
||||
view: true
|
||||
download: true
|
||||
modify: true
|
||||
create: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: false
|
||||
oidc:
|
||||
enabled: true
|
||||
createUser: true
|
||||
clientId: "vyBrzOmcrU3OokiWEZoObdGBscw0eYPNJauBtOJr"
|
||||
clientSecret: "gIMKbkEcckUhlGrnE2Vxi8eZhEcfxQqfwFgM0ATzk4DciHGrW2YRtQfqcpRTsmzrxEETkKvZ8yeK3sfoXnUV1IQTzqK7wxw3lEMtmeXi5clj0h7nH1rVGFq2Q3HL8zUe"
|
||||
issuerUrl: "http://localhost:8080"
|
||||
adminGroup: "Admins"
|
||||
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
ui:
|
||||
darkMode: true
|
||||
listing:
|
||||
singleClick: false
|
||||
account:
|
||||
disableSettings: false
|
||||
permissions:
|
||||
admin: false
|
||||
share: false
|
||||
api: false
|
||||
@@ -0,0 +1,60 @@
|
||||
|
||||
import { OAuth2Server } from 'oauth2-mock-server';
|
||||
|
||||
// This is a list of pre-defined users. You can add more users here.
|
||||
// The filebrowser application will use this data when a user logs in.
|
||||
const users = {
|
||||
// Default user
|
||||
johndoe: {
|
||||
sub: 'johndoe',
|
||||
preferred_username: 'johndoe',
|
||||
email: 'johndoe@example.com',
|
||||
groups: ['Admins', 'Users'],
|
||||
},
|
||||
// Another example user
|
||||
testuser: {
|
||||
sub: 'testuser',
|
||||
preferred_username: 'testuser',
|
||||
email: 'testuser@example.com',
|
||||
groups: ['Users'],
|
||||
},
|
||||
};
|
||||
|
||||
const server = new OAuth2Server();
|
||||
|
||||
// Generate a new RSA key for the server.
|
||||
await server.issuer.keys.generate('RS256');
|
||||
|
||||
/**
|
||||
* This hook is called before the ID token is signed.
|
||||
* We're using it to add claims to the ID token.
|
||||
* This is the primary way the application will get user information.
|
||||
*/
|
||||
server.service.on('beforeIdTokenSigning', (token, req) => {
|
||||
// By default, the mock server uses 'johndoe' as the subject.
|
||||
const user = users[token.payload.sub] || users.johndoe;
|
||||
console.log(`[ID Token] Customizing token for user: ${user.sub}`);
|
||||
Object.assign(token.payload, user);
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* This hook is called before the userinfo response is sent.
|
||||
* We're using it to provide user claims when the application calls the /userinfo endpoint.
|
||||
*/
|
||||
server.service.on('beforeUserinfo', (userInfoResponse, req) => {
|
||||
const user = users.johndoe;
|
||||
console.log(`[UserInfo] Customizing userinfo for user: ${user.sub}`);
|
||||
userInfoResponse.body = user;
|
||||
});
|
||||
|
||||
|
||||
const port = 8080;
|
||||
const host = '0.0.0.0'; // Bind to 0.0.0.0 to be accessible within Docker
|
||||
await server.start(port, host);
|
||||
console.log(`OAuth 2 Mock Server listening on http://${host}:${port}`);
|
||||
console.log(`Issuer URL: ${server.issuer.url}`);
|
||||
console.log('---');
|
||||
console.log('Pre-defined users:');
|
||||
console.log(JSON.stringify(users, null, 2));
|
||||
console.log('---');
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/oidc-setup",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/oidc",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
storageState: "loginAuth.json",
|
||||
actionTimeout: 5000,
|
||||
//storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
http:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultPermissions:
|
||||
view: true
|
||||
download: true
|
||||
modify: true
|
||||
create: true
|
||||
|
||||
auth:
|
||||
methods:
|
||||
noauth: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
|
||||
userDefaults:
|
||||
listing:
|
||||
showHidden: true
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
office: true
|
||||
models: true
|
||||
highQuality: true
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/previews-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/previews",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
workers: 10,
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
|
||||
'--enable-webgl',
|
||||
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
|
||||
'--no-sandbox', // Required for Docker
|
||||
],
|
||||
},
|
||||
storageState: "previews.json",
|
||||
actionTimeout: 5000,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
demo-127.0.0.1:$apr1$j5FRA4Bc$RYdNFuM7YAAo9Qtie8w4b/
|
||||
@@ -0,0 +1,42 @@
|
||||
http:
|
||||
port: 8080
|
||||
baseURL: "/subpath"
|
||||
trustProxyHeaders: true
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: true
|
||||
defaultPermissions:
|
||||
view: true
|
||||
download: true
|
||||
modify: true
|
||||
create: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: false
|
||||
proxy:
|
||||
enabled: true
|
||||
header: "X-Username"
|
||||
createUser: true
|
||||
logoutRedirectUrl: "https://localhost/api/auth/logout2"
|
||||
|
||||
userDefaults:
|
||||
account:
|
||||
permissions:
|
||||
share: true
|
||||
@@ -0,0 +1,32 @@
|
||||
|
||||
map $remote_addr $uuid {
|
||||
default "demo-${remote_addr}";
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
location /subpath/public/ {
|
||||
proxy_pass http://filebrowser:8080/subpath/public/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Username $uuid;
|
||||
}
|
||||
|
||||
# requires basic auth demo-127.0.0.1:SecurePass123!
|
||||
location /subpath {
|
||||
auth_basic "Restricted Access";
|
||||
auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
|
||||
proxy_set_header X-Username $uuid;
|
||||
add_header X-Username $uuid;
|
||||
proxy_pass http://filebrowser:8080/subpath;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/proxy-setup.ts",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/proxy",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 3,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
http:
|
||||
port: 8080
|
||||
baseURL: "/files/"
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig<PlaywrightFixtureOptions>({
|
||||
globalSetup: "./tests/playwright/screenshots-setup.ts",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/screenshots",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 1,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
|
||||
'--enable-webgl',
|
||||
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
|
||||
'--no-sandbox', // Required for Docker
|
||||
],
|
||||
},
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:8080",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "dark-screenshots",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "light-screenshots",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
http:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
server:
|
||||
database:
|
||||
migrateFrom: "database.db.old"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
defaultPermissions:
|
||||
modify: false
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
rules:
|
||||
- folderPath: "/"
|
||||
viewable: true
|
||||
- path: "/tests/playwright-files"
|
||||
name: "access"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
denyByDefault: true
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
customThemes:
|
||||
no-rounded:
|
||||
description: "No rounded corners" # The description of the theme to display in the UI.
|
||||
css: "no-rounded.css" # The css file path and filename to use for the theme.
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
signup: true
|
||||
|
||||
userDefaults:
|
||||
listing:
|
||||
showHidden: true
|
||||
Binary file not shown.
@@ -0,0 +1,47 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/settings",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 1,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
http:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
private: true
|
||||
defaultEnabled: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/sharing",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
|
||||
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
|
||||
entrypoint = "./tmp/filebrowser"
|
||||
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs"]
|
||||
log = "air.log"
|
||||
delay = 1000 # ms
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
@@ -0,0 +1,22 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
|
||||
cmd = "go build -o ./tmp/filebrowser.exe --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
|
||||
entrypoint = "./tmp/filebrowser.exe"
|
||||
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser.exe -c test_config.yaml"
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", "internal\\web\\dist", "internal\\web\\embed", "internal\\preview\\testdata", "internal\\preview\\thumbnails", "internal\\preview\\heic", "swagger\\docs"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = false
|
||||
poll = true
|
||||
poll_interval = 1000
|
||||
log = "air.log"
|
||||
delay = 1000 # ms
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
@@ -0,0 +1,6 @@
|
||||
tmp/
|
||||
air.log
|
||||
filebrowser
|
||||
generated.yaml
|
||||
test_config.yaml
|
||||
/data
|
||||
+145
-231
@@ -1,232 +1,146 @@
|
||||
# This code is licensed under the terms of the MIT license https://opensource.org/license/mit
|
||||
# Copyright (c) 2021 Marat Reymers
|
||||
|
||||
## Golden config for golangci-lint v1.59.1
|
||||
#
|
||||
# This is the best config for golangci-lint based on my experience and opinion.
|
||||
# It is very strict, but not extremely strict.
|
||||
# Feel free to adapt and change it for your needs.
|
||||
|
||||
run:
|
||||
# Timeout for analysis, e.g. 30s, 5m.
|
||||
# Default: 1m
|
||||
timeout: 3m
|
||||
|
||||
|
||||
# This file contains only configs which differ from defaults.
|
||||
# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml
|
||||
linters-settings:
|
||||
cyclop:
|
||||
# The maximal code complexity to report.
|
||||
# Default: 10
|
||||
max-complexity: 30
|
||||
# The maximal average package complexity.
|
||||
# If it's higher than 0.0 (float) the check is enabled
|
||||
# Default: 0.0
|
||||
package-average: 10.0
|
||||
|
||||
errcheck:
|
||||
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
|
||||
# Such cases aren't reported by default.
|
||||
# Default: false
|
||||
check-type-assertions: true
|
||||
|
||||
exhaustive:
|
||||
# Program elements to check for exhaustiveness.
|
||||
# Default: [ switch ]
|
||||
check:
|
||||
- switch
|
||||
- map
|
||||
|
||||
exhaustruct:
|
||||
# List of regular expressions to exclude struct packages and their names from checks.
|
||||
# Regular expressions must match complete canonical struct package/name/structname.
|
||||
# Default: []
|
||||
exclude:
|
||||
# std libs
|
||||
- "^net/http.Client$"
|
||||
- "^net/http.Cookie$"
|
||||
- "^net/http.Request$"
|
||||
- "^net/http.Response$"
|
||||
- "^net/http.Server$"
|
||||
- "^net/http.Transport$"
|
||||
- "^net/url.URL$"
|
||||
- "^os/exec.Cmd$"
|
||||
- "^reflect.StructField$"
|
||||
# public libs
|
||||
- "^github.com/Shopify/sarama.Config$"
|
||||
- "^github.com/Shopify/sarama.ProducerMessage$"
|
||||
- "^github.com/mitchellh/mapstructure.DecoderConfig$"
|
||||
- "^github.com/prometheus/client_golang/.+Opts$"
|
||||
- "^github.com/spf13/cobra.Command$"
|
||||
- "^github.com/spf13/cobra.CompletionOptions$"
|
||||
- "^github.com/stretchr/testify/mock.Mock$"
|
||||
- "^github.com/testcontainers/testcontainers-go.+Request$"
|
||||
- "^github.com/testcontainers/testcontainers-go.FromDockerfile$"
|
||||
- "^golang.org/x/tools/go/analysis.Analyzer$"
|
||||
- "^google.golang.org/protobuf/.+Options$"
|
||||
- "^gopkg.in/yaml.v3.Node$"
|
||||
|
||||
funlen:
|
||||
# Checks the number of lines in a function.
|
||||
# If lower than 0, disable the check.
|
||||
# Default: 60
|
||||
lines: 100
|
||||
# Checks the number of statements in a function.
|
||||
# If lower than 0, disable the check.
|
||||
# Default: 40
|
||||
statements: 50
|
||||
# Ignore comments when counting lines.
|
||||
# Default false
|
||||
ignore-comments: true
|
||||
|
||||
gocognit:
|
||||
# Minimal code complexity to report.
|
||||
# Default: 30 (but we recommend 10-20)
|
||||
min-complexity: 20
|
||||
|
||||
gocritic:
|
||||
# Settings passed to gocritic.
|
||||
# The settings key is the name of a supported gocritic checker.
|
||||
# The list of supported checkers can be find in https://go-critic.github.io/overview.
|
||||
settings:
|
||||
captLocal:
|
||||
# Whether to restrict checker to params only.
|
||||
# Default: true
|
||||
paramsOnly: false
|
||||
underef:
|
||||
# Whether to skip (*x).method() calls where x is a pointer receiver.
|
||||
# Default: true
|
||||
skipRecvDeref: false
|
||||
|
||||
gomodguard:
|
||||
blocked:
|
||||
# List of blocked modules.
|
||||
# Default: []
|
||||
modules:
|
||||
- github.com/golang/protobuf:
|
||||
recommendations:
|
||||
- google.golang.org/protobuf
|
||||
reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules"
|
||||
- github.com/satori/go.uuid:
|
||||
recommendations:
|
||||
- github.com/google/uuid
|
||||
reason: "satori's package is not maintained"
|
||||
- github.com/gofrs/uuid:
|
||||
recommendations:
|
||||
- github.com/gofrs/uuid/v5
|
||||
reason: "gofrs' package was not go module before v5"
|
||||
|
||||
govet:
|
||||
# Enable all analyzers.
|
||||
# Default: false
|
||||
enable-all: true
|
||||
# Disable analyzers by name.
|
||||
# Run `go tool vet help` to see all analyzers.
|
||||
# Default: []
|
||||
disable:
|
||||
- fieldalignment # too strict
|
||||
# Settings per analyzer.
|
||||
settings:
|
||||
shadow:
|
||||
# Whether to be strict about shadowing; can be noisy.
|
||||
# Default: false
|
||||
strict: false
|
||||
|
||||
inamedparam:
|
||||
# Skips check for interface methods with only a single parameter.
|
||||
# Default: false
|
||||
skip-single-param: true
|
||||
|
||||
mnd:
|
||||
# List of function patterns to exclude from analysis.
|
||||
# Values always ignored: `time.Date`,
|
||||
# `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`,
|
||||
# `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`.
|
||||
# Default: []
|
||||
ignored-functions:
|
||||
- args.Error
|
||||
- flag.Arg
|
||||
- flag.Duration.*
|
||||
- flag.Float.*
|
||||
- flag.Int.*
|
||||
- flag.Uint.*
|
||||
- os.Chmod
|
||||
- os.Mkdir.*
|
||||
- os.OpenFile
|
||||
- os.WriteFile
|
||||
- prometheus.ExponentialBuckets.*
|
||||
- prometheus.LinearBuckets
|
||||
|
||||
nakedret:
|
||||
# Make an issue if func has more lines of code than this setting, and it has naked returns.
|
||||
# Default: 30
|
||||
max-func-lines: 0
|
||||
|
||||
nolintlint:
|
||||
# Exclude following linters from requiring an explanation.
|
||||
# Default: []
|
||||
allow-no-explanation: [ funlen, gocognit, lll ]
|
||||
# Enable to require an explanation of nonzero length after each nolint directive.
|
||||
# Default: false
|
||||
require-explanation: true
|
||||
# Enable to require nolint directives to mention the specific linter being suppressed.
|
||||
# Default: false
|
||||
require-specific: true
|
||||
|
||||
perfsprint:
|
||||
# Optimizes into strings concatenation.
|
||||
# Default: true
|
||||
strconcat: false
|
||||
|
||||
rowserrcheck:
|
||||
# database/sql is always checked
|
||||
# Default: []
|
||||
packages:
|
||||
- github.com/jmoiron/sqlx
|
||||
|
||||
sloglint:
|
||||
# Enforce not using global loggers.
|
||||
# Values:
|
||||
# - "": disabled
|
||||
# - "all": report all global loggers
|
||||
# - "default": report only the default slog logger
|
||||
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global
|
||||
# Default: ""
|
||||
no-global: "all"
|
||||
# Enforce using methods that accept a context.
|
||||
# Values:
|
||||
# - "": disabled
|
||||
# - "all": report all contextless calls
|
||||
# - "scope": report only if a context exists in the scope of the outermost function
|
||||
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only
|
||||
# Default: ""
|
||||
context: "scope"
|
||||
|
||||
tenv:
|
||||
# The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures.
|
||||
# Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked.
|
||||
# Default: false
|
||||
all: true
|
||||
|
||||
|
||||
issues:
|
||||
# Maximum count of issues with the same text.
|
||||
# Set to 0 to disable.
|
||||
# Default: 3
|
||||
max-same-issues: 50
|
||||
|
||||
exclude-rules:
|
||||
- source: "(noinspection|TODO)"
|
||||
linters: [ godot ]
|
||||
- source: "//noinspection"
|
||||
linters: [ gocritic ]
|
||||
- path: "_test\\.go"
|
||||
linters:
|
||||
- bodyclose
|
||||
- dupl
|
||||
version: "2"
|
||||
linters:
|
||||
settings:
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- '-QF1001'
|
||||
- '-ST1003'
|
||||
- '-ST1016'
|
||||
- '-QF1004'
|
||||
- '-QF1003'
|
||||
cyclop:
|
||||
max-complexity: 30
|
||||
package-average: 10
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
exhaustive:
|
||||
check:
|
||||
- switch
|
||||
- map
|
||||
exhaustruct:
|
||||
exclude:
|
||||
- ^net/http.Client$
|
||||
- ^net/http.Cookie$
|
||||
- ^net/http.Request$
|
||||
- ^net/http.Response$
|
||||
- ^net/http.Server$
|
||||
- ^net/http.Transport$
|
||||
- ^net/url.URL$
|
||||
- ^os/exec.Cmd$
|
||||
- ^reflect.StructField$
|
||||
- ^github.com/Shopify/sarama.Config$
|
||||
- ^github.com/Shopify/sarama.ProducerMessage$
|
||||
- ^github.com/mitchellh/mapstructure.DecoderConfig$
|
||||
- ^github.com/prometheus/client_golang/.+Opts$
|
||||
- ^github.com/spf13/cobra.Command$
|
||||
- ^github.com/spf13/cobra.CompletionOptions$
|
||||
- ^github.com/stretchr/testify/mock.Mock$
|
||||
- ^github.com/testcontainers/testcontainers-go.+Request$
|
||||
- ^github.com/testcontainers/testcontainers-go.FromDockerfile$
|
||||
- ^golang.org/x/tools/go/analysis.Analyzer$
|
||||
- ^google.golang.org/protobuf/.+Options$
|
||||
- ^gopkg.in/yaml.v3.Node$
|
||||
funlen:
|
||||
lines: 100
|
||||
statements: 50
|
||||
ignore-comments: true
|
||||
gocognit:
|
||||
min-complexity: 20
|
||||
gocritic:
|
||||
settings:
|
||||
captLocal:
|
||||
paramsOnly: false
|
||||
underef:
|
||||
skipRecvDeref: false
|
||||
gomodguard:
|
||||
blocked:
|
||||
modules:
|
||||
- github.com/golang/protobuf:
|
||||
recommendations:
|
||||
- google.golang.org/protobuf
|
||||
reason: see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules
|
||||
- github.com/satori/go.uuid:
|
||||
recommendations:
|
||||
- github.com/google/uuid
|
||||
reason: satori's package is not maintained
|
||||
- github.com/gofrs/uuid:
|
||||
recommendations:
|
||||
- github.com/gofrs/uuid/v5
|
||||
reason: gofrs' package was not go module before v5
|
||||
govet:
|
||||
disable:
|
||||
- fieldalignment
|
||||
enable-all: true
|
||||
settings:
|
||||
shadow:
|
||||
strict: false
|
||||
inamedparam:
|
||||
skip-single-param: true
|
||||
mnd:
|
||||
ignored-functions:
|
||||
- args.Error
|
||||
- flag.Arg
|
||||
- flag.Duration.*
|
||||
- flag.Float.*
|
||||
- flag.Int.*
|
||||
- flag.Uint.*
|
||||
- os.Chmod
|
||||
- os.Mkdir.*
|
||||
- os.OpenFile
|
||||
- os.WriteFile
|
||||
- prometheus.ExponentialBuckets.*
|
||||
- prometheus.LinearBuckets
|
||||
nakedret:
|
||||
max-func-lines: 0
|
||||
nolintlint:
|
||||
require-explanation: true
|
||||
require-specific: true
|
||||
allow-no-explanation:
|
||||
- funlen
|
||||
- goconst
|
||||
- gosec
|
||||
- noctx
|
||||
- wrapcheck
|
||||
- gocognit
|
||||
- lll
|
||||
perfsprint:
|
||||
strconcat: false
|
||||
rowserrcheck:
|
||||
packages:
|
||||
- github.com/jmoiron/sqlx
|
||||
sloglint:
|
||||
no-global: all
|
||||
context: scope
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
- linters:
|
||||
- godot
|
||||
source: (noinspection|TODO)
|
||||
- linters:
|
||||
- gocritic
|
||||
source: //noinspection
|
||||
- linters:
|
||||
- bodyclose
|
||||
- dupl
|
||||
- funlen
|
||||
- goconst
|
||||
- gosec
|
||||
- noctx
|
||||
- wrapcheck
|
||||
path: _test\.go
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
issues:
|
||||
max-same-issues: 50
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
+111
-13
@@ -2,29 +2,96 @@
|
||||
project_name: filebrowser
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
- go run ./scripts/setup-gofitz-cgo
|
||||
|
||||
builds:
|
||||
# Build configuration for darwin and linux
|
||||
- id: default
|
||||
# Build configuration for linux amd64
|
||||
- id: linux-amd64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=zig cc -target x86_64-linux-musl
|
||||
tags:
|
||||
- analytics
|
||||
- mupdf
|
||||
- musl
|
||||
ldflags: &ldflags
|
||||
- -s -w -X github.com/gtsteffaniak/filebrowser/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/version.CommitSHA={{ .ShortCommit }}
|
||||
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA={{ .ShortCommit }}
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- 6
|
||||
- 7
|
||||
hooks:
|
||||
post:
|
||||
post:
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux arm64
|
||||
- id: linux-arm64
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=zig cc -target aarch64-linux-musl
|
||||
tags:
|
||||
- analytics
|
||||
- mupdf
|
||||
- musl
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux riscv64
|
||||
- id: linux-riscv64
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
env:
|
||||
- CC=zig cc -target riscv64-linux-musl
|
||||
# go-fitz does not provide riscv64 MuPDF libraries yet: https://github.com/gen2brain/go-fitz/tree/master/libs
|
||||
tags:
|
||||
- analytics
|
||||
- musl
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- riscv64
|
||||
|
||||
# Build configuration for linux
|
||||
- id: linux-arm32
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
|
||||
# Build configuration for windows without arm
|
||||
- id: windows
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
# windows-gnu avoids zig rejecting Go's -tsaware when cross-compiling from macOS
|
||||
- CC=zig cc -target x86_64-windows-gnu -lc
|
||||
tags:
|
||||
- analytics
|
||||
- mupdf
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
@@ -32,12 +99,43 @@ builds:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
|
||||
# Build configuration for freebsd without arm & upx
|
||||
- id: freeBSD and freebsd-arm
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
|
||||
# Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported)
|
||||
- id: macos
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
- amd64
|
||||
|
||||
archives:
|
||||
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
|
||||
format: binary
|
||||
- name_template: >
|
||||
{{- if eq .Os "windows" -}}
|
||||
{{.ProjectName}}
|
||||
{{- else -}}
|
||||
{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}}
|
||||
{{- end -}}
|
||||
formats: [ 'binary' ]
|
||||
|
||||
checksum:
|
||||
disable: true
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
)
|
||||
|
||||
// Auther is the authentication interface.
|
||||
type Auther interface {
|
||||
// Auth is called to authenticate a request.
|
||||
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
|
||||
// LoginPage indicates if this auther needs a login page.
|
||||
LoginPage() bool
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
)
|
||||
|
||||
type hookCred struct {
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// HookAuth is a hook implementation of an Auther.
|
||||
type HookAuth struct {
|
||||
Users users.Store `json:"-"`
|
||||
Settings *settings.Settings `json:"-"`
|
||||
Server *settings.Server `json:"-"`
|
||||
Cred hookCred `json:"-"`
|
||||
Fields hookFields `json:"-"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// Auth authenticates the user via a json in content body.
|
||||
func (a *HookAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||
var cred hookCred
|
||||
|
||||
if r.Body == nil {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&cred)
|
||||
if err != nil {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
a.Users = usr
|
||||
a.Settings = &settings.Config
|
||||
a.Server = &settings.Config.Server
|
||||
a.Cred = cred
|
||||
|
||||
action, err := a.RunCommand()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch action {
|
||||
case "auth":
|
||||
u, err := a.SaveUser()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
case "block":
|
||||
return nil, os.ErrPermission
|
||||
case "pass":
|
||||
u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
|
||||
if err != nil || !users.CheckPwd(a.Cred.Password, u.Password) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
return u, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid hook action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
// LoginPage tells that hook auth requires a login page.
|
||||
func (a *HookAuth) LoginPage() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// RunCommand starts the hook command and returns the action
|
||||
func (a *HookAuth) RunCommand() (string, error) {
|
||||
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.Env = append(os.Environ(), fmt.Sprintf("USERNAME=%s", a.Cred.Username))
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("PASSWORD=%s", a.Cred.Password))
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
a.GetValues(string(out))
|
||||
|
||||
return a.Fields.Values["hook.action"], nil
|
||||
}
|
||||
|
||||
// GetValues creates a map with values from the key-value format string
|
||||
func (a *HookAuth) GetValues(s string) {
|
||||
m := map[string]string{}
|
||||
|
||||
// make line breaks consistent on Windows platform
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
|
||||
// iterate input lines
|
||||
for _, val := range strings.Split(s, "\n") {
|
||||
v := strings.SplitN(val, "=", 2) //nolint: gomnd
|
||||
|
||||
// skips non key and value format
|
||||
if len(v) != 2 { //nolint: gomnd
|
||||
continue
|
||||
}
|
||||
|
||||
fieldKey := strings.TrimSpace(v[0])
|
||||
fieldValue := strings.TrimSpace(v[1])
|
||||
|
||||
if a.Fields.IsValid(fieldKey) {
|
||||
m[fieldKey] = fieldValue
|
||||
}
|
||||
}
|
||||
|
||||
a.Fields.Values = m
|
||||
}
|
||||
|
||||
// SaveUser updates the existing user or creates a new one when not found
|
||||
func (a *HookAuth) SaveUser() (*users.User, error) {
|
||||
u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
|
||||
if err != nil && err != errors.ErrNotExist {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if u == nil {
|
||||
// create user with the provided credentials
|
||||
d := &users.User{
|
||||
Username: a.Cred.Username,
|
||||
Password: a.Cred.Password,
|
||||
Scope: a.Settings.UserDefaults.Scope,
|
||||
Locale: a.Settings.UserDefaults.Locale,
|
||||
ViewMode: a.Settings.UserDefaults.ViewMode,
|
||||
SingleClick: a.Settings.UserDefaults.SingleClick,
|
||||
Sorting: a.Settings.UserDefaults.Sorting,
|
||||
Perm: a.Settings.UserDefaults.Perm,
|
||||
Commands: a.Settings.UserDefaults.Commands,
|
||||
HideDotfiles: a.Settings.UserDefaults.HideDotfiles,
|
||||
}
|
||||
u = a.GetUser(d)
|
||||
|
||||
userHome, err := a.Settings.MakeUserDir(u.Username, u.Scope, a.Server.Root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user: failed to mkdir user home dir: [%s]", userHome)
|
||||
}
|
||||
u.Scope = userHome
|
||||
log.Printf("user: %s, home dir: [%s].", u.Username, userHome)
|
||||
|
||||
err = a.Users.Save(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if p := !users.CheckPwd(a.Cred.Password, u.Password); len(a.Fields.Values) > 1 || p {
|
||||
u = a.GetUser(u)
|
||||
// update user with provided fields
|
||||
err := a.Users.Update(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// GetUser returns a User filled with hook values or provided defaults
|
||||
func (a *HookAuth) GetUser(d *users.User) *users.User {
|
||||
// adds all permissions when user is admin
|
||||
isAdmin := d.Perm.Admin
|
||||
perms := users.Permissions{
|
||||
Admin: isAdmin,
|
||||
Execute: isAdmin || d.Perm.Execute,
|
||||
Create: isAdmin || d.Perm.Create,
|
||||
Rename: isAdmin || d.Perm.Rename,
|
||||
Modify: isAdmin || d.Perm.Modify,
|
||||
Delete: isAdmin || d.Perm.Delete,
|
||||
Share: isAdmin || d.Perm.Share,
|
||||
Download: isAdmin || d.Perm.Download,
|
||||
}
|
||||
user := users.User{
|
||||
ID: d.ID,
|
||||
Username: d.Username,
|
||||
Password: d.Password,
|
||||
Scope: d.Scope,
|
||||
Locale: d.Locale,
|
||||
ViewMode: d.ViewMode,
|
||||
SingleClick: d.SingleClick,
|
||||
Sorting: users.Sorting{
|
||||
Asc: d.Sorting.Asc,
|
||||
By: d.Sorting.By,
|
||||
},
|
||||
Commands: d.Commands,
|
||||
HideDotfiles: d.HideDotfiles,
|
||||
Perm: perms,
|
||||
LockPassword: true,
|
||||
}
|
||||
|
||||
return &user
|
||||
}
|
||||
|
||||
// hookFields is used to access fields from the hook
|
||||
type hookFields struct {
|
||||
Values map[string]string
|
||||
}
|
||||
|
||||
// validHookFields contains names of the fields that can be used
|
||||
var validHookFields = []string{
|
||||
"hook.action",
|
||||
"user.scope",
|
||||
"user.locale",
|
||||
"user.viewMode",
|
||||
"user.singleClick",
|
||||
"user.sorting.by",
|
||||
"user.sorting.asc",
|
||||
"user.commands",
|
||||
"user.hideDotfiles",
|
||||
"user.perm.admin",
|
||||
"user.perm.execute",
|
||||
"user.perm.create",
|
||||
"user.perm.rename",
|
||||
"user.perm.modify",
|
||||
"user.perm.delete",
|
||||
"user.perm.share",
|
||||
"user.perm.download",
|
||||
}
|
||||
|
||||
// IsValid checks if the provided field is on the valid fields list
|
||||
func (hf *hookFields) IsValid(field string) bool {
|
||||
for _, val := range validHookFields {
|
||||
if field == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetString returns the string value or provided default
|
||||
func (hf *hookFields) GetString(k, dv string) string {
|
||||
val, ok := hf.Values[k]
|
||||
if ok {
|
||||
return val
|
||||
}
|
||||
return dv
|
||||
}
|
||||
|
||||
// GetBoolean returns the bool value or provided default
|
||||
func (hf *hookFields) GetBoolean(k string, dv bool) bool {
|
||||
val, ok := hf.Values[k]
|
||||
if ok {
|
||||
return val == "true"
|
||||
}
|
||||
return dv
|
||||
}
|
||||
|
||||
// GetArray returns the array value or provided default
|
||||
func (hf *hookFields) GetArray(k string, dv []string) []string {
|
||||
val, ok := hf.Values[k]
|
||||
if ok && strings.TrimSpace(val) != "" {
|
||||
return strings.Split(val, " ")
|
||||
}
|
||||
return dv
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
)
|
||||
|
||||
type jsonCred struct {
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
ReCaptcha string `json:"recaptcha"`
|
||||
}
|
||||
|
||||
// JSONAuth is a json implementation of an Auther.
|
||||
type JSONAuth struct {
|
||||
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
|
||||
}
|
||||
|
||||
// Auth authenticates the user via a json in content body.
|
||||
func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
|
||||
config := &settings.Config
|
||||
var cred jsonCred
|
||||
|
||||
if r.Body == nil {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(&cred)
|
||||
if err != nil {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
// If ReCaptcha is enabled, check the code.
|
||||
if a.ReCaptcha != nil && len(a.ReCaptcha.Secret) > 0 {
|
||||
ok, err := a.ReCaptcha.Ok(cred.ReCaptcha) //nolint:govet
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
}
|
||||
u, err := userStore.Get(config.Server.Root, cred.Username)
|
||||
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// LoginPage tells that json auth doesn't require a login page.
|
||||
func (a JSONAuth) LoginPage() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
const reCaptchaAPI = "/recaptcha/api/siteverify"
|
||||
|
||||
// ReCaptcha identifies a recaptcha connection.
|
||||
type ReCaptcha struct {
|
||||
Host string `json:"host"`
|
||||
Key string `json:"key"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// Ok checks if a reCaptcha responde is correct.
|
||||
func (r *ReCaptcha) Ok(response string) (bool, error) {
|
||||
body := url.Values{}
|
||||
body.Set("secret", r.Secret)
|
||||
body.Add("response", response)
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
resp, err := client.Post(
|
||||
r.Host+reCaptchaAPI,
|
||||
"application/x-www-form-urlencoded",
|
||||
strings.NewReader(body.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&data)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return data.Success, nil
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
)
|
||||
|
||||
// MethodNoAuth is used to identify no auth.
|
||||
const MethodNoAuth = "noauth"
|
||||
|
||||
// NoAuth is no auth implementation of auther.
|
||||
type NoAuth struct{}
|
||||
|
||||
// Auth uses authenticates user 1.
|
||||
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||
return usr.Get(settings.Config.Server.Root, uint(1))
|
||||
}
|
||||
|
||||
// LoginPage tells that no auth doesn't require a login page.
|
||||
func (a NoAuth) LoginPage() bool {
|
||||
return false
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
)
|
||||
|
||||
// MethodProxyAuth is used to identify no auth.
|
||||
const MethodProxyAuth = "proxy"
|
||||
|
||||
// ProxyAuth is a proxy implementation of an auther.
|
||||
type ProxyAuth struct {
|
||||
Header string `json:"header"`
|
||||
}
|
||||
|
||||
// Auth authenticates the user via an HTTP header.
|
||||
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||
username := r.Header.Get(a.Header)
|
||||
user, err := usr.Get(settings.Config.Server.Root, username)
|
||||
if err == errors.ErrNotExist {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
// LoginPage tells that proxy auth doesn't require a login page.
|
||||
func (a ProxyAuth) LoginPage() bool {
|
||||
return false
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
)
|
||||
|
||||
// StorageBackend is a storage backend for auth storage.
|
||||
type StorageBackend interface {
|
||||
Get(string) (Auther, error)
|
||||
Save(Auther) error
|
||||
}
|
||||
|
||||
// Storage is a auth storage.
|
||||
type Storage struct {
|
||||
back StorageBackend
|
||||
users *users.Storage
|
||||
}
|
||||
|
||||
// NewStorage creates a auth storage from a backend.
|
||||
func NewStorage(back StorageBackend, userStore *users.Storage) *Storage {
|
||||
return &Storage{back: back, users: userStore}
|
||||
}
|
||||
|
||||
// Get wraps a StorageBackend.Get.
|
||||
func (s *Storage) Get(t string) (Auther, error) {
|
||||
return s.back.Get(t)
|
||||
}
|
||||
|
||||
// Save wraps a StorageBackend.Save.
|
||||
func (s *Storage) Save(a Auther) error {
|
||||
return s.back.Save(a)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
|
||||
== Running benchmark ==
|
||||
/usr/local/go/bin/go
|
||||
? github.com/gtsteffaniak/filebrowser [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/auth [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/cmd [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/diskcache 0.004s
|
||||
? github.com/gtsteffaniak/filebrowser/errors [no test files]
|
||||
2024/10/07 12:46:34 could not update unknown type: unknown
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/files
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
BenchmarkFillIndex-8 10 3847878 ns/op 758424 B/op 5567 allocs/op
|
||||
BenchmarkSearchAllIndexes-8 10 780431 ns/op 173444 B/op 2014 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/files 0.073s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/fileutils 0.003s
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/http 0.080s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/img 0.137s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/rules 0.002s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/runner 0.003s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/settings 0.004s
|
||||
? github.com/gtsteffaniak/filebrowser/share [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/storage [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/storage/bolt [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/users 0.002s
|
||||
? github.com/gtsteffaniak/filebrowser/utils [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/version [no test files]
|
||||
@@ -0,0 +1,89 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// validateAccessRules migrates old-style access rules (without trailing slashes) to new format
|
||||
func validateAccessRules() {
|
||||
for sourcePath := range settings.Config.Server.SourceMap {
|
||||
rules, err := state.GetAllRules(sourcePath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get access rules for source %s: %v", sourcePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
needsMigration := false
|
||||
for oldPath := range rules {
|
||||
if oldPath != "/" && !strings.HasSuffix(oldPath, "/") {
|
||||
needsMigration = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !needsMigration {
|
||||
continue
|
||||
}
|
||||
|
||||
migratedCount := 0
|
||||
for oldPath, rule := range rules {
|
||||
if oldPath != "/" && !strings.HasSuffix(oldPath, "/") {
|
||||
newPath := oldPath + "/"
|
||||
|
||||
if err := migrateAccessRule(sourcePath, newPath, rule); err != nil {
|
||||
logger.Errorf("Failed to migrate rule from %s to %s: %v", oldPath, newPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
state.RemoveRuleByPathKey(sourcePath, oldPath)
|
||||
migratedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if migratedCount > 0 {
|
||||
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func migrateAccessRule(sourcePath, newPath string, rule access.FrontendAccessRule) error {
|
||||
for _, user := range rule.Deny.Users {
|
||||
if err := state.DenyUser(sourcePath, utils.IndexPathFromNormalized(newPath, true), user); err != nil && err != errors.ErrExist {
|
||||
return fmt.Errorf("failed to add deny user %s: %w", user, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range rule.Deny.Groups {
|
||||
if err := state.DenyGroup(sourcePath, utils.IndexPathFromNormalized(newPath, true), group); err != nil && err != errors.ErrExist {
|
||||
return fmt.Errorf("failed to add deny group %s: %w", group, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, user := range rule.Allow.Users {
|
||||
if err := state.AllowUser(sourcePath, utils.IndexPathFromNormalized(newPath, true), user); err != nil && err != errors.ErrExist {
|
||||
return fmt.Errorf("failed to add allow user %s: %w", user, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range rule.Allow.Groups {
|
||||
if err := state.AllowGroup(sourcePath, utils.IndexPathFromNormalized(newPath, true), group); err != nil && err != errors.ErrExist {
|
||||
return fmt.Errorf("failed to add allow group %s: %w", group, err)
|
||||
}
|
||||
}
|
||||
|
||||
if rule.DenyAll {
|
||||
if err := state.DenyAll(sourcePath, utils.IndexPathFromNormalized(newPath, true)); err != nil && err != errors.ErrExist {
|
||||
return fmt.Errorf("failed to add deny all rule: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
)
|
||||
|
||||
func TestAdminHasSharePermissionAfterPlaywrightStartup(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
sharingConfig := "../../_docker/src/sharing/backend/config.yaml"
|
||||
settings.Initialize(sharingConfig)
|
||||
settings.Env.IsPlaywright = true
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
_, err := state.Initialize(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(state.Default())
|
||||
t.Cleanup(func() { _ = state.Close() })
|
||||
|
||||
validateUserInfo(true)
|
||||
|
||||
admin, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !admin.Permissions.Share {
|
||||
t.Fatalf("admin Share=false after validateUserInfo; perms=%+v", admin.Permissions)
|
||||
}
|
||||
if !admin.Permissions.Admin {
|
||||
t.Fatalf("admin Admin=false; perms=%+v", admin.Permissions)
|
||||
}
|
||||
|
||||
_ = state.Close()
|
||||
_, err = state.Initialize(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(state.Default())
|
||||
admin, err = state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !admin.Permissions.Share {
|
||||
t.Fatalf("admin Share=false after DB reload; perms=%+v", admin.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminHasSharePermissionAfterSettingsMigration(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
settings.Env.IsPlaywright = true
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
settings.Config.Server.DatabaseV2.Path = dbPath
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
|
||||
|
||||
if err := migrateFromBoltToSQLite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := state.Initialize(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(state.Default())
|
||||
t.Cleanup(func() { _ = state.Close() })
|
||||
|
||||
validateUserInfo(false)
|
||||
|
||||
admin, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !admin.Permissions.Share {
|
||||
t.Fatalf("admin Share=false after settings migration; perms=%+v", admin.Permissions)
|
||||
}
|
||||
assertSourceSidebarLinkCount(t, &admin, 3)
|
||||
}
|
||||
|
||||
func TestGrahamGainsAccessAfterSettingsMigrationStartup_dockerSourcePaths(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
configYAML := `server:
|
||||
sources:
|
||||
- path: "/app/frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
- path: "/app/backend"
|
||||
name: "docker"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
- path: "/tests/playwright-files"
|
||||
name: "access"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
denyByDefault: true
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Initialize(configPath)
|
||||
settings.Env.IsPlaywright = true
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
settings.Config.Server.DatabaseV2.Path = dbPath
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
|
||||
if err := migrateFromBoltToSQLite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := state.Initialize(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(state.Default())
|
||||
t.Cleanup(func() { _ = state.Close() })
|
||||
|
||||
validateUserInfo(false)
|
||||
|
||||
graham, err := state.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foundScope := false
|
||||
for _, scope := range graham.BackendScopes {
|
||||
if scope.Path == fixtureAccessSource {
|
||||
foundScope = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundScope {
|
||||
t.Fatalf("graham should gain access scope after validateUserInfo: %#v", graham.BackendScopes)
|
||||
}
|
||||
foundLink := false
|
||||
for _, link := range graham.SidebarLinks {
|
||||
if link.Name == "access" || link.SourceName == fixtureAccessSource {
|
||||
foundLink = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundLink {
|
||||
t.Fatalf("graham should have access sidebar link after validateUserInfo: %#v", graham.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrahamGainsAccessAfterSettingsMigrationStartup(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
settings.Env.IsPlaywright = true
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
settings.Config.Server.DatabaseV2.Path = dbPath
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
|
||||
if err := migrateFromBoltToSQLite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := state.Initialize(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(state.Default())
|
||||
t.Cleanup(func() { _ = state.Close() })
|
||||
|
||||
validateUserInfo(false)
|
||||
|
||||
graham, err := state.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foundScope := false
|
||||
for _, scope := range graham.BackendScopes {
|
||||
if scope.Path == fixtureAccessSource {
|
||||
foundScope = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundScope {
|
||||
t.Fatalf("graham should gain access scope after validateUserInfo: %#v", graham.BackendScopes)
|
||||
}
|
||||
foundLink := false
|
||||
for _, link := range graham.SidebarLinks {
|
||||
if link.Name == "access" || link.SourceName == fixtureAccessSource {
|
||||
foundLink = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundLink {
|
||||
t.Fatalf("graham should have access sidebar link after validateUserInfo: %#v", graham.SidebarLinks)
|
||||
}
|
||||
if len(graham.SidebarLinks) == 0 {
|
||||
t.Fatal("graham should have sidebar links after migration so frontend does not fall back to all sources")
|
||||
}
|
||||
}
|
||||
|
||||
func assertSourceSidebarLinkCount(t *testing.T, user *users.User, want int) {
|
||||
t.Helper()
|
||||
count := 0
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("user %q has %d source sidebar links, want %d: %v",
|
||||
user.Username, count, want, user.SidebarLinks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
configPath string
|
||||
)
|
||||
|
||||
func setRule(backendSourcePath, indexPath, ruleCategory, value string, allow bool) error {
|
||||
if backendSourcePath == "" {
|
||||
return fmt.Errorf("backend source path is missing; check --source / -s")
|
||||
}
|
||||
if indexPath == "" {
|
||||
return fmt.Errorf("--path / -p is required (index path within the source, e.g. /)")
|
||||
}
|
||||
if ruleCategory == "" {
|
||||
return fmt.Errorf("role is required: use --role / -r <user|group|all>")
|
||||
}
|
||||
if ruleCategory != "all" && value == "" {
|
||||
return fmt.Errorf("value is required when role is 'user' or 'group': use --value / -v <username|groupname>")
|
||||
}
|
||||
|
||||
parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if allow {
|
||||
switch ruleCategory {
|
||||
case "user":
|
||||
err = state.AllowUser(backendSourcePath, parsedPath, value)
|
||||
case "group":
|
||||
err = state.AllowGroup(backendSourcePath, parsedPath, value)
|
||||
default:
|
||||
return fmt.Errorf("invalid role for allow: must be 'user' or 'group'")
|
||||
}
|
||||
} else {
|
||||
switch ruleCategory {
|
||||
case "user":
|
||||
err = state.DenyUser(backendSourcePath, parsedPath, value)
|
||||
case "group":
|
||||
err = state.DenyGroup(backendSourcePath, parsedPath, value)
|
||||
case "all":
|
||||
err = state.DenyAll(backendSourcePath, parsedPath)
|
||||
default:
|
||||
return fmt.Errorf("invalid role: must be 'user', 'group', or 'all'")
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add or update rule: %w", err)
|
||||
}
|
||||
|
||||
action := "deny"
|
||||
if allow {
|
||||
action = "allow"
|
||||
}
|
||||
if ruleCategory == "all" {
|
||||
fmt.Printf("successfully added %s rule for all users on index path '%s' in filesystem path '%s'\n", action, indexPath, backendSourcePath)
|
||||
} else {
|
||||
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, backendSourcePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setUser(username, password string, asAdmin bool) error {
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
newUser := users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: username,
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
},
|
||||
}
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
if source.Config.DefaultEnabled {
|
||||
newUser.BackendScopes = append(newUser.BackendScopes, users.BackendScope{
|
||||
Path: source.Path,
|
||||
Scope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if asAdmin {
|
||||
logger.Infof("Creating user as admin: %s\n", username)
|
||||
} else {
|
||||
logger.Infof("Creating non-admin user: %s\n", username)
|
||||
}
|
||||
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Account.Permissions)
|
||||
newUser.Permissions.Admin = asAdmin
|
||||
err = state.CreateUser(&newUser, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create user: %v", err)
|
||||
}
|
||||
if dirErr := files.MakeUserDirs(&newUser, true); dirErr != nil {
|
||||
logger.Error(dirErr.Error())
|
||||
}
|
||||
fmt.Printf("successfully created user")
|
||||
return nil
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
logger.Infof("Converting user %s from %s to password login method", username, user.LoginMethod)
|
||||
}
|
||||
user.TOTPSecret = ""
|
||||
user.TOTPNonce = ""
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
if asAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
if user.Version == 0 {
|
||||
user.Version = users.CurrentUserMigrationVersion
|
||||
}
|
||||
err = state.UpdateUser(&user, password, "password", "totpSecret", "totpNonce", "loginMethod", "permissions", "version")
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not update user: %v", err)
|
||||
}
|
||||
fmt.Printf("successfully updated user: %s\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func promoteUser(username string) error {
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user %s not found", username)
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
return fmt.Errorf("user %s cannot be promoted: only password-login users can be granted admin via CLI", username)
|
||||
}
|
||||
if user.Permissions.Admin {
|
||||
fmt.Printf("user %s is already an admin\n", username)
|
||||
return nil
|
||||
}
|
||||
user.Permissions.Admin = true
|
||||
if user.Version == 0 {
|
||||
user.Version = users.CurrentUserMigrationVersion
|
||||
}
|
||||
err = state.UpdateUser(&user, "", "permissions")
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not promote user: %v", err)
|
||||
}
|
||||
fmt.Printf("successfully promoted user to admin: %s\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func askQuestion(reader *bufio.Reader, prompt string, defaultValue string) string {
|
||||
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
|
||||
input, _ := reader.ReadString('\n')
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func askYesNoQuestion(reader *bufio.Reader, prompt string, defaultValue string) bool {
|
||||
for {
|
||||
answer := askQuestion(reader, prompt, defaultValue)
|
||||
answer = strings.ToLower(answer)
|
||||
if answer == "yes" || answer == "y" {
|
||||
return true
|
||||
}
|
||||
if answer == "no" || answer == "n" {
|
||||
return false
|
||||
}
|
||||
fmt.Println("Error: Invalid input. Please enter 'yes' or 'no'.")
|
||||
}
|
||||
}
|
||||
|
||||
func createConfig(configpath string, noInput bool) error {
|
||||
if noInput {
|
||||
return fmt.Errorf("'setup' requires interactive input; rerun without --no-input")
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
config := settings.SetDefaults(false)
|
||||
config.Server.Sources = []*settings.Source{{Path: "./"}}
|
||||
|
||||
if configpath == "" {
|
||||
configpath = "config.yaml"
|
||||
}
|
||||
|
||||
fmt.Println("--- Starting Configuration Setup ---")
|
||||
realPath := ""
|
||||
for {
|
||||
config.Server.Sources[0].Path = askQuestion(reader, "What is the source filesystem path?", "./")
|
||||
absolutePath, err := filepath.Abs(config.Server.Sources[0].Path)
|
||||
if err == nil {
|
||||
var isDir bool
|
||||
realPath, isDir, _ = iteminfo.ResolveSymlinks(absolutePath)
|
||||
if realPath != "" && isDir {
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Printf("Error: The path '%s' does not exist or isn't valid. Please try again.\n", config.Server.Sources[0].Path)
|
||||
}
|
||||
defaultSourceName := filepath.Base(realPath)
|
||||
sourceName := askQuestion(reader, "What should the first source name be?", defaultSourceName)
|
||||
if sourceName != defaultSourceName {
|
||||
config.Server.Sources[0].Name = sourceName
|
||||
}
|
||||
|
||||
for {
|
||||
portStr := askQuestion(reader, "What port should the server listen on?", "80")
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err == nil && (port >= 1 && port <= 65535) {
|
||||
config.Http.Port = port
|
||||
break
|
||||
}
|
||||
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
|
||||
}
|
||||
|
||||
for {
|
||||
levels := askQuestion(reader, "What should the log levels be?", "info|warning|error")
|
||||
checkLevels := SplitByMultiple(levels)
|
||||
invalidOptions := []string{}
|
||||
for _, level := range checkLevels {
|
||||
if !(level == "info" || level == "warning" || level == "error" || level == "debug") {
|
||||
invalidOptions = append(invalidOptions, level)
|
||||
}
|
||||
}
|
||||
if len(invalidOptions) == 0 {
|
||||
break
|
||||
}
|
||||
fmt.Printf("Error: invalid options given '%s'. valid options: 'info|warning|error|debug'.\n", invalidOptions)
|
||||
}
|
||||
|
||||
for {
|
||||
// "database.db" is the reserved legacy-bolt filename and is rejected
|
||||
// at startup (see backend/cmd/migrate.go), so the wizard must not
|
||||
// suggest it as the default.
|
||||
config.Server.DatabaseV2.Path = askQuestion(reader, "What should the file name and path be for the database?", "./filebrowser.sqlite")
|
||||
if strings.HasSuffix(config.Server.DatabaseV2.Path, ".sqlite") {
|
||||
break
|
||||
}
|
||||
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
|
||||
}
|
||||
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
|
||||
config.Auth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
|
||||
config.Auth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
|
||||
|
||||
modifyDefault := askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
|
||||
config.Server.Sources[0].Config.DefaultPermissions = settings.NormalizeSourceFilePermissions(users.SourceFilePermissions{
|
||||
View: true,
|
||||
Download: true,
|
||||
Modify: modifyDefault,
|
||||
Create: modifyDefault,
|
||||
Delete: modifyDefault,
|
||||
})
|
||||
config.UserDefaults.Account.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
|
||||
|
||||
fmt.Println("--- Configuration Complete ---")
|
||||
|
||||
yamlData, err := yaml.Marshal(&config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(configpath, yamlData, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing config file: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(config.Server.DatabaseV2.Path); err == nil {
|
||||
response := askYesNoQuestion(reader, "Database specified already exists. Move database file to backup to start fresh?", "no")
|
||||
if !response {
|
||||
return nil
|
||||
}
|
||||
backupPath := config.Server.DatabaseV2.Path + ".bak"
|
||||
err = os.Rename(config.Server.DatabaseV2.Path, backupPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error moving database file to backup: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Database file moved to backup: %s\n", backupPath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateYaml() {
|
||||
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
|
||||
if generateConfig || settings.Env.IsDevMode {
|
||||
logger.Info("Generating config.yaml")
|
||||
settings.GenerateYaml()
|
||||
}
|
||||
|
||||
if generateConfig {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func SplitByMultiple(str string) []string {
|
||||
delimiters := []rune{'|', ',', ' '}
|
||||
return strings.FieldsFunc(str, func(r rune) bool {
|
||||
for _, d := range delimiters {
|
||||
if r == d {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func freshCLI() cliRoot {
|
||||
return cliRoot{}
|
||||
}
|
||||
|
||||
func newCLIParser(t *testing.T, cli *cliRoot) *kong.Kong {
|
||||
t.Helper()
|
||||
parser, err := kong.New(cli, kong.Name("filebrowser"), kong.Bind(&cli.Globals))
|
||||
require.NoError(t, err)
|
||||
return parser
|
||||
}
|
||||
|
||||
func TestParseDefaultServerCommand(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "run", ctx.Command())
|
||||
}
|
||||
|
||||
func TestParseVersionCommand(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"version"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "version", ctx.Command())
|
||||
}
|
||||
|
||||
func TestParseInitCommand(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"init"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "init", ctx.Command())
|
||||
}
|
||||
|
||||
func TestParseGlobalConfigFlag(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
_, err := parser.Parse([]string{"-c", "/tmp/custom.yaml", "version"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/tmp/custom.yaml", cli.Config)
|
||||
}
|
||||
|
||||
func TestParseUserSetWithPasswordFlag(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"user", "set", "alice", "--password", "secret", "--admin"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user set <username>", ctx.Command())
|
||||
assert.Equal(t, "alice", cli.User.Set.Username)
|
||||
assert.True(t, cli.User.Set.Password.Provided)
|
||||
assert.Equal(t, "secret", cli.User.Set.Password.Inline)
|
||||
assert.True(t, cli.User.Set.Admin)
|
||||
}
|
||||
|
||||
func TestParseUserSetConfigFlagOrder(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
_, err := parser.Parse([]string{"-c", "other.yaml", "user", "set", "bob", "--password", "x"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "other.yaml", cli.Config)
|
||||
}
|
||||
|
||||
func TestParseSetRuleFlags(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{
|
||||
"set", "rule",
|
||||
"-s", "access", "-p", "/", "-r", "user", "-v", "admin", "--allow",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "set rule", ctx.Command())
|
||||
assert.Equal(t, "access", cli.Set.Rule.Source)
|
||||
assert.Equal(t, "/", cli.Set.Rule.Path)
|
||||
assert.Equal(t, "user", cli.Set.Rule.Role)
|
||||
assert.Equal(t, "admin", cli.Set.Rule.Value)
|
||||
assert.True(t, cli.Set.Rule.Allow)
|
||||
}
|
||||
|
||||
func TestParseLegacySetUser(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"set", "-u", "alice,secret", "-a"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "set", ctx.Command())
|
||||
assert.Equal(t, "alice,secret", cli.Set.User)
|
||||
assert.True(t, cli.Set.Admin)
|
||||
}
|
||||
|
||||
func TestPasswordFlagResolveInline(t *testing.T) {
|
||||
p := passwordFlag{Provided: true, Inline: "inline-pass"}
|
||||
got, err := p.resolve(true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "inline-pass", got)
|
||||
}
|
||||
|
||||
func TestPasswordFlagResolveStdin(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
oldStdin := os.Stdin
|
||||
os.Stdin = r
|
||||
t.Cleanup(func() { os.Stdin = oldStdin })
|
||||
|
||||
_, err = w.WriteString("piped-secret\n")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
p := passwordFlag{Provided: true}
|
||||
got, err := p.resolve(false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "piped-secret", got)
|
||||
}
|
||||
|
||||
func TestPasswordFlagMissing(t *testing.T) {
|
||||
p := passwordFlag{}
|
||||
_, err := p.resolve(false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--password is required")
|
||||
}
|
||||
|
||||
func TestPasswordFlagNoInputRequiresInline(t *testing.T) {
|
||||
p := passwordFlag{Provided: true}
|
||||
_, err := p.resolve(true)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--no-input")
|
||||
}
|
||||
|
||||
func TestSplitByMultiple(t *testing.T) {
|
||||
assert.Equal(t, []string{"info", "warning", "error"}, SplitByMultiple("info|warning|error"))
|
||||
}
|
||||
|
||||
func TestSetCmdRunSkipsWhenRuleSelected(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"set", "rule", "-s", "x", "-p", "/", "-r", "all"})
|
||||
require.NoError(t, err)
|
||||
cli.Set.User = "should-not-run"
|
||||
err = cli.Set.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSetCmdRunLegacyUser(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"set", "-u", "bad"})
|
||||
require.NoError(t, err)
|
||||
err = cli.Set.Run(ctx)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not enough info")
|
||||
}
|
||||
|
||||
func TestPasswordReadAllFromReader(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(" trimmed \n")
|
||||
data, err := io.ReadAll(&buf)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "trimmed", strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
func TestResolveConfigPathEnvMissingFile(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
|
||||
t.Setenv("FILEBROWSER_CONFIG", missing)
|
||||
config := ""
|
||||
resolveConfigPath(&config)
|
||||
assert.Equal(t, missing, config)
|
||||
_, err := os.Stat(config)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSetupNoInputRejected(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"setup", "--no-input"})
|
||||
require.NoError(t, err)
|
||||
err = ctx.Run()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "interactive input")
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupCLIUserTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.InitializeCLI("../../../_docker/src/noauth/backend/config.yaml")
|
||||
settings.Env.IsPlaywright = true
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
store, _, err := state.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(store)
|
||||
t.Cleanup(func() { _ = state.Close() })
|
||||
}
|
||||
|
||||
func TestSetUserConvertsOIDCToPassword(t *testing.T) {
|
||||
setupCLIUserTest(t)
|
||||
|
||||
oidcUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(oidcUser, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := setUser("graham", "newpassword", false); err != nil {
|
||||
t.Fatalf("setUser: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := state.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.LoginMethod != users.LoginMethodPassword {
|
||||
t.Fatalf("loginMethod = %q, want password", loaded.LoginMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteUserRejectsNonPasswordLoginMethod(t *testing.T) {
|
||||
setupCLIUserTest(t)
|
||||
|
||||
oidcUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(oidcUser, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := promoteUser("graham")
|
||||
if err == nil {
|
||||
t.Fatal("promoteUser: expected error for oidc user")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "password-login") {
|
||||
t.Fatalf("promoteUser err = %q, want password-login restriction", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteUserAllowsPasswordLoginMethod(t *testing.T) {
|
||||
setupCLIUserTest(t)
|
||||
|
||||
passwordUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(passwordUser, "secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := promoteUser("graham"); err != nil {
|
||||
t.Fatalf("promoteUser: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := state.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !loaded.Permissions.Admin {
|
||||
t.Fatal("expected admin after promote")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
const settingsMigrationBoltMinSize = 1024
|
||||
|
||||
// Docker source paths stored in the committed database.db.old Bolt fixture.
|
||||
const (
|
||||
fixturePlaywrightSource = "/app/frontend/tests/playwright-files"
|
||||
fixtureDockerSource = "/app/backend"
|
||||
fixtureAccessSource = "/tests/playwright-files"
|
||||
)
|
||||
|
||||
// settingsMigrationBoltPath returns the committed read-only BoltDB reference used by
|
||||
// migration tests and the Playwright settings Docker image. Tests must never write to it.
|
||||
func settingsMigrationBoltPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
path, err := filepath.Abs(filepath.Join("..", "..", "_docker", "src", "settings", "backend", "database.db.old"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"settings migration fixture not found at %s: %v (commit _docker/src/settings/backend/database.db.old)",
|
||||
path,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if info.Size() < settingsMigrationBoltMinSize {
|
||||
t.Fatalf("settings migration fixture at %s is empty or too small (%d bytes)", path, info.Size())
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func settingsMigrationConfigPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
path, err := filepath.Abs(filepath.Join("..", "..", "_docker", "src", "settings", "backend", "config.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("settings migration config not found at %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// alignSettingsSourcesForMigrationFixture maps config sources to the Docker paths
|
||||
// stored in database.db.old so migration tests can run locally without modifying
|
||||
// the committed Bolt fixture.
|
||||
func alignSettingsSourcesForMigrationFixture(t *testing.T) {
|
||||
t.Helper()
|
||||
byName := map[string]string{
|
||||
"playwright + files": fixturePlaywrightSource,
|
||||
"docker": fixtureDockerSource,
|
||||
"access": fixtureAccessSource,
|
||||
}
|
||||
newSourceMap := make(map[string]*settings.Source, len(byName))
|
||||
for name, dockerPath := range byName {
|
||||
source, ok := settings.Config.Server.NameToSource[name]
|
||||
if !ok {
|
||||
t.Fatalf("source %q not in config", name)
|
||||
}
|
||||
if source.Path != dockerPath {
|
||||
delete(settings.Config.Server.SourceMap, source.Path)
|
||||
source.Path = dockerPath
|
||||
}
|
||||
newSourceMap[dockerPath] = source
|
||||
settings.Config.Server.NameToSource[name] = source
|
||||
}
|
||||
settings.Config.Server.SourceMap = newSourceMap
|
||||
sources := make([]*settings.Source, 0, len(byName))
|
||||
for _, name := range []string{"playwright + files", "docker", "access"} {
|
||||
sources = append(sources, settings.Config.Server.NameToSource[name])
|
||||
}
|
||||
settings.Config.Server.Sources = sources
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Globals holds flags shared across all commands (order-independent).
|
||||
type Globals struct {
|
||||
Config string `short:"c" name:"config" default:"config.yaml" env:"FILEBROWSER_CONFIG" help:"Path to config file"`
|
||||
NoInput bool `name:"no-input" help:"Disable interactive prompts (fail if input required)"`
|
||||
}
|
||||
|
||||
type runCmd struct{}
|
||||
|
||||
type versionCmd struct{}
|
||||
|
||||
type setupCmd struct{}
|
||||
|
||||
type initCmd struct{}
|
||||
|
||||
type SetCmd struct {
|
||||
User string `short:"u" help:"Deprecated: comma-separated username,password. Use 'user set' instead."`
|
||||
Admin bool `short:"a" help:"Create user as admin (used with -u)"`
|
||||
Rule SetRuleCmd `cmd:"" name:"rule" help:"Add or update an access rule"`
|
||||
}
|
||||
|
||||
type SetRuleCmd struct {
|
||||
Source string `short:"s" name:"source" aliases:"f,sourceName" required:"" help:"Source name from config"`
|
||||
Path string `short:"p" name:"path" aliases:"sourcePath" required:"" help:"Index path within the source"`
|
||||
Role string `short:"r" name:"role" enum:"user,group,all" required:"" help:"Rule target type"`
|
||||
Value string `short:"v" name:"value" help:"Username or group name (required for user/group)"`
|
||||
Allow bool `name:"allow" help:"Allow access (default: deny)"`
|
||||
}
|
||||
|
||||
type UserCmd struct {
|
||||
Set UserSetCmd `cmd:"" name:"set" help:"Create or update a password-authenticated user"`
|
||||
Promote UserPromoteCmd `cmd:"" name:"promote" help:"Grant admin permissions without changing password"`
|
||||
}
|
||||
|
||||
type UserPromoteCmd struct {
|
||||
Username string `arg:"" help:"Username to promote"`
|
||||
}
|
||||
|
||||
type UserSetCmd struct {
|
||||
Username string `arg:"" help:"Username"`
|
||||
Password passwordFlag `name:"password" help:"Password; omit value to prompt (TTY) or read stdin (pipe)"`
|
||||
Admin bool `short:"a" name:"admin" help:"Grant admin permissions"`
|
||||
}
|
||||
|
||||
// passwordFlag accepts --password VALUE (inline) or --password alone (prompt/pipe).
|
||||
type passwordFlag struct {
|
||||
Provided bool
|
||||
Inline string
|
||||
}
|
||||
|
||||
func (p *passwordFlag) Decode(ctx *kong.DecodeContext) error {
|
||||
p.Provided = true
|
||||
if ctx.Scan.Peek().IsValue() {
|
||||
return ctx.Scan.PopValueInto("password", &p.Inline)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p passwordFlag) resolve(noInput bool) (string, error) {
|
||||
if !p.Provided {
|
||||
return "", fmt.Errorf("--password is required (inline value, interactive prompt, or piped stdin)")
|
||||
}
|
||||
if p.Inline != "" {
|
||||
return p.Inline, nil
|
||||
}
|
||||
if noInput {
|
||||
return "", fmt.Errorf("--password requires an inline value or piped stdin when --no-input is set")
|
||||
}
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return promptPassword()
|
||||
}
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read password from stdin: %w", err)
|
||||
}
|
||||
password := strings.TrimSpace(string(data))
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("password must not be empty")
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
func promptPassword() (string, error) {
|
||||
if _, err := fmt.Fprint(os.Stderr, "Password: "); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytePassword, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
password := string(bytePassword)
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("password must not be empty")
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
var rootCLI cliRoot
|
||||
|
||||
type cliRoot struct {
|
||||
Globals `embed:""`
|
||||
|
||||
Run runCmd `cmd:"" default:"1" hidden:"" help:"Start the FileBrowser server"`
|
||||
Version versionCmd `cmd:"" name:"version" help:"Print version information"`
|
||||
Setup setupCmd `cmd:"" name:"setup" help:"Interactive configuration setup"`
|
||||
Init initCmd `cmd:"" name:"init" hidden:"" help:"Initialize the database or migrate from a legacy database"`
|
||||
Set SetCmd `cmd:"" name:"set" help:"Set configuration values (deprecated: use 'user set' for users)"`
|
||||
User UserCmd `cmd:"" name:"user" help:"User management"`
|
||||
}
|
||||
|
||||
func (versionCmd) Run() error {
|
||||
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
|
||||
Version : %v
|
||||
Commit : %v
|
||||
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
|
||||
`, version.Version, version.CommitSHA, version.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (setupCmd) Run(globals *Globals) error {
|
||||
return createConfig(globals.Config, globals.NoInput)
|
||||
}
|
||||
|
||||
func (initCmd) Run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SetCmd) Run(ctx *kong.Context) error {
|
||||
if strings.HasPrefix(ctx.Command(), "set rule") {
|
||||
return nil
|
||||
}
|
||||
if s.User == "" {
|
||||
return fmt.Errorf("missing subcommand for 'set'. Use 'set rule' or 'user set'")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "warning: 'set -u' is deprecated; use 'user set' instead")
|
||||
userInfo := strings.SplitN(s.User, ",", 2)
|
||||
if len(userInfo) < 2 || userInfo[0] == "" || userInfo[1] == "" {
|
||||
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", strings.Split(s.User, ","))
|
||||
}
|
||||
return setUser(userInfo[0], userInfo[1], s.Admin)
|
||||
}
|
||||
|
||||
func (r *SetRuleCmd) Run() error {
|
||||
sourceInfo, ok := settings.Config.Server.NameToSource[r.Source]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid source name: %s", r.Source)
|
||||
}
|
||||
return setRule(sourceInfo.Path, r.Path, r.Role, r.Value, r.Allow)
|
||||
}
|
||||
|
||||
func (u *UserSetCmd) Run(globals *Globals) error {
|
||||
password, err := u.Password.resolve(globals.NoInput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return setUser(u.Username, password, u.Admin)
|
||||
}
|
||||
|
||||
func (p *UserPromoteCmd) Run() error {
|
||||
return promoteUser(p.Username)
|
||||
}
|
||||
|
||||
func resolveConfigPath(config *string) {
|
||||
envConfig := os.Getenv("FILEBROWSER_CONFIG")
|
||||
if *config == "" {
|
||||
if envConfig != "" {
|
||||
*config = envConfig
|
||||
} else {
|
||||
*config = "config.yaml"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireExistingConfig(config string) {
|
||||
envConfig := os.Getenv("FILEBROWSER_CONFIG")
|
||||
if envConfig != "" && config == envConfig {
|
||||
if _, err := os.Stat(config); err != nil {
|
||||
logger.Fatalf("config file %v does not exist, please create it or set the FILEBROWSER_CONFIG environment variable to a valid config file path", config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runCLI() (keepGoing bool, dbExists bool) {
|
||||
generateYaml()
|
||||
|
||||
parser, err := kong.New(&rootCLI,
|
||||
kong.Name("filebrowser"),
|
||||
kong.Description("FileBrowser Quantum - A modern web-based file manager"),
|
||||
kong.Bind(&rootCLI.Globals),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to configure CLI: %v", err)
|
||||
}
|
||||
|
||||
ctx, err := parser.Parse(os.Args[1:])
|
||||
parser.FatalIfErrorf(err)
|
||||
|
||||
resolveConfigPath(&rootCLI.Config)
|
||||
configPath = rootCLI.Config
|
||||
|
||||
cmd := ctx.Command()
|
||||
switch {
|
||||
case cmd == "" || cmd == "run":
|
||||
requireExistingConfig(configPath)
|
||||
dbExists = initializeDatabase(configPath, false)
|
||||
return true, dbExists
|
||||
case cmd == "version" || cmd == "setup":
|
||||
parser.FatalIfErrorf(ctx.Run(&rootCLI))
|
||||
return false, false
|
||||
case cmd == "init" || cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
|
||||
requireExistingConfig(configPath)
|
||||
dbExists = initializeDatabase(configPath, true)
|
||||
parser.FatalIfErrorf(ctx.Run(&rootCLI))
|
||||
return false, dbExists
|
||||
default:
|
||||
parser.Fatalf("unexpected command: %q", cmd)
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
storm "github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/dbindex"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
const migrationGuideURL = "https://filebrowserquantum.com/en/docs/getting-started/v2/migration"
|
||||
|
||||
func migrationErrf(format string, args ...any) error {
|
||||
return fmt.Errorf(format+" See the v2 migration guide: "+migrationGuideURL, args...)
|
||||
}
|
||||
|
||||
// checkMigrationNeeded is true when server.database.migrateFrom points at a BoltDB file to import and
|
||||
// server.database.path (SQLite) does not exist yet or is empty.
|
||||
func checkMigrationNeeded() bool {
|
||||
boltPath := settings.Config.Server.DatabaseV2.MigrateFrom
|
||||
if boltPath == "" {
|
||||
return false
|
||||
}
|
||||
if sqliteDatabasePopulated(settings.Config.Server.DatabaseV2.Path) {
|
||||
return false
|
||||
}
|
||||
return legacyBoltDatabasePopulated(boltPath)
|
||||
}
|
||||
|
||||
// validateDatabasePaths enforces database path rules before opening or creating SQLite:
|
||||
// 1. On a fresh install, fail if server.database.path is database.db or an unrenamed
|
||||
// legacy database file (database.db) is present in the working directory.
|
||||
// 2. On a fresh install with server.database.migrateFrom set, fail if the legacy database file is missing
|
||||
// or empty.
|
||||
// 3. When SQLite already exists and server.database.migrateFrom is set, fail if the legacy database file
|
||||
// is missing or empty.
|
||||
//
|
||||
// Otherwise the existing SQLite database is used, or a new one is created on first open.
|
||||
func validateDatabasePaths() error {
|
||||
sqlitePath := settings.Config.Server.DatabaseV2.Path
|
||||
boltPath := settings.Config.Server.DatabaseV2.MigrateFrom
|
||||
sqliteExists := sqliteDatabasePopulated(sqlitePath)
|
||||
|
||||
if !sqliteExists {
|
||||
if filepath.Base(sqlitePath) == "database.db" {
|
||||
return migrationErrf(
|
||||
`server.database.path cannot be "database.db": that name is reserved for the legacy database file. ` +
|
||||
`Rename database.db to database.db.old, set server.database.path to a new SQLite filename ` +
|
||||
`and set server.database.migrateFrom to "database.db.old".`,
|
||||
)
|
||||
}
|
||||
if _, err := os.Stat("database.db"); err == nil {
|
||||
return migrationErrf(
|
||||
`legacy database file found in the working directory. ` +
|
||||
`Rename it to database.db.old, set server.database.migrateFrom to "database.db.old" ` +
|
||||
`or use migrateFrom: "default".`,
|
||||
)
|
||||
}
|
||||
if boltPath != "" {
|
||||
return legacyBoltDatabaseError(boltPath, sqlitePath, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if boltPath != "" {
|
||||
return legacyBoltDatabaseError(boltPath, sqlitePath, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func legacyBoltDatabaseError(boltPath, sqlitePath string, freshInstall bool) error {
|
||||
stat, err := os.Stat(boltPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if freshInstall {
|
||||
return migrationErrf(
|
||||
`server.database.migrateFrom is %q but that file does not exist, and no SQLite database exists at server.database.path (%q). `+
|
||||
`Check the migrateFrom path or restore your renamed legacy database backup.`,
|
||||
boltPath, sqlitePath,
|
||||
)
|
||||
}
|
||||
return migrationErrf(
|
||||
`server.database.migrateFrom is %q but that file does not exist. `+
|
||||
`Migration appears complete — remove server.database.migrateFrom from your config.`,
|
||||
boltPath,
|
||||
)
|
||||
}
|
||||
return migrationErrf(
|
||||
`server.database.migrateFrom is %q but cannot be read: %v. `+
|
||||
`Check file permissions or restore your backed-up database file.`,
|
||||
boltPath, err,
|
||||
)
|
||||
}
|
||||
if stat.Size() == 0 {
|
||||
if freshInstall {
|
||||
return migrationErrf(
|
||||
`server.database.migrateFrom is %q but that file is empty, and no SQLite database exists at server.database.path (%q). `+
|
||||
`Restore your backed-up database file or fix the migrateFrom path.`,
|
||||
boltPath, sqlitePath,
|
||||
)
|
||||
}
|
||||
return migrationErrf(
|
||||
`server.database.migrateFrom is %q but that file is empty. `+
|
||||
`Remove server.database.migrateFrom from your config, or restore the legacy database backup if migration is not complete.`,
|
||||
boltPath,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteDatabasePopulated(path string) bool {
|
||||
stat, err := os.Stat(path)
|
||||
return err == nil && stat.Size() > 0
|
||||
}
|
||||
|
||||
func legacyBoltDatabasePopulated(path string) bool {
|
||||
stat, err := os.Stat(path)
|
||||
return err == nil && stat.Size() > 0
|
||||
}
|
||||
|
||||
// migrateFromBoltToSQLite migrates essential data from BoltDB to SQLite
|
||||
func migrateFromBoltToSQLite() error {
|
||||
oldDBPath := settings.Config.Server.DatabaseV2.MigrateFrom
|
||||
newDBPath := settings.Config.Server.DatabaseV2.Path
|
||||
|
||||
logger.Info("========================================")
|
||||
logger.Info("Starting migration from legacy database")
|
||||
logger.Info("========================================")
|
||||
// Open old BoltDB (read-only)
|
||||
logger.Info("Opening old database...")
|
||||
oldDB, err := storm.Open(oldDBPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open old database: %w", err)
|
||||
}
|
||||
logger.Info("✓ Old database opened")
|
||||
|
||||
// Initialize new database
|
||||
logger.Info("Initializing new database...")
|
||||
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(newDBPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
|
||||
if err != nil {
|
||||
oldDB.Close()
|
||||
return fmt.Errorf("failed to initialize new database: %w", err)
|
||||
}
|
||||
logger.Info("✓ New database initialized")
|
||||
|
||||
// Ensure cleanup on error - remove incomplete database for retry
|
||||
migrationSuccess := false
|
||||
defer func() {
|
||||
oldDB.Close()
|
||||
sqlStore.Close()
|
||||
|
||||
if !migrationSuccess {
|
||||
// Migration failed - remove the new database so it can be retried
|
||||
logger.Warning("Migration failed, cleaning up new database file for retry...")
|
||||
if err := os.Remove(newDBPath); err != nil && !os.IsNotExist(err) {
|
||||
logger.Errorf("Failed to remove incomplete database file: %v", err)
|
||||
} else {
|
||||
logger.Info("✓ Incomplete database file removed, migration can be retried on next startup")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Migrate users (bolt User.ID is stored as SQLite users.user_id)
|
||||
logger.Info("Migrating users...")
|
||||
if err := migrateUsers(oldDB, sqlStore); err != nil {
|
||||
return fmt.Errorf("failed to migrate users: %w", err)
|
||||
}
|
||||
if err := state.EnsureNoAuthAdminUserAfterMigration(sqlStore); err != nil {
|
||||
return fmt.Errorf("failed to configure noauth admin user: %w", err)
|
||||
}
|
||||
|
||||
// Migrate shares
|
||||
logger.Info("Migrating shares...")
|
||||
if err := migrateShares(oldDB, sqlStore); err != nil {
|
||||
return fmt.Errorf("failed to migrate shares: %w", err)
|
||||
}
|
||||
|
||||
// Migrate access rules and groups
|
||||
logger.Info("Migrating access rules and groups...")
|
||||
if err := migrateAccessRules(oldDB, sqlStore); err != nil {
|
||||
return fmt.Errorf("failed to migrate access rules: %w", err)
|
||||
}
|
||||
|
||||
// Migrate index info
|
||||
logger.Info("Migrating index info...")
|
||||
if err := migrateIndexInfo(oldDB, sqlStore); err != nil {
|
||||
return fmt.Errorf("failed to migrate index info: %w", err)
|
||||
}
|
||||
|
||||
// Mark migration as successful
|
||||
migrationSuccess = true
|
||||
|
||||
logger.Info("========================================")
|
||||
logger.Info("Migration completed successfully!")
|
||||
logger.Info("========================================")
|
||||
logger.Infof("Your legacy database file is unchanged at: %s", oldDBPath)
|
||||
logger.Infof("New SQLite database created at: %s", newDBPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeUserScopesBeforeSQLite copies scopes from FrontendScopes into BackendScopes when the Bolt
|
||||
// record only had JSON key "scopes" (FrontendUser) and nothing under backendScopes — CreateUser
|
||||
// persists only BackendScopes in SQLite user_data.
|
||||
func normalizeUserScopesBeforeSQLite(user *users.User) error {
|
||||
if len(user.BackendScopes) > 0 {
|
||||
return nil
|
||||
}
|
||||
backendScopes, err := users.APIScopesToBackend(user.FrontendScopes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert frontend scopes to backend: %w", err)
|
||||
}
|
||||
for i := range backendScopes {
|
||||
backendScopes[i].Permissions = users.SourceFilePermissions{
|
||||
View: true,
|
||||
Download: user.Permissions.Download,
|
||||
Modify: user.Permissions.Modify,
|
||||
Delete: user.Permissions.Delete,
|
||||
Create: user.Permissions.Create,
|
||||
}
|
||||
}
|
||||
user.FrontendScopes = nil
|
||||
user.BackendScopes = backendScopes
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateUsers migrates all users from BoltDB to SQLite. Each bolt user.ID is written as user_id
|
||||
func migrateUsers(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
|
||||
var usersList []*users.User
|
||||
err := oldDB.All(&usersList)
|
||||
if err != nil {
|
||||
if err.Error() == "not found" {
|
||||
logger.Info(" No users to migrate")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read users from old DB: %w", err)
|
||||
}
|
||||
|
||||
promoted := 0
|
||||
for _, user := range usersList {
|
||||
oldScopesCount := len(user.FrontendScopes)
|
||||
newScopesCount := len(user.BackendScopes)
|
||||
if err := normalizeUserScopesBeforeSQLite(user); err != nil {
|
||||
return fmt.Errorf("failed to normalize scopes for user %s: %w", user.Username, err)
|
||||
}
|
||||
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
|
||||
user.SidebarLinks = normalized
|
||||
}
|
||||
if merged, changed := usersidebar.EnsureSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes); changed {
|
||||
user.SidebarLinks = merged
|
||||
}
|
||||
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
|
||||
user.SidebarLinks = normalized
|
||||
}
|
||||
prevVersion := user.Version
|
||||
if len(user.BackendScopes) == 0 {
|
||||
settings.ApplyUserDefaults(user)
|
||||
}
|
||||
if prevVersion < users.SourcePermissionsMigrationVersion {
|
||||
// ApplyUserDefaults bumps Version; restore pre-defaults version so legacy global file ops migrate to scopes.
|
||||
user.Version = prevVersion
|
||||
users.MigrateToSourcePermissions(user)
|
||||
}
|
||||
normalizeUserTokensBeforeSQLite(user)
|
||||
updateTokens(user)
|
||||
user.Version = users.ProfileStorageVersion
|
||||
if newScopesCount > oldScopesCount {
|
||||
promoted++
|
||||
logger.Infof(" user %q: legacy database had %d scopes, SQLite now has %d",
|
||||
user.Username, oldScopesCount, newScopesCount)
|
||||
}
|
||||
boltID := user.ID
|
||||
if err := sqlStore.CreateUser(user); err != nil {
|
||||
return fmt.Errorf("failed to save user %s (bolt id: %d): %w", user.Username, boltID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if promoted > 0 {
|
||||
logger.Infof(" Promoted frontend-only Bolt scopes to backend for %d user(s) before SQLite insert", promoted)
|
||||
}
|
||||
logger.Infof(" ✓ Migrated %d users", len(usersList))
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeUserTokensBeforeSQLite ensures Bolt name-keyed tokens have Name set so
|
||||
// TokensForPersist retains them in SQLite user_data (Bolt often stores the name only as the map key).
|
||||
func normalizeUserTokensBeforeSQLite(user *users.User) {
|
||||
if len(user.Tokens) == 0 {
|
||||
return
|
||||
}
|
||||
normalized := make(map[string]users.AuthToken)
|
||||
for key, token := range user.Tokens {
|
||||
name := token.Name
|
||||
if name == "" {
|
||||
if key == token.Token {
|
||||
continue
|
||||
}
|
||||
name = key
|
||||
}
|
||||
if _, exists := normalized[name]; exists {
|
||||
continue
|
||||
}
|
||||
token.Name = name
|
||||
if token.Token == "" {
|
||||
token.Token = token.Key
|
||||
}
|
||||
token.Permissions = users.SanitizeTokenPermissions(token.Permissions)
|
||||
users.StoreToken(normalized, token)
|
||||
}
|
||||
user.Tokens = normalized
|
||||
}
|
||||
|
||||
// migrateShares migrates all shares from BoltDB to SQLite.
|
||||
//
|
||||
// Legacy Bolt "Link" / "Share" JSON (flat CommonShare + Link fields):
|
||||
// - "source" → Share.SourcePath (backend source path; not the share item path)
|
||||
// - "path" → Share.Path (index-relative path within that source)
|
||||
// - "userID" → Share.UserID unchanged (same numeric id as migrated users in SQLite)
|
||||
// - "password_hash" → Share.PasswordHash (username is never stored on the share row)
|
||||
//
|
||||
// New users created after migration get random uint64 ids (utils.RandomUint64ID); Bolt ids 1,2,3…
|
||||
// remain valid. PrepForFrontend resolves Share.UserID to username for API responses only.
|
||||
func migrateShares(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
|
||||
boltShareBuckets := []string{"Share", "Link"}
|
||||
var sharesList []*share.LegacyShare
|
||||
for _, bucket := range boltShareBuckets {
|
||||
var batch []*share.LegacyShare
|
||||
err := oldDB.Select().Bucket(bucket).Find(&batch)
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
return fmt.Errorf("failed to read shares from legacy DB bucket %q: %w", bucket, err)
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
sharesList = append(sharesList, batch...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sharesList) == 0 {
|
||||
logger.Info(" No shares to migrate")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, legacy := range sharesList {
|
||||
link := legacy.ToShare()
|
||||
if link.UserID == 0 {
|
||||
return fmt.Errorf("failed to save share %s: owner user id is missing", link.Hash)
|
||||
}
|
||||
// Legacy Bolt "source" → SourcePath; normalize when it was stored as a display name.
|
||||
if link.SourcePath != "" {
|
||||
if _, ok := settings.Config.Server.SourceMap[link.SourcePath]; !ok {
|
||||
if src, ok := settings.Config.Server.NameToSource[link.SourcePath]; ok {
|
||||
link.SourceName = src.Name
|
||||
link.SourcePath = src.Path
|
||||
}
|
||||
} else if link.SourceName == "" {
|
||||
link.SourceName = settings.Config.Server.SourceMap[link.SourcePath].Name
|
||||
}
|
||||
}
|
||||
// Do not persist API-only fields; PrepForFrontend fills these on read.
|
||||
link.ShareURL = ""
|
||||
link.DownloadURL = ""
|
||||
link.FrontendShareInfo.SourceURL = ""
|
||||
link.CanEditShare = false
|
||||
if err := sqlStore.SaveShare(&link); err != nil {
|
||||
return fmt.Errorf("failed to save share %s: %w", link.Hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof(" ✓ Migrated %d shares", len(sharesList))
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateAccessRules migrates access control data from BoltDB to SQLite.
|
||||
// Path rules (AllRules) and group definitions (Groups) are unchanged through migration
|
||||
func migrateAccessRules(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
|
||||
// Read the access rules JSON blob from BoltDB
|
||||
var data []byte
|
||||
err := oldDB.Get("access_rules", "rules", &data)
|
||||
if err != nil {
|
||||
if err.Error() == "not found" {
|
||||
logger.Info(" No access rules to migrate")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read access rules: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal the data
|
||||
type dbStorage struct {
|
||||
AllRules access.SourceRuleMap `json:"all_rules"`
|
||||
Groups access.GroupMap `json:"groups"`
|
||||
RevokedTokens map[string]struct{} `json:"revoked_tokens"`
|
||||
HashedTokens map[string]uint `json:"hashed_tokens"`
|
||||
}
|
||||
|
||||
var storage dbStorage
|
||||
if err := json.Unmarshal(data, &storage); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal access rules: %w", err)
|
||||
}
|
||||
|
||||
// Migrate access rules
|
||||
ruleCount := 0
|
||||
for source, rules := range storage.AllRules {
|
||||
resolvedSource := source
|
||||
if info, ok := users.ResolveSourceKey(source); ok {
|
||||
resolvedSource = info.Path
|
||||
} else if src, ok := settings.Config.Server.NameToSource[source]; ok {
|
||||
resolvedSource = src.Path
|
||||
}
|
||||
for path, rule := range rules {
|
||||
err := sqlStore.SaveAccessRule(resolvedSource, path, rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save access rule: %w", err)
|
||||
}
|
||||
ruleCount++
|
||||
}
|
||||
}
|
||||
logger.Infof(" ✓ Migrated %d access rules", ruleCount)
|
||||
|
||||
// Migrate groups
|
||||
for name, members := range storage.Groups {
|
||||
err := sqlStore.SaveGroup(name, members)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save group %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
logger.Infof(" ✓ Migrated %d groups", len(storage.Groups))
|
||||
|
||||
// Migrate revoked tokens
|
||||
for tokenHash := range storage.RevokedTokens {
|
||||
err := sqlStore.SaveRevokedToken(tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save revoked token: %w", err)
|
||||
}
|
||||
}
|
||||
logger.Infof(" ✓ Migrated %d revoked tokens", len(storage.RevokedTokens))
|
||||
|
||||
// Migrate hashed tokens (bolt stored owner user id)
|
||||
for tokenHash, userID := range storage.HashedTokens {
|
||||
if userID == 0 {
|
||||
logger.Warningf(" skipping hashed token: invalid user id 0")
|
||||
continue
|
||||
}
|
||||
err := sqlStore.SaveHashedToken(tokenHash, uint64(userID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save hashed token: %w", err)
|
||||
}
|
||||
}
|
||||
logger.Infof(" ✓ Migrated %d hashed tokens", len(storage.HashedTokens))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateIndexInfo migrates index info metadata from BoltDB to SQLite
|
||||
func migrateIndexInfo(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
|
||||
var indexList []*dbindex.IndexInfo
|
||||
err := oldDB.All(&indexList)
|
||||
if err != nil {
|
||||
if err.Error() == "not found" {
|
||||
logger.Info(" No index info to migrate")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read index info: %w", err)
|
||||
}
|
||||
|
||||
for _, info := range indexList {
|
||||
err := sqlStore.SaveIndexInfo(info)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save index info for %s: %w", info.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof(" ✓ Migrated %d index info entries", len(indexList))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestValidateDatabasePaths_missingBoltOnFreshInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = filepath.Join(dir, "database.db.old")
|
||||
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
|
||||
|
||||
err := validateDatabasePaths()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when migrateFrom file is missing on fresh install")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabasePaths_emptyBoltOnFreshInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
boltPath := filepath.Join(dir, "database.db.old")
|
||||
if err := os.WriteFile(boltPath, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
|
||||
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
|
||||
|
||||
err := validateDatabasePaths()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when migrateFrom file is empty on fresh install")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabasePaths_missingBoltWhenSQLiteExists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = filepath.Join(dir, "missing.db.old")
|
||||
sqlitePath := filepath.Join(dir, "filebrowser.sqlite")
|
||||
if err := os.WriteFile(sqlitePath, []byte("sqlite"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.DatabaseV2.Path = sqlitePath
|
||||
|
||||
if err := validateDatabasePaths(); err == nil {
|
||||
t.Fatal("expected error when migrateFrom is set but legacy file is missing after migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabasePaths_okWhenSQLiteExistsWithoutMigrateFrom(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = ""
|
||||
sqlitePath := filepath.Join(dir, "filebrowser.sqlite")
|
||||
if err := os.WriteFile(sqlitePath, []byte("sqlite"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.DatabaseV2.Path = sqlitePath
|
||||
|
||||
if err := validateDatabasePaths(); err != nil {
|
||||
t.Fatalf("expected no error: %v", err)
|
||||
}
|
||||
if checkMigrationNeeded() {
|
||||
t.Fatal("migration should not run when sqlite already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabasePaths_okWhenSQLiteAndBoltExist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
boltPath := filepath.Join(dir, "database.db.old")
|
||||
if err := os.WriteFile(boltPath, []byte("bolt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlitePath := filepath.Join(dir, "filebrowser.sqlite")
|
||||
if err := os.WriteFile(sqlitePath, []byte("sqlite"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
|
||||
settings.Config.Server.DatabaseV2.Path = sqlitePath
|
||||
|
||||
if err := validateDatabasePaths(); err != nil {
|
||||
t.Fatalf("expected no error when both databases exist: %v", err)
|
||||
}
|
||||
if checkMigrationNeeded() {
|
||||
t.Fatal("migration should not run when sqlite already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabasePaths_rejectsDatabaseDBAsSQLitePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = ""
|
||||
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "database.db")
|
||||
|
||||
if err := validateDatabasePaths(); err == nil {
|
||||
t.Fatal("expected error when sqlite path is database.db on fresh install")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabasePaths_rejectsUnrenamedLegacyDatabaseDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = ""
|
||||
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
|
||||
if err := os.WriteFile("database.db", []byte("legacy-bolt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := validateDatabasePaths(); err == nil {
|
||||
t.Fatal("expected error when unrenamed database.db exists on fresh install")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabasePaths_migrateFromDefaultResolvesFromEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
boltPath := filepath.Join(dir, "database.db.old")
|
||||
if err := os.WriteFile(boltPath, []byte("bolt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("FILEBROWSER_DATABASE", boltPath)
|
||||
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = settings.MigrateFromDefault
|
||||
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
|
||||
|
||||
if err := settings.ResolveDatabasePaths(); err != nil {
|
||||
t.Fatalf("ResolveDatabasePaths: %v", err)
|
||||
}
|
||||
if settings.Config.Server.DatabaseV2.MigrateFrom != boltPath {
|
||||
t.Fatalf("migrateFrom = %q, want %q", settings.Config.Server.DatabaseV2.MigrateFrom, boltPath)
|
||||
}
|
||||
if err := validateDatabasePaths(); err != nil {
|
||||
t.Fatalf("validateDatabasePaths: %v", err)
|
||||
}
|
||||
if !checkMigrationNeeded() {
|
||||
t.Fatal("expected migration to be needed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMigrationNeeded_trueWhenBoltPresentAndSQLiteMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
boltPath := filepath.Join(dir, "database.db.old")
|
||||
if err := os.WriteFile(boltPath, []byte("bolt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
|
||||
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
|
||||
|
||||
if err := validateDatabasePaths(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !checkMigrationNeeded() {
|
||||
t.Fatal("expected migration to be needed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabasePaths_okFreshInstallWithoutMigrateFrom(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = ""
|
||||
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
|
||||
|
||||
if err := validateDatabasePaths(); err != nil {
|
||||
t.Fatalf("expected no error on fresh install without migrateFrom: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
storm "github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestMigrateAdminTokensRoundTripSQLite(t *testing.T) {
|
||||
boltPath := settingsMigrationBoltPath(t)
|
||||
sqlitePath := filepath.Join(t.TempDir(), "migrate-tokens.sqlite")
|
||||
|
||||
oldDB, err := storm.Open(boltPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer oldDB.Close()
|
||||
|
||||
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(sqlitePath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sqlStore.Close()
|
||||
|
||||
var list []*users.User
|
||||
if err = oldDB.All(&list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var admin *users.User
|
||||
for _, u := range list {
|
||||
if u.Username == "admin" {
|
||||
admin = u
|
||||
break
|
||||
}
|
||||
}
|
||||
if admin == nil {
|
||||
t.Fatal("admin not found")
|
||||
}
|
||||
|
||||
normalizeUserTokensBeforeSQLite(admin)
|
||||
if err = sqlStore.CreateUser(admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := sqlStore.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
customized, ok := loaded.Tokens["customized"]
|
||||
if !ok {
|
||||
t.Fatalf("customized missing after reload: %#v", loaded.Tokens)
|
||||
}
|
||||
if !customized.Permissions.Admin {
|
||||
t.Fatalf("customized admin permission missing after reload: %#v", customized.Permissions)
|
||||
}
|
||||
if customized.Permissions.Api || customized.Permissions.Share || customized.Permissions.Realtime {
|
||||
t.Fatalf("customized should only retain admin global after reload: %#v", customized.Permissions)
|
||||
}
|
||||
if customized.Permissions.Modify || customized.Permissions.Create || customized.Permissions.Delete || customized.Permissions.Download {
|
||||
t.Fatalf("customized legacy file ops should be stripped: %#v", customized.Permissions)
|
||||
}
|
||||
|
||||
out, _ := json.MarshalIndent(map[string]any{
|
||||
"customizedPermissions": customized.Permissions,
|
||||
"tokenNames": len(loaded.Tokens),
|
||||
}, "", " ")
|
||||
os.Stdout.Write(out)
|
||||
os.Stdout.Write([]byte("\n"))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
// Simulates migrateUsers() steps for a Bolt user without backend scopes or source permissions.
|
||||
func TestMigrateUsersFlow_appliesConfigDefaultsAndSourcePermissions(t *testing.T) {
|
||||
loadMigrateTestConfig(t, "../../_docker/src/jwt/backend/config.yaml")
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "legacy-bolt-user",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
Modify: true,
|
||||
Create: true,
|
||||
Share: true,
|
||||
},
|
||||
},
|
||||
Version: 2,
|
||||
}
|
||||
|
||||
prevVersion := user.Version
|
||||
if len(user.BackendScopes) == 0 {
|
||||
settings.ApplyUserDefaults(user)
|
||||
}
|
||||
if prevVersion < users.SourcePermissionsMigrationVersion {
|
||||
user.Version = prevVersion
|
||||
if !users.MigrateToSourcePermissions(user) {
|
||||
t.Fatal("expected source permission migration")
|
||||
}
|
||||
}
|
||||
user.Version = users.ProfileStorageVersion
|
||||
|
||||
if !user.Permissions.Share {
|
||||
t.Fatal("global share should remain after migration")
|
||||
}
|
||||
if user.Permissions.Modify || user.Permissions.Download {
|
||||
t.Fatalf("legacy global file ops should be cleared: %+v", user.Permissions)
|
||||
}
|
||||
if len(user.BackendScopes) == 0 {
|
||||
t.Fatal("ApplyUserDefaults should assign default-enabled sources")
|
||||
}
|
||||
perms := user.BackendScopes[0].Permissions
|
||||
if !perms.View || !perms.Modify || !perms.Create || !perms.Download {
|
||||
t.Fatalf("scope permissions after migration: %+v", perms)
|
||||
}
|
||||
if user.BackendScopes[0].Scope != "/legacy-bolt-user" {
|
||||
t.Fatalf("createUserDir scope=%q", user.BackendScopes[0].Scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateUsersFlow_existingScopeGetsLegacyGlobalFileOps(t *testing.T) {
|
||||
loadMigrateTestConfig(t, "../../_docker/src/proxy/backend/config.yaml")
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "scoped",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
Modify: false,
|
||||
Create: false,
|
||||
Share: true,
|
||||
},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: settings.Config.Server.Sources[0].Path, Scope: "/"},
|
||||
},
|
||||
Version: 2,
|
||||
}
|
||||
|
||||
users.MigrateToSourcePermissions(user)
|
||||
perms := user.BackendScopes[0].Permissions
|
||||
if !perms.Download || perms.Modify || perms.Create {
|
||||
t.Fatalf("expected legacy global copied to scope: %+v", perms)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMigrateTestConfig(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
settings.Config = settings.SetDefaults(true)
|
||||
if err := settings.LoadConfigWithDefaultsForTest(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestMigrationFixturePostState(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
settings.Env.IsPlaywright = true
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
settings.Config.Server.DatabaseV2.Path = dbPath
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
|
||||
|
||||
if err := migrateFromBoltToSQLite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sqlStore.Close()
|
||||
|
||||
playwrightPath := fixturePlaywrightSource
|
||||
dockerPath := fixtureDockerSource
|
||||
accessPath := fixtureAccessSource
|
||||
|
||||
admin, err := sqlStore.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !admin.Permissions.Admin || !admin.Permissions.Api || !admin.Permissions.Share {
|
||||
t.Fatalf("admin globals=%+v", admin.Permissions)
|
||||
}
|
||||
if admin.Permissions.Realtime {
|
||||
t.Fatal("admin realtime should be false")
|
||||
}
|
||||
|
||||
fullPerms := users.SourceFilePermissions{
|
||||
View: true, Download: true, Modify: true, Create: true, Delete: true,
|
||||
}
|
||||
for _, sourcePath := range []string{playwrightPath, dockerPath, accessPath} {
|
||||
assertSourceFilePerms(t, admin, sourcePath, fullPerms)
|
||||
}
|
||||
|
||||
customized, ok := admin.Tokens["customized"]
|
||||
if !ok {
|
||||
t.Fatal("customized token missing")
|
||||
}
|
||||
if !customized.Permissions.Admin {
|
||||
t.Fatalf("customized token globals=%+v", customized.Permissions)
|
||||
}
|
||||
if customized.Permissions.Api || customized.Permissions.Share || customized.Permissions.Realtime {
|
||||
t.Fatalf("customized token should only have admin global: %+v", customized.Permissions)
|
||||
}
|
||||
if customized.Permissions.Modify || customized.Permissions.Create || customized.Permissions.Delete || customized.Permissions.Download {
|
||||
t.Fatalf("customized token legacy file ops should be stripped: %+v", customized.Permissions)
|
||||
}
|
||||
|
||||
basic, err := sqlStore.GetUserByUsername("basic")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !basic.Permissions.Share || basic.Permissions.Admin || basic.Permissions.Api {
|
||||
t.Fatalf("basic globals=%+v", basic.Permissions)
|
||||
}
|
||||
viewOnly := users.SourceFilePermissions{View: true}
|
||||
for _, sourcePath := range []string{playwrightPath, dockerPath, accessPath} {
|
||||
assertSourceFilePerms(t, basic, sourcePath, viewOnly)
|
||||
}
|
||||
|
||||
graham, err := sqlStore.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grahamPerms := users.SourceFilePermissions{
|
||||
View: true, Download: true, Modify: true, Create: true, Delete: false,
|
||||
}
|
||||
assertSourceFilePerms(t, graham, playwrightPath, grahamPerms)
|
||||
assertSourceFilePerms(t, graham, dockerPath, grahamPerms)
|
||||
|
||||
allRules, err := sqlStore.GetAllAccessRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playwrightRules := allRules[playwrightPath]
|
||||
if rule, ok := playwrightRules["/text-files/bash.sh/"]; !ok || len(rule.Deny.Users) != 1 {
|
||||
t.Fatalf("playwright access rule=%+v ok=%v", rule, ok)
|
||||
} else if _, denied := rule.Deny.Users["admin"]; !denied {
|
||||
t.Fatalf("playwright access rule deny=%+v", rule.Deny.Users)
|
||||
}
|
||||
|
||||
accessRules := allRules[accessPath]
|
||||
if len(accessRules) != 1 {
|
||||
t.Fatalf("access source rule count=%d", len(accessRules))
|
||||
}
|
||||
if rule, ok := accessRules["/"]; !ok || len(rule.Deny.Users) != 1 {
|
||||
t.Fatalf("access source rule=%+v ok=%v", rule, ok)
|
||||
} else if _, denied := rule.Deny.Users["basic"]; !denied {
|
||||
t.Fatalf("access source rule deny=%+v", rule.Deny.Users)
|
||||
}
|
||||
|
||||
shares, err := sqlStore.GetSharesByUserID(admin.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(shares) != 2 {
|
||||
t.Fatalf("share count=%d", len(shares))
|
||||
}
|
||||
shareByHash := make(map[string]*share.Share, len(shares))
|
||||
for _, s := range shares {
|
||||
shareByHash[s.Hash] = s
|
||||
}
|
||||
if link, ok := shareByHash["lMhwHkF-hqCN92-QIJJZow"]; !ok || link.Path != "/myfolder/" ||
|
||||
!link.AllowModify || !link.AllowCreate || !link.AllowDelete {
|
||||
t.Fatalf("myfolder share=%+v", link)
|
||||
}
|
||||
if link, ok := shareByHash["dGhQi4AcMhva2Ne-7x7fvw"]; !ok || link.Path != "/test & test.txt/" ||
|
||||
link.AllowModify || link.AllowCreate || link.AllowDelete {
|
||||
t.Fatalf("test share=%+v", link)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSourceFilePerms(t *testing.T, user *users.User, sourcePath string, want users.SourceFilePermissions) {
|
||||
t.Helper()
|
||||
got, ok := user.BackendSourcePermissions[sourcePath]
|
||||
if !ok {
|
||||
t.Fatalf("user %q missing source permissions for %q", user.Username, sourcePath)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("user %q source %q perms=%+v want=%+v", user.Username, sourcePath, got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
storm "github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func writeSidebarMigrationBolt(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
db, err := storm.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
user := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: "$2a$10$IYCsziHjzH0mPc.bZwRuXefQKPVXfFqjdyfVmNcL.XZJsgyfxljDy",
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
{
|
||||
Name: "My Files",
|
||||
Category: string(users.SidebarLinkSourceMinimal),
|
||||
Icon: "folder",
|
||||
SourceName: fixturePlaywrightSource,
|
||||
Target: "/",
|
||||
},
|
||||
{
|
||||
Name: "External Docs",
|
||||
Category: string(users.SidebarLinkCustom),
|
||||
Target: "https://example.com/docs",
|
||||
Icon: "link",
|
||||
},
|
||||
},
|
||||
},
|
||||
Username: "sidebaruser",
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
FrontendScopes: []users.FrontendScope{
|
||||
{Name: fixturePlaywrightSource, Scope: "/"},
|
||||
{Name: fixtureDockerSource, Scope: "/"},
|
||||
},
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
},
|
||||
},
|
||||
Version: 3,
|
||||
}
|
||||
if err := db.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationSidebarLinks_customMinimalPartialScopesAndCustomURL(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
settings.Env.IsPlaywright = true
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
boltPath := filepath.Join(t.TempDir(), "sidebar-migration.db.old")
|
||||
writeSidebarMigrationBolt(t, boltPath)
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
settings.Config.Server.DatabaseV2.Path = dbPath
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
|
||||
|
||||
if err := migrateFromBoltToSQLite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sqlStore.Close()
|
||||
|
||||
sidebarUser, err := sqlStore.GetUserByUsername("sidebaruser")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
links := sidebarUser.SidebarLinks
|
||||
if len(links) < 3 {
|
||||
t.Fatalf("sidebaruser links=%#v, want at least 3 after scope merge", links)
|
||||
}
|
||||
|
||||
var minimalLink *users.SidebarLink
|
||||
var customLink *users.SidebarLink
|
||||
var dockerLink *users.SidebarLink
|
||||
for i := range links {
|
||||
link := links[i]
|
||||
switch {
|
||||
case link.Category == string(users.SidebarLinkSourceMinimal):
|
||||
minimalLink = &links[i]
|
||||
case link.Category == string(users.SidebarLinkCustom):
|
||||
customLink = &links[i]
|
||||
case link.SourceName == fixtureDockerSource || link.Name == "docker":
|
||||
dockerLink = &links[i]
|
||||
}
|
||||
}
|
||||
if minimalLink == nil {
|
||||
t.Fatalf("missing source-minimal link in %#v", links)
|
||||
}
|
||||
if minimalLink.Name != "My Files" || minimalLink.Icon != "folder" {
|
||||
t.Fatalf("custom minimal link not preserved: %#v", *minimalLink)
|
||||
}
|
||||
if customLink == nil || customLink.Name != "External Docs" || customLink.Target != "https://example.com/docs" {
|
||||
t.Fatalf("custom link not preserved: %#v", customLink)
|
||||
}
|
||||
if dockerLink == nil {
|
||||
t.Fatalf("docker scope link missing after merge: %#v", links)
|
||||
}
|
||||
|
||||
frontendLinks := usersidebar.FrontendLinks(links, true)
|
||||
if len(frontendLinks) < 3 {
|
||||
t.Fatalf("frontend links=%#v", frontendLinks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// healthcheck attempt to save test file against configured url
|
||||
func validateOfficeIntegration() {
|
||||
if settings.Config.Integrations.OnlyOffice.Url != "" {
|
||||
// get url health
|
||||
// get request against the url
|
||||
_, err := http.NewRequest("GET", settings.Config.Integrations.OnlyOffice.Url, nil)
|
||||
if err != nil {
|
||||
logger.Warning("Could not create request to only office Url, make sure its valid and running")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+194
-126
@@ -1,161 +1,229 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/diskcache"
|
||||
"github.com/gtsteffaniak/filebrowser/files"
|
||||
fbhttp "github.com/gtsteffaniak/filebrowser/http"
|
||||
"github.com/gtsteffaniak/filebrowser/img"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/swagger/docs"
|
||||
_ "net/http/pprof"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/analytics"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/icons"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/web"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/swaggo/swag"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/version"
|
||||
)
|
||||
|
||||
func getStore(config string) (*storage.Storage, bool) {
|
||||
// Use the config file (global flag)
|
||||
settings.Initialize(config)
|
||||
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
|
||||
if err != nil {
|
||||
log.Fatal("could not load db info: ", err)
|
||||
}
|
||||
return store, hasDB
|
||||
}
|
||||
var runtimeApp *app.App
|
||||
|
||||
func generalUsage() {
|
||||
fmt.Printf(`usage: ./html-web-crawler <command> [options] --urls <urls>
|
||||
commands:
|
||||
collect Collect data from URLs
|
||||
crawl Crawl URLs and collect data
|
||||
install Install chrome browser for javascript enabled scraping.
|
||||
Note: Consider instead to install via native package manager,
|
||||
then set "CHROME_EXECUTABLE" in the environment
|
||||
` + "\n")
|
||||
func initializeDatabase(configFile string, cliMode bool) bool {
|
||||
if cliMode {
|
||||
settings.InitializeCLI(configFile)
|
||||
} else {
|
||||
settings.Initialize(configFile)
|
||||
}
|
||||
|
||||
if err := validateDatabasePaths(); err != nil {
|
||||
logger.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
if checkMigrationNeeded() {
|
||||
logger.Info("Old database detected, starting migration...")
|
||||
err := migrateFromBoltToSQLite()
|
||||
if err != nil {
|
||||
logger.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
store, existingDb, err := state.Open(settings.Config.Server.DatabaseV2.Path)
|
||||
if err != nil {
|
||||
logger.Fatalf("could not initialize state: %v", err)
|
||||
}
|
||||
runtimeApp, err = app.WireServices(store)
|
||||
if err != nil {
|
||||
logger.Fatalf("could not wire services: %v", err)
|
||||
}
|
||||
return existingDb
|
||||
}
|
||||
|
||||
func StartFilebrowser() {
|
||||
// Global flags
|
||||
var configPath string
|
||||
var help bool
|
||||
// Override the default usage output to use generalUsage()
|
||||
flag.Usage = generalUsage
|
||||
flag.StringVar(&configPath, "c", "filebrowser.yaml", "Path to the config file.")
|
||||
flag.BoolVar(&help, "h", false, "Get help about commands")
|
||||
|
||||
// Parse global flags (before subcommands)
|
||||
flag.Parse() // print generalUsage on error
|
||||
|
||||
// Show help if requested
|
||||
if help {
|
||||
generalUsage()
|
||||
keepGoing, dbExists := runCLI()
|
||||
if !keepGoing {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new FlagSet for the 'set' subcommand
|
||||
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
|
||||
var user, scope, dbConfig string
|
||||
var asAdmin bool
|
||||
|
||||
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
|
||||
setCmd.BoolVar(&asAdmin, "a", false, "Create user as admin user, used in combination with -u")
|
||||
setCmd.StringVar(&scope, "s", "", "Specify a user scope, otherwise default user config scope is used")
|
||||
setCmd.StringVar(&dbConfig, "c", "filebrowser.yaml", "Path to the config file.")
|
||||
|
||||
// Parse subcommand flags only if a subcommand is specified
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "set":
|
||||
err := setCmd.Parse(os.Args)
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
userInfo := strings.Split(user, ",")
|
||||
if len(userInfo) < 2 {
|
||||
fmt.Println("not enough info to create user: \"set -u username,password\"")
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
username := userInfo[0]
|
||||
password := userInfo[1]
|
||||
getStore(dbConfig)
|
||||
// Create the user logic
|
||||
if asAdmin {
|
||||
log.Printf("Creating user as admin: %s\n", username)
|
||||
} else {
|
||||
log.Printf("Creating user: %s\n", username)
|
||||
}
|
||||
newUser := users.User{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
if scope != "" {
|
||||
newUser.Scope = scope
|
||||
}
|
||||
err = storage.CreateUser(newUser, asAdmin)
|
||||
if err != nil {
|
||||
log.Fatal("Could not create user: ", err)
|
||||
}
|
||||
return
|
||||
case "version":
|
||||
fmt.Println("FileBrowser Quantum - A modern web-based file manager")
|
||||
fmt.Printf("Version : %v\n", version.Version)
|
||||
fmt.Printf("Commit : %v\n", version.CommitSHA)
|
||||
fmt.Printf("Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v\n", version.Version)
|
||||
return
|
||||
}
|
||||
}
|
||||
store, dbExists := getStore(configPath)
|
||||
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
|
||||
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.DatabaseV2.Path)
|
||||
if !dbExists {
|
||||
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
|
||||
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.DatabaseV2.Path)
|
||||
}
|
||||
if !settings.Config.Server.DisableUpdateCheck {
|
||||
info, _ := utils.CheckForUpdates()
|
||||
if info.LatestVersion != "" {
|
||||
logger.Infof("A new version is available: %s (current: %s)", info.LatestVersion, info.CurrentVersion)
|
||||
logger.Infof("Release notes: %s", info.ReleaseNotes)
|
||||
}
|
||||
go utils.StartCheckForUpdates()
|
||||
}
|
||||
log.Printf("Initializing FileBrowser Quantum (%v)\n", version.Version)
|
||||
log.Printf("Using Config file : %v", configPath)
|
||||
log.Println("Embeded frontend :", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true")
|
||||
log.Println(database)
|
||||
log.Println("Sources :", settings.Config.Server.Root)
|
||||
|
||||
// Create context and channels for graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
signalChan := make(chan os.Signal, 1)
|
||||
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
done := make(chan struct{}) // Signals server has stopped
|
||||
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
|
||||
|
||||
// Dev mode enables development features like template hot-reloading
|
||||
_, err := os.Stat("internal/web/dist")
|
||||
// In dev mode, always use filesystem assets. Otherwise, check if internal/web/dist exists
|
||||
if !settings.Env.IsDevMode {
|
||||
settings.Env.EmbeddedFs = os.IsNotExist(err)
|
||||
}
|
||||
|
||||
err = utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to set security hash: %v", err)
|
||||
}
|
||||
|
||||
sourceList := []string{}
|
||||
for path, source := range settings.Config.Server.SourceMap {
|
||||
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
|
||||
}
|
||||
logger.Infof("Initializing FileBrowser Quantum (%v)", version.Version)
|
||||
logger.Infof("Using Config file : %v", configPath)
|
||||
logger.Infof("Auth Methods : %v", settings.Config.Auth.AuthMethods)
|
||||
logger.Info(database)
|
||||
logger.Infof("Sources : %v", sourceList)
|
||||
logger.Debugf("Using Embedded FS : %v", settings.Env.EmbeddedFs)
|
||||
walModeStr := "OFF"
|
||||
if settings.Config.Server.IndexSqlConfig.WalMode {
|
||||
walModeStr = "WAL"
|
||||
}
|
||||
logger.Infof("SQL Journal Mode : %v", walModeStr)
|
||||
if settings.Config.Server.CacheDirCleanup {
|
||||
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
serverConfig := settings.Config.Server
|
||||
swagInfo := docs.SwaggerInfo
|
||||
swagInfo.BasePath = serverConfig.BaseURL
|
||||
swagInfo.BasePath = settings.Config.Http.BaseURL
|
||||
swag.Register(docs.SwaggerInfo.InstanceName(), swagInfo)
|
||||
// initialize indexing and schedule indexing ever n minutes (default 5)
|
||||
go files.InitializeIndex(serverConfig.Indexing)
|
||||
if err := rootCMD(store, &serverConfig); err != nil {
|
||||
log.Fatal("Error starting filebrowser:", err)
|
||||
if len(settings.Config.Server.SourceMap) == 0 {
|
||||
logger.Fatal("No sources configured, exiting...")
|
||||
}
|
||||
|
||||
// Initialize shared index database before starting HTTP service
|
||||
isNewDb, err := indexing.InitializeIndexDB()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to initialize index database: %v", err)
|
||||
}
|
||||
|
||||
// Index metadata persistence is wired from app.WireServices during initializeDatabase.
|
||||
if isNewDb {
|
||||
if err := state.ResetAllIndexComplexities(); err != nil {
|
||||
logger.Errorf("Failed to reset index complexities: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
go indexing.Initialize(source, false, isNewDb)
|
||||
}
|
||||
analytics.StartReporter()
|
||||
validateUserInfo(!dbExists)
|
||||
validateOfficeIntegration()
|
||||
validateAccessRules()
|
||||
validateShareInfo()
|
||||
// Start the rootCMD in a goroutine
|
||||
go func() {
|
||||
if err := rootCMD(ctx, &serverConfig, runtimeApp, shutdownComplete); err != nil {
|
||||
logger.Fatalf("Error starting filebrowser: %v", err)
|
||||
}
|
||||
close(done) // Signal that the server has stopped
|
||||
}()
|
||||
// Wait for a shutdown signal or the server to stop
|
||||
select {
|
||||
case <-signalChan:
|
||||
logger.Info("Received shutdown signal. Shutting down gracefully...")
|
||||
cancel() // Trigger context cancellation
|
||||
case <-done:
|
||||
logger.Info("Server stopped unexpectedly. Shutting down...")
|
||||
}
|
||||
|
||||
// Stop all indexing scanners before closing the database
|
||||
indexing.StopAllScanners()
|
||||
|
||||
// Give scanners a moment to finish their current scan operations
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// cleanup temp databases
|
||||
indexDB := indexing.GetIndexDB()
|
||||
if indexDB != nil {
|
||||
indexDB.Close()
|
||||
}
|
||||
if settings.Config.Server.CacheDirCleanup {
|
||||
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
<-shutdownComplete
|
||||
if err := fileutils.ClearDirectoryContents(settings.DownloadCacheDir()); err != nil {
|
||||
logger.Warningf("failed to clear download spool on shutdown: %v", err)
|
||||
}
|
||||
logger.Info("Shutdown complete.")
|
||||
}
|
||||
|
||||
func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
|
||||
func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shutdownComplete chan struct{}) error {
|
||||
if serverConfig.NumImageProcessors < 1 {
|
||||
log.Fatal("Image resize workers count could not be < 1")
|
||||
logger.Fatal("Image resize workers count could not be < 1")
|
||||
}
|
||||
imgSvc := img.New(serverConfig.NumImageProcessors)
|
||||
cacheDir := settings.Config.Server.CacheDir
|
||||
numWorkers := settings.Config.Server.NumImageProcessors
|
||||
|
||||
cacheDir := "/tmp"
|
||||
var fileCache diskcache.Interface
|
||||
|
||||
// Use file cache if cacheDir is specified
|
||||
if cacheDir != "" {
|
||||
var err error
|
||||
fileCache, err = diskcache.NewFileCache(cacheDir)
|
||||
// Initialize asset filesystem before starting services
|
||||
if settings.Env.EmbeddedFs {
|
||||
embeddedAssets := web.GetEmbeddedAssets()
|
||||
subAssets, err := fs.Sub(embeddedAssets, "embed")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create file cache: %v", err)
|
||||
logger.Fatalf("Failed to create sub filesystem: %v", err)
|
||||
}
|
||||
fileutils.InitAssetFS(subAssets, true)
|
||||
} else {
|
||||
// No-op cache if no cacheDir is specified
|
||||
fileCache = diskcache.NewNoOp()
|
||||
fileutils.InitAssetFS(nil, false)
|
||||
}
|
||||
fbhttp.StartHttp(imgSvc, store, fileCache)
|
||||
|
||||
// Start preview service
|
||||
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
|
||||
if err != nil {
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
|
||||
// Generate PWA icons after preview service is initialized
|
||||
if err := icons.GeneratePWAIcons(); err != nil {
|
||||
logger.Warningf("Failed to generate PWA icons: %v", err)
|
||||
}
|
||||
|
||||
// Initialize PWA manifest after icons are generated
|
||||
icons.InitializePWAManifest()
|
||||
|
||||
web.StartHttp(ctx, web.Deps{
|
||||
Store: a.Store,
|
||||
Files: a.Files,
|
||||
Auth: a.Auth,
|
||||
}, shutdownComplete)
|
||||
return nil
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rulesCmd.AddCommand(rulesRmCommand)
|
||||
rulesRmCommand.Flags().Uint("index", 0, "index of rule to remove")
|
||||
_ = rulesRmCommand.MarkFlagRequired("index")
|
||||
}
|
||||
|
||||
var rulesRmCommand = &cobra.Command{
|
||||
Use: "rm <index> [index_end]",
|
||||
Short: "Remove a global rule or user rule",
|
||||
Long: `Remove a global rule or user rule. The provided index
|
||||
is the same that's printed when you run 'rules ls'. Note
|
||||
that after each removal/addition, the index of the
|
||||
commands change. So be careful when removing them after each
|
||||
other.
|
||||
|
||||
You can also specify an optional parameter (index_end) so
|
||||
you can remove all commands from 'index' to 'index_end',
|
||||
including 'index_end'.`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if err := cobra.RangeArgs(1, 2)(cmd, args); err != nil { //nolint:gomnd
|
||||
return err
|
||||
}
|
||||
|
||||
for _, arg := range args {
|
||||
if _, err := strconv.Atoi(arg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
i, err := strconv.Atoi(args[0])
|
||||
utils.CheckErr("strconv.Atoi", err)
|
||||
f := i
|
||||
if len(args) == 2 { //nolint:gomnd
|
||||
f, err = strconv.Atoi(args[1])
|
||||
utils.CheckErr("strconv.Atoi", err)
|
||||
}
|
||||
|
||||
user := func(u *users.User) {
|
||||
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
|
||||
err := store.Users.Save(u)
|
||||
utils.CheckErr("store.Users.Save", err)
|
||||
}
|
||||
|
||||
global := func(s *settings.Settings) {
|
||||
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
|
||||
err := store.Settings.Save(s)
|
||||
utils.CheckErr("store.Settings.Save", err)
|
||||
}
|
||||
|
||||
runRules(store, cmd, user, global)
|
||||
}),
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rulesCmd.PersistentFlags().StringP("username", "u", "", "username of user to which the rules apply")
|
||||
rulesCmd.PersistentFlags().UintP("id", "i", 0, "id of user to which the rules apply")
|
||||
}
|
||||
|
||||
var rulesCmd = &cobra.Command{
|
||||
Use: "rules",
|
||||
Short: "Rules management utility",
|
||||
Long: `On each subcommand you'll have available at least two flags:
|
||||
"username" and "id". You must either set only one of them
|
||||
or none. If you set one of them, the command will apply to
|
||||
an user, otherwise it will be applied to the global set or
|
||||
rules.`,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
|
||||
func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User), globalFn func(*settings.Settings)) {
|
||||
id := getUserIdentifier(cmd.Flags())
|
||||
if id != nil {
|
||||
user, err := st.Users.Get("", id)
|
||||
utils.CheckErr("st.Users.Get", err)
|
||||
|
||||
if usersFn != nil {
|
||||
usersFn(user)
|
||||
}
|
||||
|
||||
printRules(user.Rules, id)
|
||||
return
|
||||
}
|
||||
|
||||
s, err := st.Settings.Get()
|
||||
utils.CheckErr("st.Settings.Get", err)
|
||||
|
||||
if globalFn != nil {
|
||||
globalFn(s)
|
||||
}
|
||||
|
||||
printRules(s.Rules, id)
|
||||
}
|
||||
|
||||
func getUserIdentifier(flags *pflag.FlagSet) interface{} {
|
||||
id := mustGetUint(flags, "id")
|
||||
username := mustGetString(flags, "username")
|
||||
|
||||
if id != 0 {
|
||||
return id
|
||||
} else if username != "" {
|
||||
return username
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printRules(rulez []users.Rule, id interface{}) {
|
||||
|
||||
for id, rule := range rulez {
|
||||
fmt.Printf("(%d) ", id)
|
||||
if rule.Regex {
|
||||
if rule.Allow {
|
||||
fmt.Printf("Allow Regex: \t%s\n", rule.Regexp.Raw)
|
||||
} else {
|
||||
fmt.Printf("Disallow Regex: \t%s\n", rule.Regexp.Raw)
|
||||
}
|
||||
} else {
|
||||
if rule.Allow {
|
||||
fmt.Printf("Allow Path: \t%s\n", rule.Path)
|
||||
} else {
|
||||
fmt.Printf("Disallow Path: \t%s\n", rule.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rulesCmd.AddCommand(rulesAddCmd)
|
||||
rulesAddCmd.Flags().BoolP("allow", "a", false, "indicates this is an allow rule")
|
||||
rulesAddCmd.Flags().BoolP("regex", "r", false, "indicates this is a regex rule")
|
||||
}
|
||||
|
||||
var rulesAddCmd = &cobra.Command{
|
||||
Use: "add <path|expression>",
|
||||
Short: "Add a global rule or user rule",
|
||||
Long: `Add a global rule or user rule.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
allow := mustGetBool(cmd.Flags(), "allow")
|
||||
regex := mustGetBool(cmd.Flags(), "regex")
|
||||
exp := args[0]
|
||||
|
||||
if regex {
|
||||
regexp.MustCompile(exp)
|
||||
}
|
||||
|
||||
rule := users.Rule{
|
||||
Allow: allow,
|
||||
Regex: regex,
|
||||
}
|
||||
|
||||
if regex {
|
||||
rule.Regexp = &users.Regexp{Raw: exp}
|
||||
} else {
|
||||
rule.Path = exp
|
||||
}
|
||||
|
||||
user := func(u *users.User) {
|
||||
u.Rules = append(u.Rules, rule)
|
||||
err := store.Users.Save(u)
|
||||
utils.CheckErr("store.Users.Save", err)
|
||||
}
|
||||
|
||||
global := func(s *settings.Settings) {
|
||||
s.Rules = append(s.Rules, rule)
|
||||
err := store.Settings.Save(s)
|
||||
utils.CheckErr("store.Settings.Save", err)
|
||||
}
|
||||
|
||||
runRules(store, cmd, user, global)
|
||||
}),
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rulesCmd.AddCommand(rulesLsCommand)
|
||||
}
|
||||
|
||||
var rulesLsCommand = &cobra.Command{
|
||||
Use: "ls",
|
||||
Short: "List global rules or user specific rules",
|
||||
Long: `List global rules or user specific rules.`,
|
||||
Args: cobra.NoArgs,
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
runRules(store, cmd, nil, nil)
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// validateShareInfo migrates share links to add default sidebar links
|
||||
func validateShareInfo() {
|
||||
shares, err := state.GetAllShares()
|
||||
if err != nil {
|
||||
logger.Debugf("No shares found or error getting shares: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
migratedCount := 0
|
||||
for _, link := range shares {
|
||||
// Check if this share needs migration (version not set or 0)
|
||||
if link.Version == 0 {
|
||||
hash := link.Hash
|
||||
// Save the updated share
|
||||
if err := state.UpdateShare(hash, func(existingShare *share.Share) error {
|
||||
// Set version to 1 to indicate migration is complete
|
||||
existingShare.Version = 1
|
||||
// Add default sidebar links
|
||||
existingShare.SidebarLinks = []users.SidebarLink{
|
||||
{
|
||||
Name: "Share QR Code and Info",
|
||||
Category: "shareInfo",
|
||||
Target: "#",
|
||||
Icon: "qr_code",
|
||||
},
|
||||
{
|
||||
Name: "Download",
|
||||
Category: "download",
|
||||
Target: "#",
|
||||
Icon: "download",
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.Errorf("Failed to migrate share %s: %v", link.Hash, err)
|
||||
continue
|
||||
}
|
||||
|
||||
migratedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if migratedCount > 0 {
|
||||
logger.Infof("Migrated %d share links with default sidebar links", migratedCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var createBackup = false
|
||||
|
||||
func validateUserInfo(newDB bool) {
|
||||
// update source info for users if names/sources/paths might have changed
|
||||
usersList, err := state.GetAllUsers()
|
||||
if err != nil {
|
||||
logger.Fatalf("could not load users: %v", err)
|
||||
}
|
||||
for i := range usersList {
|
||||
user := &usersList[i]
|
||||
changedFields := make([]string, 0, 8)
|
||||
changePass := false
|
||||
|
||||
scopesChanged := updateUserScopes(user)
|
||||
if scopesChanged {
|
||||
changedFields = append(changedFields, "backendScopes")
|
||||
}
|
||||
if updatePermissions(user) {
|
||||
changedFields = append(changedFields, "permissions", "perm", "version")
|
||||
}
|
||||
if updateSourcePermissions(user) {
|
||||
changedFields = append(changedFields, "backendScopes", "backendSourcePermissions", "version")
|
||||
}
|
||||
if updatePreviewSettings(user) {
|
||||
changedFields = append(changedFields, "preview")
|
||||
}
|
||||
if updateLoginType(user) {
|
||||
changedFields = append(changedFields, "loginMethod")
|
||||
}
|
||||
if updateShowFirstLogin(user) {
|
||||
changedFields = append(changedFields, "showFirstLogin")
|
||||
}
|
||||
if updateSidebarLinks(user, scopesChanged) {
|
||||
changedFields = append(changedFields, "sidebarLinks")
|
||||
}
|
||||
if updateTokens(user) {
|
||||
changedFields = append(changedFields, "tokens", "version")
|
||||
}
|
||||
if normalizeApiTokenPermissions(user) {
|
||||
changedFields = append(changedFields, "tokens")
|
||||
}
|
||||
if state.ApplyEnforcedSyncToUser(user) {
|
||||
changedFields = append(changedFields, settings.UserJSONFieldsForEnforcedSync()...)
|
||||
}
|
||||
if state.ApplyEnforcedSourcePermissionsSyncToUser(user) {
|
||||
changedFields = append(changedFields, "backendScopes", "backendSourcePermissions")
|
||||
}
|
||||
if user.Version < users.ProfileStorageVersion {
|
||||
user.Version = users.ProfileStorageVersion
|
||||
changedFields = append(changedFields, "version")
|
||||
}
|
||||
adminUser := settings.Config.Auth.AdminUsername
|
||||
if adminUser == "" {
|
||||
adminUser = "admin"
|
||||
}
|
||||
if user.Username == adminUser && user.Permissions.Admin {
|
||||
adminPerms := settings.AdminPerms()
|
||||
if user.Permissions.Share != adminPerms.Share || user.Permissions.Api != adminPerms.Api {
|
||||
user.Permissions.Share = adminPerms.Share
|
||||
user.Permissions.Api = adminPerms.Api
|
||||
user.Permissions.Admin = true
|
||||
changedFields = append(changedFields, "permissions")
|
||||
}
|
||||
}
|
||||
if user.Username == adminUser && settings.Config.Auth.AdminPassword != "" && user.LoginMethod == users.LoginMethodPassword {
|
||||
logger.Info("Resetting admin user to default username and password.")
|
||||
user.Permissions = settings.AdminPerms()
|
||||
user.Password = settings.Config.Auth.AdminPassword
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
changedFields = append(changedFields, "permissions", "password", "loginMethod")
|
||||
changePass = true
|
||||
}
|
||||
|
||||
changedFields = dedupeFields(changedFields)
|
||||
if len(changedFields) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
skipCreateBackup := os.Getenv("FILEBROWSER_DISABLE_AUTOMATIC_BACKUP") == "true" || newDB
|
||||
if createBackup && !skipCreateBackup {
|
||||
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
|
||||
err = fileutils.CopyFile(settings.Config.Server.DatabaseV2.Path, fmt.Sprintf("%s.bak", settings.Config.Server.DatabaseV2.Path))
|
||||
if err != nil {
|
||||
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
|
||||
}
|
||||
}
|
||||
plainPass := ""
|
||||
if changePass {
|
||||
plainPass = user.Password
|
||||
}
|
||||
if err := state.UpdateUser(user, plainPass, changedFields...); err != nil {
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dedupeFields(fields []string) []string {
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(field)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, field)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func updateUserScopes(user *users.User) bool {
|
||||
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
|
||||
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
|
||||
user.BackendScopes = newScopes
|
||||
return changed
|
||||
}
|
||||
|
||||
func updateSourcePermissions(user *users.User) bool {
|
||||
changed := false
|
||||
if user.Version < users.SourcePermissionsMigrationVersion {
|
||||
if users.MigrateToSourcePermissions(user) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if users.EnsureSourcePermissionsForScopes(
|
||||
user,
|
||||
settings.DefaultSourceFilePermissions(),
|
||||
settings.AdminSourceFilePermissions(),
|
||||
) {
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func updateShowFirstLogin(user *users.User) bool {
|
||||
if user.ShowFirstLogin && !settings.Env.IsFirstLoad {
|
||||
user.ShowFirstLogin = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// func to convert legacy user with perm key to permissions
|
||||
func updatePermissions(user *users.User) bool {
|
||||
if user.Version >= 1 {
|
||||
return false
|
||||
}
|
||||
updateUser := true
|
||||
user.Permissions.Download = true
|
||||
// if any keys are true, set the permissions to true
|
||||
if user.Perm.Api {
|
||||
user.Permissions.Api = true
|
||||
user.Perm.Api = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Perm.Admin {
|
||||
user.Permissions.Admin = true
|
||||
user.Perm.Admin = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Perm.Modify {
|
||||
user.Permissions.Modify = true
|
||||
user.Perm.Modify = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Perm.Share {
|
||||
user.Permissions.Share = true
|
||||
user.Perm.Share = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Perm.Create {
|
||||
user.Permissions.Create = true
|
||||
user.Perm.Create = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Perm.Create {
|
||||
user.Permissions.Create = true
|
||||
user.Perm.Create = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Perm.Download {
|
||||
user.Permissions.Download = true
|
||||
user.Perm.Download = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Permissions.Modify {
|
||||
user.Permissions.Create = true
|
||||
user.Permissions.Delete = true
|
||||
updateUser = true
|
||||
}
|
||||
user.Version = 2
|
||||
if updateUser {
|
||||
createBackup = true
|
||||
}
|
||||
return updateUser
|
||||
}
|
||||
|
||||
func updateLoginType(user *users.User) bool {
|
||||
if user.LoginMethod == "" {
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func updatePreviewSettings(user *users.User) bool {
|
||||
// if user hasn't been updated yet
|
||||
if user.LoginMethod == "" {
|
||||
user.Preview.Image = true
|
||||
user.Preview.PopUp = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// updateSidebarLinks normalizes saved links and adds defaults for new or changed scopes.
|
||||
func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
|
||||
links, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks)
|
||||
if scopesChanged || user.SidebarLinks == nil {
|
||||
links, changed = usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
|
||||
}
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
user.SidebarLinks = links
|
||||
return true
|
||||
}
|
||||
|
||||
func updateTokens(user *users.User) bool {
|
||||
if user.Version >= 2 {
|
||||
return false
|
||||
}
|
||||
if user.ApiKeys != nil {
|
||||
user.Tokens = make(map[string]users.AuthToken)
|
||||
for name, token := range user.ApiKeys {
|
||||
token.Token = token.Key
|
||||
token.Name = name
|
||||
users.StoreToken(user.Tokens, token)
|
||||
}
|
||||
}
|
||||
user.Version = 2
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeApiTokenPermissions(user *users.User) bool {
|
||||
if user == nil || len(user.Tokens) == 0 {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for name, token := range user.Tokens {
|
||||
if token.Name == "" || name != token.Name {
|
||||
continue
|
||||
}
|
||||
sanitized := users.SanitizeTokenPermissions(token.Permissions)
|
||||
if sanitized != token.Permissions {
|
||||
token.Permissions = sanitized
|
||||
user.Tokens[name] = token
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestUpdateUserScopes_mergesMissingDefaultEnabled(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: "graham"},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: fixturePlaywrightSource, Scope: "/myfolder"},
|
||||
{Path: fixtureDockerSource, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateUserScopes(user) {
|
||||
t.Fatal("expected missing defaultEnabled access source to be merged")
|
||||
}
|
||||
if len(user.BackendScopes) != 3 {
|
||||
t.Fatalf("scopes=%#v want 3 including access", user.BackendScopes)
|
||||
}
|
||||
found := false
|
||||
for _, scope := range user.BackendScopes {
|
||||
if scope.Path == fixtureAccessSource {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("access source should be added for defaultEnabled: %#v", user.BackendScopes)
|
||||
}
|
||||
// Preserve existing scope paths
|
||||
for _, scope := range user.BackendScopes {
|
||||
if scope.Path == fixturePlaywrightSource && scope.Scope != "/myfolder" {
|
||||
t.Fatalf("playwright scope path changed: %#v", scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
settings.Env.IsPlaywright = true
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
settings.Config.Server.DatabaseV2.Path = dbPath
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
|
||||
if err := migrateFromBoltToSQLite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sqlStore.Close()
|
||||
|
||||
graham, err := sqlStore.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if graham.BackendSourcePermissions == nil {
|
||||
t.Fatal("expected backend source permissions after migration")
|
||||
}
|
||||
// Migration fixture itself may omit access; merge happens in updateUserScopes.
|
||||
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; ok {
|
||||
t.Fatalf("graham should not have access permissions from migration alone: %#v", graham.BackendSourcePermissions)
|
||||
}
|
||||
|
||||
updateUserScopes(graham)
|
||||
updateSourcePermissions(graham)
|
||||
updateSidebarLinks(graham, true)
|
||||
|
||||
foundScope := false
|
||||
for _, scope := range graham.BackendScopes {
|
||||
if scope.Path == fixtureAccessSource {
|
||||
foundScope = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundScope {
|
||||
t.Fatalf("graham should gain access scope after merge: %#v", graham.BackendScopes)
|
||||
}
|
||||
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; !ok {
|
||||
t.Fatalf("graham should have access source permissions after merge: %#v", graham.BackendSourcePermissions)
|
||||
}
|
||||
|
||||
foundLink := false
|
||||
for _, link := range usersidebar.FrontendLinks(graham.SidebarLinks, true) {
|
||||
if link.Name == "access" || link.SourceName == fixtureAccessSource {
|
||||
foundLink = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundLink {
|
||||
t.Fatalf("graham should have access sidebar link after merge: %#v", graham.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserScopes_seedsDefaultEnabledWhenEmpty(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: "newuser"},
|
||||
}
|
||||
|
||||
if !updateUserScopes(user) {
|
||||
t.Fatal("expected default-enabled scopes to be seeded")
|
||||
}
|
||||
if len(user.BackendScopes) != 3 {
|
||||
t.Fatalf("scopes=%#v want all default-enabled sources", user.BackendScopes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestUpdateSidebarLinks_dedupesDuplicateMigratedLinks(t *testing.T) {
|
||||
settingsConfig := "../../_docker/src/settings/backend/config.yaml"
|
||||
settings.Initialize(settingsConfig)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
{Name: "playwright + files", Category: "source", SourceName: "/app/frontend/tests/playwright-files", Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: "/app/backend", Target: "/"},
|
||||
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
|
||||
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
|
||||
},
|
||||
},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: "../frontend/tests/playwright-files", Scope: "/"},
|
||||
{Path: ".", Scope: "/"},
|
||||
{Path: "/tests/playwright-files", Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: fixturePlaywrightSource, Scope: "/"},
|
||||
{Path: fixtureDockerSource, Scope: "/"},
|
||||
{Path: fixtureAccessSource, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSidebarLinks_keepsDeletedSourceLinkAbsent(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
{Name: "playwright + files", Category: "source", SourceName: fixturePlaywrightSource, Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: fixtureDockerSource, Target: "/"},
|
||||
},
|
||||
},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: fixturePlaywrightSource, Scope: "/"},
|
||||
{Path: fixtureDockerSource, Scope: "/"},
|
||||
{Path: fixtureAccessSource, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected default source names to be blanked on normalize")
|
||||
}
|
||||
if len(user.SidebarLinks) != 2 {
|
||||
t.Fatalf("got %d sidebar links, want 2: %v", len(user.SidebarLinks), user.SidebarLinks)
|
||||
}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if link.Name != "" {
|
||||
t.Fatalf("expected blank default name, got %#v", link)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
|
||||
sourceA := settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
|
||||
DefaultEnabled: false,
|
||||
DefaultUserScope: "/defaultA",
|
||||
}}
|
||||
sourceB := settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
|
||||
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
"/pathA": &sourceA,
|
||||
"/pathB": &sourceB,
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"sourceA": &sourceA,
|
||||
"sourceB": &sourceB,
|
||||
}
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
// ---------------------
|
||||
// Test Cases Definition
|
||||
// ---------------------
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *users.User
|
||||
expectedPhase1 []users.BackendScope
|
||||
expectedPhase2 []users.BackendScope
|
||||
}{
|
||||
{
|
||||
name: "Single valid scope",
|
||||
user: &users.User{
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Scope: "/home", Path: "/pathA"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/home", Path: "/pathA"},
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
},
|
||||
// Phase2: both already present after phase1 merge.
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/home", Path: "/pathA"},
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Single empty scope path",
|
||||
user: &users.User{
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Scope: "", Path: "/pathB"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
},
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/defaultA", Path: "/pathA"},
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Two scopes, one includes username in path",
|
||||
user: &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: "user123"},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Scope: "/home/user123", Path: "/pathA"},
|
||||
{Scope: "/data", Path: "/pathB"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/home/user123", Path: "/pathA"},
|
||||
{Scope: "/data", Path: "/pathB"},
|
||||
},
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/home/user123", Path: "/pathA"},
|
||||
{Scope: "/data", Path: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Two scopes, one with empty name",
|
||||
user: &users.User{
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Scope: "/home", Path: "/pathB"},
|
||||
{Scope: "/data", Path: "/somethingElse"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/home", Path: "/pathB"},
|
||||
{Scope: "/data", Path: "/somethingElse"},
|
||||
},
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/defaultA", Path: "/pathA"},
|
||||
{Scope: "/home", Path: "/pathB"},
|
||||
{Scope: "/data", Path: "/somethingElse"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "No scopes at all",
|
||||
user: &users.User{
|
||||
BackendScopes: []users.BackendScope{},
|
||||
},
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
},
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/defaultA", Path: "/pathA"},
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All user Scope and source change",
|
||||
user: &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: "user123"},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Scope: "/defaultC/user123", Path: "/pathC"},
|
||||
{Scope: "/defaultA/user123", Path: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Path: "/pathB"},
|
||||
},
|
||||
},
|
||||
// updateUserScopes emits configured sources first (Sources order), then unknown paths.
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/defaultA/user123", Path: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Path: "/pathB"},
|
||||
{Scope: "/defaultC/user123", Path: "/pathC"},
|
||||
},
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/defaultA/user123", Path: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Path: "/pathB"},
|
||||
{Scope: "/defaultC/user123", Path: "/pathC"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Phase 1 Tests
|
||||
// ---------------------
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name+"_Phase1", func(t *testing.T) {
|
||||
originalScopes := tc.user.BackendScopes
|
||||
updated := updateUserScopes(tc.user)
|
||||
assert.Equal(t, tc.expectedPhase1, tc.user.BackendScopes, "Phase1 scope mismatch for test case: %s", tc.name)
|
||||
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.BackendScopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.BackendScopes, tc.expectedPhase1)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Phase 2: rename sources but keep same paths
|
||||
// ---------------------
|
||||
sourceA = settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
|
||||
DefaultEnabled: true,
|
||||
DefaultUserScope: "/defaultA",
|
||||
}}
|
||||
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
|
||||
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
"/pathA": &sourceA,
|
||||
"/pathB": &sourceB,
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"sourceA": &sourceA,
|
||||
"sourceB": &sourceB,
|
||||
}
|
||||
|
||||
// Run again without resetting user objects to test idempotency + renaming
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name+"_Phase2", func(t *testing.T) {
|
||||
originalScopes := tc.user.BackendScopes
|
||||
updated := updateUserScopes(tc.user)
|
||||
assert.Equal(t, tc.expectedPhase2, tc.user.BackendScopes, "Phase2 scope mismatch for test case: %s", tc.name)
|
||||
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.BackendScopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.BackendScopes, tc.expectedPhase1)
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
)
|
||||
|
||||
var usersCmd = &cobra.Command{
|
||||
Use: "users",
|
||||
Short: "Users management utility",
|
||||
Long: `Users management utility.`,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
|
||||
func printUsers(usrs []*users.User) {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) //nolint:gomnd
|
||||
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
|
||||
|
||||
for _, u := range usrs {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
|
||||
u.ID,
|
||||
u.Username,
|
||||
u.Scope,
|
||||
u.Locale,
|
||||
u.ViewMode,
|
||||
u.SingleClick,
|
||||
u.Perm.Admin,
|
||||
u.Perm.Execute,
|
||||
u.Perm.Create,
|
||||
u.Perm.Rename,
|
||||
u.Perm.Modify,
|
||||
u.Perm.Delete,
|
||||
u.Perm.Share,
|
||||
u.Perm.Download,
|
||||
u.LockPassword,
|
||||
)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func parseUsernameOrID(arg string) (username string, id uint) {
|
||||
id64, err := strconv.ParseUint(arg, 10, 64)
|
||||
if err != nil {
|
||||
return arg, 0
|
||||
}
|
||||
return "", uint(id64)
|
||||
}
|
||||
Loaded 100 of 1028 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user