mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-08 10:25:32 -05:00
Compare commits
213
Commits
v0.3.2
...
v1.4.2-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a997d4605 | ||
|
|
2633768d9f | ||
|
|
4bca80a156 | ||
|
|
fc929970b3 | ||
|
|
12c71d38a9 | ||
|
|
641e4c5397 | ||
|
|
587cd62da6 | ||
|
|
ea19302c25 | ||
|
|
d4dd0c70f4 | ||
|
|
1babfa03bc | ||
|
|
daa48c3c31 | ||
|
|
a9e4fc27b0 | ||
|
|
1715577d99 | ||
|
|
5eb4dc7f7c | ||
|
|
4248849bb6 | ||
|
|
687dc01293 | ||
|
|
bfde65a41e | ||
|
|
c5eeb308fa | ||
|
|
12cd81340e | ||
|
|
2b532c368d | ||
|
|
28e9b81e43 | ||
|
|
6d4bffd407 | ||
|
|
5e49e1ecdd | ||
|
|
1aba10b87f | ||
|
|
15f484fba5 | ||
|
|
d4eb051294 | ||
|
|
1802e12811 | ||
|
|
5597b28f10 | ||
|
|
0f215333ee | ||
|
|
6d81d1973c | ||
|
|
869b640a99 | ||
|
|
3b8d179774 | ||
|
|
569314cdf4 | ||
|
|
6bfc397419 | ||
|
|
112740bdd4 | ||
|
|
9117fb2b5f | ||
|
|
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": "20"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -1,4 +1,12 @@
|
||||
backend/**.db
|
||||
**.crt
|
||||
**.key
|
||||
backend/tmp
|
||||
backend/dist
|
||||
backend/backend*
|
||||
backend/http/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-latest
|
||||
|
||||
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'!"
|
||||
+13
-27
@@ -6,41 +6,27 @@ on:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
push_latest_to_registry:
|
||||
name: Push latest
|
||||
test_playwright:
|
||||
name: Test Playwright
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Find latest tag
|
||||
run: |
|
||||
echo "LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`)" >> $GITHUB_ENV
|
||||
echo "latest tag is $LATEST_TAG"
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
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@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
|
||||
+115
-21
@@ -1,30 +1,129 @@
|
||||
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
|
||||
prime_docker_cache:
|
||||
name: Prime docker cache
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Build
|
||||
uses: docker/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-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- 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/http/dist
|
||||
build_backend:
|
||||
name: Build backend
|
||||
runs-on: ubuntu-latest
|
||||
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-latest
|
||||
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/http/embed
|
||||
cp -r frontend/dist/* backend/http/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
|
||||
name: Build docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -33,24 +132,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 Beta to Stable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to promote (format: 0.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
promote-beta-to-stable:
|
||||
name: Promote Beta to Stable
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }} # Uses Personal Access Token for branch operations
|
||||
|
||||
- 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 beta branch 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' does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Beta branch '$BETA_BRANCH' exists."
|
||||
|
||||
- name: Check if stable branch already exists
|
||||
id: check_stable_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
if git ls-remote --exit-code origin "$STABLE_BRANCH"; then
|
||||
echo "❌ ERROR: Stable branch '$STABLE_BRANCH' already exists!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Stable branch '$STABLE_BRANCH' does not exist."
|
||||
|
||||
- name: Create and push new stable branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
# Fetch latest changes
|
||||
git fetch origin "$BETA_BRANCH"
|
||||
|
||||
# Create new stable branch from beta branch
|
||||
git checkout -b "$STABLE_BRANCH" origin/"$BETA_BRANCH"
|
||||
|
||||
# Push new stable branch to remote
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully created stable branch '$STABLE_BRANCH' from '$BETA_BRANCH'."
|
||||
|
||||
- name: Merge beta into stable
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
# Checkout stable branch
|
||||
git checkout "$STABLE_BRANCH"
|
||||
|
||||
# Merge beta into stable
|
||||
git merge --no-ff "origin/$BETA_BRANCH" -m "Merge $BETA_BRANCH into $STABLE_BRANCH"
|
||||
|
||||
# Push merge changes
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
|
||||
@@ -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-latest
|
||||
|
||||
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,9 +1,9 @@
|
||||
name: dev tests
|
||||
name: regular tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
@@ -14,18 +14,19 @@ jobs:
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23.3'
|
||||
- uses: golangci/golangci-lint-action@v5
|
||||
go-version: 'stable'
|
||||
- uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: v1.60
|
||||
version: 'v2.1.6'
|
||||
working-directory: backend
|
||||
install-mode: goinstall
|
||||
format-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -49,3 +50,19 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run test
|
||||
check-translations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run i18n:check
|
||||
check-docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- run: make generate-docs
|
||||
- run: git diff --exit-code
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,66 @@
|
||||
name: beta release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "beta/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright - regular
|
||||
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
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
create_release_tag:
|
||||
needs: [ test_playwright ]
|
||||
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: |
|
||||
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
|
||||
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
|
||||
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
id: extract_branch
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: true
|
||||
make_latest: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,66 @@
|
||||
name: stable release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "stable/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright - regular
|
||||
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
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
create_release_tag:
|
||||
needs: [ test_playwright ]
|
||||
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: |
|
||||
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
|
||||
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
|
||||
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
id: extract_branch
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
+174
-4
@@ -1,9 +1,9 @@
|
||||
name: tag
|
||||
name: tag update
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -20,8 +20,9 @@ jobs:
|
||||
- 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 +30,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-latest
|
||||
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
|
||||
+28
-4
@@ -1,19 +1,37 @@
|
||||
*.db
|
||||
*.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/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/http/embed
|
||||
/backend/debug
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
@@ -37,3 +55,9 @@ yarn-error.log*
|
||||
*.sw*
|
||||
bin/
|
||||
build/
|
||||
*__debug*
|
||||
backend/custom.css
|
||||
|
||||
# Runtime cache directories created by tests/application
|
||||
backend/preview/heic/
|
||||
backend/preview/thumbnails/
|
||||
+1812
-1
File diff suppressed because it is too large.
Load diff
+208
@@ -0,0 +1,208 @@
|
||||
# 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 20.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
|
||||
- **Docker** (optional, for containerized development)
|
||||
- **Git** especially improtant on windows -- needed for bash support.
|
||||
|
||||
### Optional Tools
|
||||
|
||||
- **ffmpeg**: 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/http/` - API routes, middleware, auth
|
||||
- **Storage**: BoltDB via `backend/database/storage/`
|
||||
- **Authentication**: Multiple providers in `backend/auth/`
|
||||
- **Indexing**: Real-time search in `backend/indexing/`
|
||||
- **Previews**: Image/video/document generation in `backend/preview/`
|
||||
|
||||
### 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.
|
||||
|
||||
### Coverage & Performance
|
||||
```bash
|
||||
cd backend
|
||||
./run_check_coverage.sh # Coverage report with HTML output
|
||||
./run_benchmark.sh # Benchmarks
|
||||
```
|
||||
|
||||
**Code Coverage:**
|
||||
- View report: Open `backend/coverage.html` after running coverage script
|
||||
- CI enforces coverage for critical packages
|
||||
- Use `go test -cover` for quick package coverage
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Check the [Project Roadmap](https://github.com/users/gtsteffaniak/projects/4/views/2) to see issues sorted by priority. This helps you understand what features are planned and where you can contribute most effectively.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Build failures:**
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && rm -rf node_modules && npm install
|
||||
|
||||
# Backend
|
||||
cd backend && go mod tidy && go clean -modcache
|
||||
```
|
||||
|
||||
**Authentication Issues:**
|
||||
|
||||
Always first enable debug logging in your config file if you have issues.
|
||||
|
||||
1. **OIDC Login Fails:**
|
||||
- Check redirect URLs match your config
|
||||
- Verify OIDC provider settings
|
||||
- Enable debug logs to see auth flow
|
||||
- Common issue: mismatched callback URLs
|
||||
|
||||
2. **Proxy Auth Not Working:**
|
||||
- Verify header names match config
|
||||
- Check nginx/reverse proxy passes headers
|
||||
- Test with: `curl -H "X-Auth: username" localhost:8080`
|
||||
- Enable `--log-level debug` to see headers
|
||||
|
||||
3. **TOTP Issues:**
|
||||
- Ensure server time is synchronized
|
||||
- Check QR code generation in logs
|
||||
- Test with authenticator app time settings
|
||||
- Database might have stale TOTP secrets
|
||||
|
||||
4. **Session/Cookie Problems:**
|
||||
- Clear browser cookies and localStorage
|
||||
- Verify cookie domain settings
|
||||
- Try incognito/private browsing mode
|
||||
|
||||
### Getting Help
|
||||
|
||||
- **Wiki**: [Project Wiki](https://github.com/gtsteffaniak/filebrowser/wiki)
|
||||
- **Issues**: [GitHub Issues](https://github.com/gtsteffaniak/filebrowser/issues)
|
||||
- **PR Template**: See `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
-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,138 @@
|
||||
<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://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
|
||||
[](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" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" title="Logo">
|
||||
<h3>FileBrowser Quantum</h3>
|
||||
The best free self-hosted web-based file manager.
|
||||
<br/><br/>
|
||||
<img width="800" src="https://github.com/user-attachments/assets/162d7a95-33b7-49bd-976c-dd6822c0d22b">
|
||||
</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.
|
||||
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
|
||||
|
||||
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: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
## 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
|
||||
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
|
||||
|
||||
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. ✅ Ultra-efficient Sqlite [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing)
|
||||
- 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
|
||||
- Better thumbnail support including **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.
|
||||
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. ✅ Directory-level access control that can be scoped to user or group.
|
||||
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.
|
||||
3. (Right) The view change toggle.
|
||||
|
||||
All other functions are moved either into the action menu or popup menus.
|
||||
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">
|
||||
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
|
||||
</p>
|
||||
|
||||
## 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" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
|
||||
--- | --- | --- | --- | --- | --- | --- |
|
||||
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
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 | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
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,49 @@
|
||||
FROM gtstef/ffmpeg:8.1-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
|
||||
ENV CGO_ENABLED=1
|
||||
RUN go build -tags mupdf,musl -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
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 npm i --maxsockets 1
|
||||
COPY ./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="/home/filebrowser/data/database.db"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
|
||||
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 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
RUN [ "ffmpeg", "-version" ]
|
||||
RUN [ "ffprobe", "-version" ]
|
||||
RUN [ "exiftool", "-ver" ]
|
||||
# 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,23 @@
|
||||
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 ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/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 set -u testuser1,testuser1
|
||||
RUN ./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 ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/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,14 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/", "./" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
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 ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/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 ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/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 ./http/dist
|
||||
COPY [ "./_docker/src/oidc/backend/", "./"]
|
||||
COPY ["./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/http/embed/", "./http/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 ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/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 ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/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,15 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/", "./" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/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 ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser set -u testuser1,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 ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/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,41 @@
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add
|
||||
RUN go build -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
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 npm i
|
||||
COPY ./frontend/ ./
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
|
||||
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 [ "/app/dist/", "./http/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-local
|
||||
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,18 @@
|
||||
server:
|
||||
<<: *server-main
|
||||
frontend:
|
||||
<<: *frontend-main
|
||||
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
models: true
|
||||
office: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
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,18 @@
|
||||
server-main: &server-main
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: 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,49 @@
|
||||
server:
|
||||
port: 8080
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: 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:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: false
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: true
|
||||
api: false
|
||||
download: true
|
||||
create: 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,66 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "exclude"
|
||||
config:
|
||||
defaultEnabled: 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
|
||||
conditionals:
|
||||
rules:
|
||||
- fileStartsWith: "startsWith"
|
||||
- folderEndsWith: "Folder"
|
||||
- folderNames: 'folderNames'
|
||||
- fileNames: 'fileNames'
|
||||
- folderName: 'folderName'
|
||||
- fileName: 'fileName'
|
||||
- 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:
|
||||
showHidden: true
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: 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,48 @@
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: 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
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: false
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: false
|
||||
api: false
|
||||
download: true
|
||||
create: true
|
||||
@@ -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,36 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
|
||||
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:
|
||||
showHidden: true
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
office: true
|
||||
models: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: 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,48 @@
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/subpath"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: 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:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: false
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: true
|
||||
api: false
|
||||
download: true
|
||||
create: 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,11 @@
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
@@ -0,0 +1,65 @@
|
||||
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/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,41 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
- 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
|
||||
@@ -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,26 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
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,17 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=version'\" ."
|
||||
bin = "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", "http/dist", "http/embed", "swagger/docs", "preview/thumbnails", "preview/heic"]
|
||||
log = "air.log"
|
||||
delay = 1000 # ms
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
@@ -0,0 +1,21 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/filebrowser.exe --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=version'\" ."
|
||||
bin = "tmp/filebrowser.exe"
|
||||
args_bin = ["-c", "test_config.yaml"]
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", "http\\dist", "http\\embed", "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$
|
||||
+80
-13
@@ -3,28 +3,68 @@ project_name: filebrowser
|
||||
version: 2
|
||||
|
||||
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:
|
||||
- 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/common/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/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:
|
||||
- mupdf
|
||||
- musl
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux
|
||||
- id: linux-arm32
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
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
|
||||
- CC=zig cc -target x86_64-windows
|
||||
tags:
|
||||
- mupdf
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
@@ -32,12 +72,39 @@ 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
|
||||
goos:
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
|
||||
# Build configuration for macos without upx
|
||||
- id: macos
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
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
|
||||
|
||||
File renamed without changes.
@@ -2,7 +2,7 @@ package diskcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1" //nolint:gosec
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Cache interface for caching operations
|
||||
@@ -29,12 +32,61 @@ type FileCache struct {
|
||||
}
|
||||
}
|
||||
|
||||
// NewFileCache creates a new FileCache
|
||||
func NewFileCache(dir string) (*FileCache, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("can't make directory %s: %v", dir, err)
|
||||
cacheDir := filepath.Join(dir, "diskcache")
|
||||
|
||||
// Migrate existing cache files from old structure (if any)
|
||||
if err := migrateOldCacheStructure(dir, cacheDir); err != nil {
|
||||
logger.Warningf("failed to migrate old cache structure: %v\n", err)
|
||||
}
|
||||
return &FileCache{dir: dir}, nil
|
||||
|
||||
// Create the cache directory immediately with proper permissions
|
||||
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
return &FileCache{dir: cacheDir}, nil
|
||||
}
|
||||
|
||||
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
|
||||
func migrateOldCacheStructure(oldDir, newDir string) error {
|
||||
// Read the old cache directory
|
||||
entries, err := os.ReadDir(oldDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // Directory doesn't exist yet, nothing to migrate
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
migrated := 0
|
||||
for _, entry := range entries {
|
||||
// Only migrate single-character directories (cache hash directories like a, b, c, etc.)
|
||||
if !entry.IsDir() || len(entry.Name()) != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if it's already the diskcache directory
|
||||
if entry.Name() == "d" && filepath.Join(oldDir, entry.Name()) == newDir {
|
||||
continue
|
||||
}
|
||||
|
||||
oldPath := filepath.Join(oldDir, entry.Name())
|
||||
newPath := filepath.Join(newDir, entry.Name())
|
||||
|
||||
// Create parent directory in new location
|
||||
if err := os.MkdirAll(newDir, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Move the directory
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
// If rename fails (e.g., cross-device), skip this one
|
||||
continue
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
@@ -43,11 +95,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
defer mu.Unlock()
|
||||
|
||||
fileName := f.getFileName(key)
|
||||
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fileName, value, 0600); err != nil {
|
||||
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -109,7 +161,7 @@ func (f *FileCache) getScopedLocks(key string) (lock sync.Locker) {
|
||||
}
|
||||
|
||||
func (f *FileCache) getFileName(key string) string {
|
||||
hasher := sha1.New() //nolint:gosec
|
||||
hasher := sha1.New()
|
||||
_, _ = hasher.Write([]byte(key))
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
return filepath.Join(f.dir, fmt.Sprintf("%s/%s/%s", hash[:1], hash[1:3], hash))
|
||||
+8
-3
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,9 @@ func TestFileCache(t *testing.T) {
|
||||
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
|
||||
)
|
||||
|
||||
// Set up file permissions before creating cache
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
|
||||
// Create temporary directory for the cache
|
||||
cacheDir, err := os.MkdirTemp("", cacheRoot)
|
||||
require.NoError(t, err)
|
||||
@@ -28,19 +32,20 @@ func TestFileCache(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// store new key
|
||||
// Note: NewFileCache creates a "diskcache" subdirectory, so the actual path includes it
|
||||
err = cache.Store(ctx, key, []byte(value))
|
||||
require.NoError(t, err)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
|
||||
|
||||
// update existing key
|
||||
err = cache.Store(ctx, key, []byte(newValue))
|
||||
require.NoError(t, err)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
|
||||
|
||||
// delete key
|
||||
err = cache.Delete(ctx, key)
|
||||
require.NoError(t, err)
|
||||
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
|
||||
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
|
||||
require.False(t, exists)
|
||||
}
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,138 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetContent_UTF8Truncation(t *testing.T) {
|
||||
// Get the path to the test file
|
||||
// The test file is in frontend/tests/playwright-files/utf8-truncated.txt
|
||||
// We need to find it relative to the test execution directory
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Navigate from backend/adapters/fs/files to the project root
|
||||
// backend/adapters/fs/files -> backend/adapters/fs -> backend/adapters -> backend -> root
|
||||
testFilePath := filepath.Join(cwd, "..", "..", "..", "..", "frontend", "tests", "playwright-files", "utf8-truncated.txt")
|
||||
|
||||
// Check if file exists, if not try alternative path
|
||||
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
|
||||
// Try from project root
|
||||
testFilePath = filepath.Join("frontend", "tests", "playwright-files", "utf8-truncated.txt")
|
||||
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
|
||||
t.Skipf("Test file not found at %s, skipping test", testFilePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get absolute path
|
||||
var absPath string
|
||||
absPath, err = filepath.Abs(testFilePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("file with UTF-8 truncation at 4096 byte boundary", func(t *testing.T) {
|
||||
// The test file is longer than 4096 bytes, with valid UTF-8 throughout.
|
||||
// However, when reading exactly 4096 bytes as a header, it cuts off in the
|
||||
// middle of a multi-byte UTF-8 sequence (e6 9c, missing the last byte 88 of '月').
|
||||
// This should trigger the truncation handling in getContent's header validation.
|
||||
// The fix should trim the incomplete sequence from the header, allowing the
|
||||
// header check to pass, and then the full file (which is valid UTF-8) should
|
||||
// pass the full file validation.
|
||||
content, err := getContent(absPath)
|
||||
|
||||
// Should not return an error - the header truncation is handled, and the full file is valid
|
||||
require.NoError(t, err)
|
||||
|
||||
// The content should be the full valid UTF-8 file
|
||||
require.NotEmpty(t, content, "Content should not be empty - full file is valid UTF-8")
|
||||
|
||||
// Verify it contains the expected text
|
||||
require.Contains(t, content, "文件已备份", "Content should contain Chinese characters")
|
||||
require.Contains(t, content, "2024年", "Content should contain date information")
|
||||
})
|
||||
|
||||
t.Run("regular UTF-8 text file", func(t *testing.T) {
|
||||
// Test with a simple text file to ensure normal files still work
|
||||
tmpFile, err := os.CreateTemp("", "test-utf8-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
testText := "Hello, 世界! This is a test file with UTF-8 characters.\n"
|
||||
_, err = tmpFile.WriteString(testText)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
content, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testText, content)
|
||||
})
|
||||
|
||||
t.Run("file smaller than header size", func(t *testing.T) {
|
||||
// Test with a file smaller than 4096 bytes
|
||||
tmpFile, err := os.CreateTemp("", "test-small-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
testText := "Small file content"
|
||||
_, err = tmpFile.WriteString(testText)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
content, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testText, content)
|
||||
})
|
||||
|
||||
t.Run("file with Chinese characters at boundary", func(t *testing.T) {
|
||||
// Create a file that's exactly 4094 bytes, ending with a complete Chinese character
|
||||
tmpFile, err := os.CreateTemp("", "test-chinese-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
// Create content that's exactly 4094 bytes, ending with complete UTF-8
|
||||
baseText := "2024年 06月 17日 星期一 04:05:58 CST 文件已备份\n"
|
||||
content := ""
|
||||
for len([]byte(content)) < 4094 {
|
||||
content += baseText
|
||||
}
|
||||
// Trim to exactly 4094 bytes
|
||||
encoded := []byte(content)
|
||||
if len(encoded) > 4094 {
|
||||
encoded = encoded[:4094]
|
||||
}
|
||||
// Ensure it ends with a complete character by finding the last complete rune
|
||||
for len(encoded) > 0 {
|
||||
lastRune, _ := decodeLastRune(encoded)
|
||||
if lastRune != 0xFFFD { // RuneError
|
||||
break
|
||||
}
|
||||
encoded = encoded[:len(encoded)-1]
|
||||
}
|
||||
|
||||
_, err = tmpFile.Write(encoded)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
result, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result)
|
||||
require.Equal(t, string(encoded), result)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to check if last rune is valid
|
||||
func decodeLastRune(p []byte) (rune, int) {
|
||||
if len(p) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
r, size := utf8.DecodeLastRune(p)
|
||||
return r, size
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func Test_GetRealPath(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
trimPrefix := filepath.Dir(filepath.Dir(cwd))
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string
|
||||
want struct {
|
||||
path string
|
||||
isDir bool
|
||||
}
|
||||
}{
|
||||
{
|
||||
name: "current directory",
|
||||
paths: []string{
|
||||
"./",
|
||||
},
|
||||
want: struct {
|
||||
path string
|
||||
isDir bool
|
||||
}{
|
||||
path: "",
|
||||
isDir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "current directory",
|
||||
paths: []string{
|
||||
"./files/file.go",
|
||||
},
|
||||
want: struct {
|
||||
path string
|
||||
isDir bool
|
||||
}{
|
||||
path: "/files/file.go",
|
||||
isDir: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "other test case",
|
||||
paths: []string{
|
||||
"/mnt/doesnt/exist",
|
||||
},
|
||||
want: struct {
|
||||
path string
|
||||
isDir bool
|
||||
}{
|
||||
path: "/mnt/doesnt/exist",
|
||||
isDir: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
idx := indexing.Index{
|
||||
Source: settings.Source{
|
||||
Path: trimPrefix,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
realPath, isDir, _ := idx.GetRealPath(tt.paths...)
|
||||
adjustedRealPath := strings.TrimPrefix(realPath, trimPrefix)
|
||||
if tt.want.path != adjustedRealPath || tt.want.isDir != isDir {
|
||||
t.Errorf("expected %v:%v but got: %v:%v", tt.want.path, tt.want.isDir, adjustedRealPath, isDir)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: PUT / save must not os.Chmod to PermFile for existing files, or +x (and other mode bits) are lost (#2309).
|
||||
func TestWriteFilePreservesExecutableBit(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
originalCache := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
|
||||
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_writefile_exec", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
scriptName := "backupscript.sh"
|
||||
scriptAbs := filepath.Join(root, scriptName)
|
||||
if err := os.WriteFile(scriptAbs, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName := "test_writefile_exec"
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: sourceName,
|
||||
Path: root,
|
||||
}, false, false)
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
stopPoll := startScannerStatusPoll(t, idx, 1)
|
||||
defer stopPoll()
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
indexPath := "/" + scriptName
|
||||
if err := WriteFile(sourceName, indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
st, err := os.Stat(scriptAbs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode()&0o111 == 0 {
|
||||
t.Fatalf("executable bits cleared after save: mode=%v", st.Mode())
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
}
|
||||
|
||||
func TestWriteFileRejectsExistingDirectory(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
originalCache := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
|
||||
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_writefile_isdir", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
dirName := "folder"
|
||||
if err := os.Mkdir(filepath.Join(root, dirName), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName := "test_writefile_isdir"
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: sourceName,
|
||||
Path: root,
|
||||
}, false, false)
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
stopPoll := startScannerStatusPoll(t, idx, 1)
|
||||
defer stopPoll()
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
err := WriteFile(sourceName, "/"+dirName, strings.NewReader("file content"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when path is an existing directory")
|
||||
}
|
||||
if !errors.Is(err, liberrors.ErrIsDirectory) {
|
||||
t.Fatalf("expected ErrIsDirectory, got: %v", err)
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
}
|
||||
|
||||
func TestSortItems(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input iteminfo.FileInfo
|
||||
expected iteminfo.FileInfo
|
||||
}{
|
||||
{
|
||||
name: "Numeric and Lexicographical Sorting",
|
||||
input: iteminfo.FileInfo{
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "10.txt"},
|
||||
{Name: "2.txt"},
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
|
||||
},
|
||||
},
|
||||
expected: iteminfo.FileInfo{
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "2.txt"},
|
||||
{Name: "10.txt"},
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Only Lexicographical Sorting",
|
||||
input: iteminfo.FileInfo{
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "dog.txt"},
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "apple"},
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
|
||||
},
|
||||
},
|
||||
expected: iteminfo.FileInfo{
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "apple"},
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "dog.txt"},
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.input.SortItems()
|
||||
|
||||
getFolderNames := func(items []iteminfo.ItemInfo) []string {
|
||||
names := []string{}
|
||||
for _, folder := range items {
|
||||
names = append(names, folder.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
getFileNames := func(items []iteminfo.ExtendedItemInfo) []string {
|
||||
names := []string{}
|
||||
for _, file := range items {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
actualFolderNames := getFolderNames(test.input.Folders)
|
||||
expectedFolderNames := getFolderNames(test.expected.Folders)
|
||||
|
||||
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
|
||||
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
|
||||
}
|
||||
|
||||
actualFileNames := getFileNames(test.input.Files)
|
||||
expectedFileNames := getFileNames(test.expected.Files)
|
||||
|
||||
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
|
||||
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFilesCacheClearing was removed because cache clearing is not performed
|
||||
// per design decision - RealPathCache is auxiliary and doesn't need clearing
|
||||
|
||||
func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
// Use a temporary directory for cache to avoid creating directories in the source tree
|
||||
tmpDir := t.TempDir()
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = tmpDir
|
||||
defer func() {
|
||||
settings.Config.Server.CacheDir = originalCacheDir
|
||||
}()
|
||||
|
||||
// Ensure fileutils permissions are set (needed by NewTempDB)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Initialize the database first (use test helper to avoid permission issues)
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
// Initialize the index with scanning disabled to prevent background scanner interference
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: "/mock/path",
|
||||
}, true, false) // true for mock mode, false for isNewDb
|
||||
|
||||
// Get the index and set up mock data
|
||||
idx := indexing.GetIndex("test")
|
||||
if idx == nil { //nolint:staticcheck // t.Fatal terminates execution
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Wait for initial scanner to complete (it will fail on /mock/path but needs to finish cleanup)
|
||||
// This ensures batchItems is nil before we start testing
|
||||
for i := 0; i < 50; i++ { // Wait up to 500ms
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
status := idx.GetScannerStatus()
|
||||
if status["status"] == "ready" || status["status"] == "unavailable" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock directory structure using UpdateMetadata
|
||||
rootInfo := &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
Type: "directory",
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "Test Object", Type: "directory"},
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Delete the old directory item from the database before replacing it with a file
|
||||
idx.DeleteMetadata("/Test Object", true, false)
|
||||
|
||||
// Simulate the directory-to-file override by updating the mock data
|
||||
// Remove the directory from the parent's Folders slice
|
||||
rootInfo.Folders = []iteminfo.ItemInfo{} // Clear folders
|
||||
|
||||
// Add the file to the parent's Files slice
|
||||
rootInfo.Files = []iteminfo.ExtendedItemInfo{
|
||||
{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Size: 25, // Length of "This is test file content"
|
||||
},
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Verify the directory was replaced with a file in the mock data
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
|
||||
// Check that the directory was removed from Folders
|
||||
foundDir := false
|
||||
for _, folder := range rootInfo.Folders {
|
||||
if folder.Name == "Test Object" {
|
||||
foundDir = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundDir {
|
||||
t.Error("Directory 'Test Object' should have been removed from Folders")
|
||||
}
|
||||
|
||||
// Check that the file was added to Files
|
||||
foundFile := false
|
||||
for _, file := range rootInfo.Files {
|
||||
if file.Name == "Test Object" {
|
||||
foundFile = true
|
||||
if file.Size != 25 {
|
||||
t.Errorf("Expected file size 25, got %d", file.Size)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundFile {
|
||||
t.Error("File 'Test Object' should have been added to Files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverrideFileToDirectory(t *testing.T) {
|
||||
// Use a temporary directory for cache to avoid creating directories in the source tree
|
||||
tmpDir := t.TempDir()
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = tmpDir
|
||||
defer func() {
|
||||
settings.Config.Server.CacheDir = originalCacheDir
|
||||
}()
|
||||
|
||||
// Ensure fileutils permissions are set (needed by NewTempDB)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Initialize the database first (use test helper to avoid permission issues)
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
// Initialize the index in mock mode (no filesystem operations)
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: "/mock/path",
|
||||
}, true, false) // true for mock mode, false for isNewDb
|
||||
|
||||
// Get the index and set up mock data
|
||||
idx := indexing.GetIndex("test")
|
||||
if idx == nil { //nolint:staticcheck // t.Fatal terminates execution
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Wait for initial scanner to complete (it will fail on /mock/path but needs to finish cleanup)
|
||||
// This ensures batchItems is nil before we start testing
|
||||
for i := 0; i < 50; i++ { // Wait up to 500ms
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
status := idx.GetScannerStatus()
|
||||
if status["status"] == "ready" || status["status"] == "unavailable" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock directory structure with a file using UpdateMetadata
|
||||
rootInfo := &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Delete the old file item from the database before replacing it with a directory
|
||||
idx.DeleteMetadata("/Test Object", false, false)
|
||||
|
||||
// Simulate the file-to-directory override by updating the mock data
|
||||
// Remove the file from the parent's Files slice
|
||||
rootInfo.Files = []iteminfo.ExtendedItemInfo{} // Clear files
|
||||
|
||||
// Add the directory to the parent's Folders slice
|
||||
rootInfo.Folders = []iteminfo.ItemInfo{
|
||||
{
|
||||
Name: "Test Object",
|
||||
Type: "directory",
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Verify the file was replaced with a directory in the mock data
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
|
||||
// Check that the file was removed from Files
|
||||
foundFile := false
|
||||
for _, file := range rootInfo.Files {
|
||||
if file.Name == "Test Object" {
|
||||
foundFile = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundFile {
|
||||
t.Error("File 'Test Object' should have been removed from Files")
|
||||
}
|
||||
|
||||
// Check that the directory was added to Folders
|
||||
foundDir := false
|
||||
for _, folder := range rootInfo.Folders {
|
||||
if folder.Name == "Test Object" {
|
||||
foundDir = true
|
||||
if folder.Type != "directory" {
|
||||
t.Errorf("Expected directory type, got %s", folder.Type)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundDir {
|
||||
t.Error("Directory 'Test Object' should have been added to Folders")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFilesRootProtection tests that DeleteFiles refuses to delete
|
||||
// the source root directory itself, preventing catastrophic data loss.
|
||||
// This is a regression test for the safety guard added to prevent accidental
|
||||
// deletion of the entire source directory.
|
||||
func TestDeleteFilesRootProtection(t *testing.T) {
|
||||
// Use a temporary directory for cache
|
||||
tmpDir := t.TempDir()
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = tmpDir
|
||||
defer func() {
|
||||
settings.Config.Server.CacheDir = originalCacheDir
|
||||
}()
|
||||
|
||||
// Ensure fileutils permissions are set
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Initialize the database
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_root_protection", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
// Create a real temporary directory to use as source root
|
||||
realTmpDir := t.TempDir()
|
||||
|
||||
// Initialize the index with a real path
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test_root_protection",
|
||||
Path: realTmpDir,
|
||||
}, false, false) // false for mock mode (real path), false for isNewDb
|
||||
|
||||
// Wait for scanner to be ready
|
||||
idx := indexing.GetIndex("test_root_protection")
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Wait for scanner to finish
|
||||
for i := 0; i < 50; i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
status := idx.GetScannerStatus()
|
||||
if status["status"] == "ready" || status["status"] == "unavailable" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Attempting to delete the root directory should return an error
|
||||
err := DeleteFiles("test_root_protection", realTmpDir, true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error when trying to delete root directory")
|
||||
}
|
||||
|
||||
// Verify the error message contains expected text
|
||||
expectedErrMsg := "refusing to delete source root directory"
|
||||
if err != nil && !strings.Contains(err.Error(), expectedErrMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got: %v", expectedErrMsg, err.Error())
|
||||
}
|
||||
|
||||
// Test with trailing slash - should still be blocked
|
||||
err = DeleteFiles("test_root_protection", realTmpDir+"/", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error for root directory with trailing slash")
|
||||
}
|
||||
|
||||
// Test with cleaned path variations
|
||||
err = DeleteFiles("test_root_protection", realTmpDir+"/.", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error for root directory with /. suffix")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFilesSubfolderWithRootName tests that deleting a subfolder
|
||||
// with the same name as the root directory works correctly and doesn't
|
||||
// accidentally delete the root.
|
||||
// Regression test for path handling bug where /srv/srv could be mistaken for /srv.
|
||||
func TestDeleteFilesSubfolderWithRootName(t *testing.T) {
|
||||
for iter := 1; iter <= raceStressIterations; iter++ {
|
||||
iter := iter
|
||||
t.Run(fmt.Sprintf("stress-iter-%d", iter), func(t *testing.T) {
|
||||
runDeleteFilesSubfolderWithRaceStress(t, iter)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
raceStressIterations = 3
|
||||
raceStatusPollInterval = 5 * time.Millisecond
|
||||
waitStatusMaxTries = 100
|
||||
waitStatusSleep = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
|
||||
t.Helper()
|
||||
start := time.Now()
|
||||
t.Logf("iteration %d: start", iter)
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
originalCache := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
|
||||
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_subfolder_rootname", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
realTmpDir := t.TempDir()
|
||||
subfolderName := filepath.Base(realTmpDir)
|
||||
subfolderPath := filepath.Join(realTmpDir, subfolderName)
|
||||
|
||||
if err := os.Mkdir(subfolderPath, 0755); err != nil {
|
||||
t.Fatalf("Failed to create subfolder: %v", err)
|
||||
}
|
||||
|
||||
testFile := filepath.Join(subfolderPath, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
sourceName := fmt.Sprintf("test_subfolder_rootname-%d", iter)
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: sourceName,
|
||||
Path: realTmpDir,
|
||||
}, false, false)
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatalf("Failed to get test index for %s", sourceName)
|
||||
}
|
||||
|
||||
stopPolling := startScannerStatusPoll(t, idx, iter)
|
||||
defer stopPolling()
|
||||
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
err := DeleteFiles(sourceName, subfolderPath, true)
|
||||
stopPolling()
|
||||
|
||||
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteFiles should succeed for subfolder with root-like name, got error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(subfolderPath); !os.IsNotExist(err) {
|
||||
t.Fatal("Subfolder should have been deleted")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(realTmpDir); os.IsNotExist(err) {
|
||||
t.Fatal("Root directory should NOT have been deleted")
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
// Give scanners time to finish their cleanup (defer blocks in executeScan)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
t.Logf("iteration %d: cleaned scanners and indices", iter)
|
||||
}
|
||||
|
||||
func waitForScannerReady(t *testing.T, idx *indexing.Index) {
|
||||
t.Helper()
|
||||
// Give the scanners a moment to initialize, but don't wait for full "ready" status
|
||||
// The initial scan can take longer than the test timeout, but DeleteFiles will work
|
||||
// as long as the scanners are initialized and running
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify scanners exist
|
||||
status := idx.GetScannerStatus()
|
||||
if totalScanners, ok := status["totalScanners"].(int); !ok || totalScanners == 0 {
|
||||
t.Fatal("No scanners were created")
|
||||
}
|
||||
t.Log("Scanners initialized, proceeding with delete operation")
|
||||
}
|
||||
|
||||
func startScannerStatusPoll(t *testing.T, idx *indexing.Index, iter int) func() {
|
||||
t.Helper()
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(raceStatusPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
close(done)
|
||||
return
|
||||
case <-ticker.C:
|
||||
idx.GetScannerStatus()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
close(stop)
|
||||
<-done
|
||||
t.Logf("iteration %d: poll watcher stopped", iter)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
|
||||
var CheckPermissionsFunc = checkPermissionsImpl
|
||||
|
||||
// addMetadataToChildren runs concurrent ffmpeg/tag extraction for audio/video children.
|
||||
// Each goroutine is registered on wg. Caller must invoke wg.Wait() after scheduling.
|
||||
func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileOptions, wg *sync.WaitGroup) {
|
||||
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
|
||||
if sharedFFmpegService == nil {
|
||||
return
|
||||
}
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
if !isItemAudio && !isItemVideo {
|
||||
continue
|
||||
}
|
||||
isAudio := isItemAudio
|
||||
wg.Go(func() {
|
||||
fullPath := response.RealPath + "/" + fileItem.Name
|
||||
if isAudio {
|
||||
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, false, sharedFFmpegService); err != nil {
|
||||
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
}
|
||||
} else {
|
||||
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, sharedFFmpegService); err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// processDirectoryMetadata extracts metadata for audio/video files in directories
|
||||
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
|
||||
metadataCount := 0
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
if isItemAudio || isItemVideo {
|
||||
metadataCount++
|
||||
}
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
indexPath := idx.MakeIndexPath(response.Path, true)
|
||||
err := idx.RefreshDirectory(indexPath, false)
|
||||
if err != nil {
|
||||
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if opts.Metadata && metadataCount > 0 {
|
||||
addMetadataToChildren(response, opts, &wg)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
|
||||
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
|
||||
// Add OnlyOffice ID if applicable
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
response.OnlyOfficeId = generateOfficeId(realPath)
|
||||
}
|
||||
|
||||
// Strip user scope from response path to return path relative to user's context
|
||||
if user != nil && userScope != "" && userScope != "/" {
|
||||
response.Path = strings.TrimPrefix(response.Path, userScope)
|
||||
if response.Path == "" {
|
||||
response.Path = "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckPermissions validates user access and returns the resolved index path and user scope
|
||||
func CheckPermissions(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
return CheckPermissionsFunc(opts, access, user)
|
||||
}
|
||||
|
||||
// checkPermissionsImpl is the actual implementation of CheckPermissions
|
||||
func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
if access == nil {
|
||||
return "", "", fmt.Errorf("access not provided")
|
||||
}
|
||||
if user == nil {
|
||||
return "", "", fmt.Errorf("user not provided")
|
||||
}
|
||||
if opts.Path == "" {
|
||||
return "", "", fmt.Errorf("path not provided")
|
||||
}
|
||||
if opts.Source == "" {
|
||||
return "", "", fmt.Errorf("source not provided")
|
||||
}
|
||||
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return "", "", fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
|
||||
// Resolve user scope
|
||||
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
|
||||
if scopeErr != nil || userScope == "" {
|
||||
return "", "", fmt.Errorf("user has no access to source: %v", opts.Source)
|
||||
}
|
||||
|
||||
safePath, err := utils.SanitizeUserPath(opts.Path)
|
||||
if err != nil {
|
||||
return "", "", errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Combine scope + sanitized path
|
||||
indexPath := utils.JoinPathAsUnix(userScope, safePath)
|
||||
// Layer 1: USER ACCESS CONTROL
|
||||
// Quick check: Does THIS user have permission?
|
||||
if !access.Permitted(idx.Path, indexPath, user.Username) {
|
||||
return indexPath, "", errors.ErrAccessDenied
|
||||
}
|
||||
return indexPath, userScope, nil
|
||||
}
|
||||
|
||||
type Items struct {
|
||||
Files []string `json:"files,omitempty"`
|
||||
Folders []string `json:"folders,omitempty"`
|
||||
}
|
||||
|
||||
// This removes files whose names match any extension in hideFileExt.
|
||||
func filterFilesByExt(files []iteminfo.ExtendedItemInfo, hideFileExt string) []iteminfo.ExtendedItemInfo {
|
||||
if hideFileExt == "" {
|
||||
return files
|
||||
}
|
||||
filtered := files[:0]
|
||||
for _, f := range files {
|
||||
if !utils.HideFileByExt(f.Name, hideFileExt) {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
|
||||
items := Items{}
|
||||
indexPath, _, topLevelErr := CheckPermissions(opts, access, user)
|
||||
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
|
||||
if topLevelErr != nil && !accessRulesErr {
|
||||
return items, topLevelErr
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return items, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
HideFileExt: opts.HideFileExt,
|
||||
Expand: true,
|
||||
SkipExtendedAttrs: true,
|
||||
})
|
||||
if err != nil {
|
||||
return items, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
if info.Type != "directory" {
|
||||
return items, fmt.Errorf("path is not a directory: %v ", indexPath)
|
||||
}
|
||||
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
|
||||
return items, err
|
||||
}
|
||||
hasAccessibleItems := len(info.Files) > 0 || len(info.Folders) > 0
|
||||
if accessRulesErr && !hasAccessibleItems {
|
||||
return items, errors.ErrAccessDenied
|
||||
}
|
||||
if opts.Only == "files" || opts.Only == "" {
|
||||
for _, file := range info.Files {
|
||||
if opts.HideFileExt != "" && utils.HideFileByExt(file.Name, opts.HideFileExt) {
|
||||
continue
|
||||
}
|
||||
items.Files = append(items.Files, file.Name)
|
||||
}
|
||||
}
|
||||
if opts.Only == "folders" || opts.Only == "" {
|
||||
for _, folder := range info.Folders {
|
||||
items.Folders = append(items.Folders, folder.Name)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// FileInfoFasterFunc is a variable that can be mocked in tests
|
||||
var FileInfoFasterFunc = fileInfoFasterImpl
|
||||
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return FileInfoFasterFunc(opts, access, user, share)
|
||||
}
|
||||
|
||||
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
|
||||
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
indexPath, userScope, topLevelErr := CheckPermissions(opts, access, user)
|
||||
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
|
||||
if topLevelErr != nil && !accessRulesErr {
|
||||
return response, topLevelErr
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
// Layer 2: INDEX RULES (global)
|
||||
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
|
||||
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
HideFileExt: opts.HideFileExt,
|
||||
Expand: opts.Expand,
|
||||
SkipExtendedAttrs: opts.SkipExtendedAttrs,
|
||||
})
|
||||
if err != nil {
|
||||
return response, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
// otherwise response keeps unfiltered Folders/Files while CheckChildItemAccess only mutates info.
|
||||
if info.Type == "directory" {
|
||||
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
hasAccessibleItems := len(info.Files) > 0 || len(info.Folders) > 0
|
||||
if accessRulesErr && !hasAccessibleItems {
|
||||
return response, errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Build response
|
||||
response.FileInfo = *info
|
||||
response.RealPath = filepath.Join(idx.Path, indexPath)
|
||||
response.Source = opts.Source
|
||||
if share != nil && user.Permissions.Share && opts.ShowSharedAttr {
|
||||
for i := range response.Files {
|
||||
file := &response.Files[i]
|
||||
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path, user.ID)
|
||||
}
|
||||
for i := range response.Folders {
|
||||
folder := &response.Folders[i]
|
||||
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path, user.ID)
|
||||
}
|
||||
response.IsShared = share.IsShared(response.Path, idx.Path, user.ID)
|
||||
}
|
||||
defer finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
if opts.SkipExtendedAttrs {
|
||||
if info.Type == "directory" && opts.HideFileExt != "" {
|
||||
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
// Process directory metadata if requested
|
||||
if info.Type == "directory" {
|
||||
processDirectoryMetadata(response, idx, opts)
|
||||
if opts.HideFileExt != "" {
|
||||
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
|
||||
}
|
||||
}
|
||||
// Process single file content/metadata
|
||||
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
|
||||
if opts.Content || opts.Metadata || isAudioVideo {
|
||||
processContent(response, idx, opts)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
|
||||
isVideo := strings.HasPrefix(info.Type, "video")
|
||||
isAudio := strings.HasPrefix(info.Type, "audio")
|
||||
isFolder := info.Type == "directory"
|
||||
if isFolder {
|
||||
return
|
||||
}
|
||||
if isVideo && !opts.Metadata {
|
||||
return
|
||||
}
|
||||
if isAudio && !(opts.Metadata || opts.AlbumArt) {
|
||||
return
|
||||
}
|
||||
if isVideo {
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
info.Metadata = extItem.Metadata
|
||||
}
|
||||
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
|
||||
if exists {
|
||||
info.GetSubtitles(parentInfo)
|
||||
}
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
info.Subtitles = append(info.Subtitles, subtitles...)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Audio: run tag/album-art extraction when full metadata or preview (AlbumArt) is requested.
|
||||
// Preview uses AlbumArt without Metadata so we do not load arbitrary files as text content.
|
||||
if isAudio {
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, false, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Process text content for non-video, non-audio files
|
||||
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
|
||||
content, err := getContent(info.RealPath)
|
||||
if err != nil {
|
||||
logger.Debugf("could not get content for file: "+info.RealPath, info.Name, err)
|
||||
return
|
||||
}
|
||||
info.Content = content
|
||||
} else {
|
||||
logger.Debug("skipping large text file contents (20MB limit): "+info.Path, info.Name, info.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func generateOfficeId(realPath string) string {
|
||||
key, ok := utils.OnlyOfficeCache.Get(realPath)
|
||||
if !ok {
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
documentKey := utils.HashSHA256(realPath + timestamp)
|
||||
utils.OnlyOfficeCache.Set(realPath, documentKey)
|
||||
return documentKey
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Parses raw LRC text into a slice of Lyric.
|
||||
// Lines with [hh:mm:ss.xx] (timestamps) will be parsed to ms for synced lyrics
|
||||
// The ones without timestamp are kept as unsynced (timestamp 0).
|
||||
func parseLRC(raw string) []iteminfo.Lyric {
|
||||
var lines []iteminfo.Lyric
|
||||
scanner := bufio.NewScanner(strings.NewReader(raw))
|
||||
re := regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
matches := re.FindStringSubmatch(line)
|
||||
if len(matches) == 6 {
|
||||
// Hours default to 0 (if not present in the timestamps)
|
||||
h := 0
|
||||
if matches[1] != "" {
|
||||
h, _ = strconv.Atoi(matches[1])
|
||||
}
|
||||
min, _ := strconv.Atoi(matches[2])
|
||||
sec, _ := strconv.Atoi(matches[3])
|
||||
|
||||
msStr := matches[4]
|
||||
// Normalise to 3 digits, some songs have more than 3 digits in the milliseconds
|
||||
switch {
|
||||
case len(msStr) < 3:
|
||||
msStr += strings.Repeat("0", 3-len(msStr))
|
||||
case len(msStr) > 3:
|
||||
msStr = msStr[:3]
|
||||
}
|
||||
ms, _ := strconv.Atoi(msStr)
|
||||
timestamp := int64((h*3600+min*60+sec)*1000 + ms)
|
||||
text := strings.TrimSpace(matches[5])
|
||||
lines = append(lines, iteminfo.Lyric{
|
||||
Text: text,
|
||||
Timestamp: timestamp,
|
||||
})
|
||||
} else {
|
||||
// Line without a valid timestamp – treat as unsynced (timestamp 0)
|
||||
lines = append(lines, iteminfo.Lyric{
|
||||
Text: line,
|
||||
Timestamp: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
|
||||
// and optionally extracts duration using the ffmpeg service with concurrency control
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Check file size first to prevent reading extremely large files
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip files larger than 300MB to prevent memory issues
|
||||
maxSize := int64(300)
|
||||
if fileInfo.Size() > maxSize*1024*1024 {
|
||||
return fmt.Errorf("file with size %d MB exceeds metadata check limit: %d MB", fileInfo.Size()/1024/1024, maxSize)
|
||||
}
|
||||
|
||||
m, err := tag.ReadFrom(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Title: m.Title(),
|
||||
Artist: m.Artist(),
|
||||
Album: m.Album(),
|
||||
Year: m.Year(),
|
||||
Genre: m.Genre(),
|
||||
}
|
||||
|
||||
// Extract track number
|
||||
track, _ := m.Track()
|
||||
item.Metadata.Track = track
|
||||
|
||||
// Check if lyrics are available without extract them
|
||||
if rawLyrics := m.Lyrics(); rawLyrics != "" {
|
||||
item.Metadata.HasLyrics = true
|
||||
} else {
|
||||
// Check for sidecar .lrc file
|
||||
dir := filepath.Dir(realPath)
|
||||
base := filepath.Base(realPath)
|
||||
ext := filepath.Ext(base)
|
||||
nameWithoutExt := strings.TrimSuffix(base, ext)
|
||||
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
|
||||
if _, err := os.Stat(lrcPath); err == nil {
|
||||
item.Metadata.HasLyrics = true
|
||||
}
|
||||
}
|
||||
|
||||
if getLyrics {
|
||||
lyrics, err := ExtractLyrics(realPath)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract lyrics for %s: %v", realPath, err)
|
||||
} else if len(lyrics) > 0 {
|
||||
item.Metadata.Lyrics = lyrics
|
||||
}
|
||||
}
|
||||
|
||||
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
|
||||
// This respects concurrency limits and gracefully handles missing ffmpeg
|
||||
if getDuration {
|
||||
// Use provided service or create a new one for backward compatibility
|
||||
service := ffmpegService
|
||||
if service == nil {
|
||||
service = ffmpeg.NewFFmpegService(5, false, "")
|
||||
}
|
||||
if service != nil {
|
||||
if duration, err := service.GetMediaDuration(ctx, realPath); err == nil {
|
||||
item.Metadata.Duration = int(duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !getArt {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
|
||||
if picture := m.Picture(); picture != nil && picture.Data != nil {
|
||||
// Size limit to prevent memory issues (max 5MB)
|
||||
if len(picture.Data) <= 5*1024*1024 {
|
||||
item.Metadata.AlbumArt = picture.Data
|
||||
} else {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns lyrics from an audio file (from embedded tags or from a .lrc file with the same name).
|
||||
func ExtractLyrics(realPath string) ([]iteminfo.Lyric, error) {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
m, err := tag.ReadFrom(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lyrics []iteminfo.Lyric
|
||||
if raw := m.Lyrics(); raw != "" {
|
||||
lyrics = parseLRC(raw)
|
||||
}
|
||||
if len(lyrics) == 0 {
|
||||
// Check for sidecar .lrc file
|
||||
dir := filepath.Dir(realPath)
|
||||
base := filepath.Base(realPath)
|
||||
ext := filepath.Ext(base)
|
||||
nameWithoutExt := strings.TrimSuffix(base, ext)
|
||||
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
|
||||
if data, err := os.ReadFile(lrcPath); err == nil {
|
||||
lyrics = parseLRC(string(data))
|
||||
}
|
||||
}
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// extractVideoMetadata extracts duration from video files using ffprobe
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
// Use provided service or create a new one for backward compatibility
|
||||
service := ffmpegService
|
||||
if service == nil {
|
||||
service = ffmpeg.NewFFmpegService(10, false, "")
|
||||
}
|
||||
if service != nil {
|
||||
duration, err := service.GetMediaDuration(ctx, realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duration > 0 {
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Duration: int(duration),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
// Safety guard: never allow deletion of the source root directory itself
|
||||
cleanAbs := filepath.Clean(absPath)
|
||||
if cleanAbs == filepath.Clean(index.Path) {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
|
||||
}
|
||||
|
||||
if !index.Config.ResolvedRules.IndexingDisabled {
|
||||
indexPath := index.MakeIndexPath(absPath, isDir)
|
||||
|
||||
// Perform the physical deletion
|
||||
err := os.RemoveAll(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear cache entries
|
||||
indexing.RealPathCache.Delete(absPath)
|
||||
indexing.IsDirCache.Delete(absPath + ":isdir")
|
||||
|
||||
// Remove metadata from index
|
||||
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
|
||||
if !deleteSuccess {
|
||||
logger.Errorf("Failed to delete metadata from index for %s, but filesystem deletion succeeded", indexPath)
|
||||
}
|
||||
|
||||
// Refresh the parent directory to recalculate sizes and update counts
|
||||
refreshConfig := utils.FileOptions{
|
||||
Path: index.MakeIndexPath(filepath.Dir(absPath), true),
|
||||
IsDir: true,
|
||||
}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
return err
|
||||
}
|
||||
|
||||
// Indexing disabled, just delete the file
|
||||
return os.RemoveAll(absPath)
|
||||
}
|
||||
|
||||
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
if idx.Config.ResolvedRules.IndexingDisabled {
|
||||
return nil
|
||||
}
|
||||
// Always normalize path using MakeIndexPath
|
||||
path = idx.MakeIndexPath(path, isDir)
|
||||
|
||||
// Check if path is a symlink
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
isSymlink := false
|
||||
if realPath != "" {
|
||||
if fileInfo, err := os.Lstat(realPath); err == nil {
|
||||
isSymlink = fileInfo.Mode()&os.ModeSymlink != 0
|
||||
}
|
||||
}
|
||||
|
||||
// Skip indexing only for paths that are explicitly excluded (not viewable and should be skipped)
|
||||
hidden := indexing.IsHidden(filepath.Join(idx.Path, path))
|
||||
if idx.ShouldSkip(isDir, path, hidden, isSymlink, false) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For directories, check if the path exists on disk
|
||||
// If it doesn't exist, remove it from the index
|
||||
if isDir {
|
||||
realPath, _, err := idx.GetRealPath(path)
|
||||
if err == nil {
|
||||
// Check if the directory exists on disk
|
||||
if !Exists(realPath) {
|
||||
// Directory no longer exists, remove it from the index
|
||||
// This clears both Directories and DirectoriesLedger maps
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
idx.DeleteMetadata(path, true, false)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := idx.RefreshFileInfo(utils.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
|
||||
if err != nil {
|
||||
logger.Errorf("RefreshFileInfo failed: %v", err)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// validateMoveDestination checks if a move/rename operation is valid
|
||||
// It prevents moving a directory into itself or its subdirectories
|
||||
func validateMoveDestination(src, dst string, isSrcDir bool) error {
|
||||
// Clean and normalize paths
|
||||
src = filepath.Clean(src)
|
||||
dst = filepath.Clean(dst)
|
||||
|
||||
// If source is a directory, check if destination is within source
|
||||
if isSrcDir {
|
||||
// Get the parent directory of the destination
|
||||
dstParent := filepath.Dir(dst)
|
||||
|
||||
// Check if destination parent is the source directory or a subdirectory of it
|
||||
if strings.HasPrefix(dstParent+string(filepath.Separator), src+string(filepath.Separator)) || dstParent == src {
|
||||
return fmt.Errorf("cannot move directory '%s' to a location within itself: '%s'", src, dst)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if destination parent directory exists
|
||||
dstParent := filepath.Dir(dst)
|
||||
if dstParent != "." && dstParent != "/" {
|
||||
if _, err := os.Stat(dstParent); os.IsNotExist(err) {
|
||||
return fmt.Errorf("destination directory does not exist: '%s'", dstParent)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage, a *access.Storage) error {
|
||||
// Check if source and destination are the same file
|
||||
if realsrc == realdst {
|
||||
return fmt.Errorf("cannot move a file to itself: %s", realsrc)
|
||||
}
|
||||
|
||||
// Validate the move operation before executing
|
||||
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get indexes for deletion and refresh operations
|
||||
srcIdx := indexing.GetIndex(sourceIndex)
|
||||
if srcIdx == nil {
|
||||
return fmt.Errorf("could not get source index: %v", sourceIndex)
|
||||
}
|
||||
dstIdx := indexing.GetIndex(destIndex)
|
||||
if dstIdx == nil {
|
||||
return fmt.Errorf("could not get destination index: %v", destIndex)
|
||||
}
|
||||
|
||||
// Prepare paths for index operations
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc, isSrcDir)
|
||||
srcParentPath := filepath.Dir(realsrc)
|
||||
|
||||
// Perform the physical move
|
||||
err := fileutils.MoveFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !srcIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
go func() {
|
||||
if isSrcDir {
|
||||
srcIdx.DeleteMetadata(srcIndexPath, true, true)
|
||||
} else {
|
||||
srcIdx.DeleteMetadata(srcIndexPath, false, false)
|
||||
}
|
||||
if err := RefreshIndex(sourceIndex, srcParentPath, true, false); err != nil {
|
||||
logger.Errorf("Failed to refresh source parent directory %s after move: %v", srcParentPath, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Handle DESTINATION indexing
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !dstIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// Recursively index the moved directory tree
|
||||
if err := RefreshIndex(destIndex, realdst, true, true); err != nil {
|
||||
logger.Errorf("Failed to index moved directory %s: %v", realdst, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh parent to update its size
|
||||
parentDir := filepath.Dir(realdst)
|
||||
if err := RefreshIndex(destIndex, parentDir, true, false); err != nil {
|
||||
logger.Errorf("Failed to refresh destination parent %s: %v", parentDir, err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// For files, refresh parent directory
|
||||
parentDir := filepath.Dir(realdst)
|
||||
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// Use backend source paths to match how shares are stored
|
||||
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc, isSrcDir), dstIdx.Path, dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
|
||||
|
||||
// Update access rules for the moved path
|
||||
if a != nil {
|
||||
// If moving within the same source, update the rules
|
||||
if srcIdx.Path == dstIdx.Path {
|
||||
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc, isSrcDir), dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
|
||||
}
|
||||
// Cross-source moves don't preserve access rules (they're source-specific)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string) error {
|
||||
// Check if source and destination are the same file
|
||||
if realsrc == realdst {
|
||||
return fmt.Errorf("cannot copy a file to itself: %s", realsrc)
|
||||
}
|
||||
|
||||
// Validate the copy operation before executing
|
||||
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
|
||||
logger.Errorf("[COPY] Copy validation failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Perform the physical copy
|
||||
err := fileutils.CopyFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
logger.Errorf("[COPY] Physical copy failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh source (non-recursive, just metadata)
|
||||
srcIdx := indexing.GetIndex(sourceIndex)
|
||||
if srcIdx != nil && !srcIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
srcRefreshPath := realsrc
|
||||
if !isSrcDir {
|
||||
srcRefreshPath = filepath.Dir(realsrc)
|
||||
}
|
||||
go RefreshIndex(sourceIndex, srcRefreshPath, true, false) //nolint:errcheck
|
||||
}
|
||||
|
||||
// Refresh destination (RECURSIVE for directories to capture full tree)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
dstIdx := indexing.GetIndex(destIndex)
|
||||
if dstIdx != nil && !dstIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// Recursively index the copied directory tree
|
||||
if err := RefreshIndex(destIndex, realdst, true, true); err != nil {
|
||||
logger.Errorf("[COPY] Failed to index copied directory %s: %v", realdst, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh parent to update its size
|
||||
parentDir := filepath.Dir(realdst)
|
||||
if err := RefreshIndex(destIndex, parentDir, true, false); err != nil {
|
||||
logger.Errorf("[COPY] Failed to refresh parent %s: %v", parentDir, err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// For files, refresh parent directory
|
||||
dstParent := filepath.Dir(realdst)
|
||||
go RefreshIndex(destIndex, dstParent, true, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteDirectory(opts utils.FileOptions) error {
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(opts.Path)
|
||||
|
||||
var stat os.FileInfo
|
||||
var err error
|
||||
// Check if the destination exists and is a file
|
||||
if stat, err = os.Stat(realPath); err == nil && !stat.IsDir() {
|
||||
// If it's a file and we're trying to create a directory, remove the file first
|
||||
err = os.Remove(realPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove existing file to create directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the parent directories exist
|
||||
// Permissions are set by MkdirAll (subject to umask, which is usually acceptable)
|
||||
err = os.MkdirAll(realPath, fileutils.PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Explicitly set directory permissions to bypass umask
|
||||
err = os.Chmod(realPath, fileutils.PermDir)
|
||||
if err != nil {
|
||||
// Handle chmod error gracefully
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
}
|
||||
|
||||
// Refresh the directory itself recursively
|
||||
err = RefreshIndex(idx.Name, opts.Path, true, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh parent directory to update its size
|
||||
parentPath := filepath.Dir(opts.Path)
|
||||
if parentPath != "." && parentPath != "/" && parentPath != opts.Path {
|
||||
if err := RefreshIndex(idx.Name, parentPath, true, false); err != nil {
|
||||
logger.Debugf("Could not refresh parent directory %s: %v", parentPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteFile(source, path string, in io.Reader) error {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
// Strip trailing slash from realPath if it's meant to be a file
|
||||
realPath = strings.TrimRight(realPath, "/")
|
||||
// Ensure the parent directories exist
|
||||
parentDir := filepath.Dir(realPath)
|
||||
err := os.MkdirAll(parentDir, fileutils.PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyDefaultFilePerm := false
|
||||
var stat os.FileInfo
|
||||
stat, err = os.Stat(realPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat file: %w", err)
|
||||
}
|
||||
applyDefaultFilePerm = true
|
||||
} else if stat.IsDir() {
|
||||
return fmt.Errorf("%w: %q", errors.ErrIsDirectory, path)
|
||||
}
|
||||
|
||||
// Open the file for writing (create if it doesn't exist, truncate if it does)
|
||||
// For new files: permissions are set to fileutils.PermFile (subject to umask, then Chmod bypasses umask)
|
||||
// For existing regular files: do not Chmod so saves preserve mode and special bits
|
||||
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Copy the contents from the reader to the file
|
||||
_, err = io.Copy(file, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if applyDefaultFilePerm {
|
||||
err = os.Chmod(realPath, fileutils.PermFile)
|
||||
if err != nil {
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the file itself
|
||||
err = RefreshIndex(source, path, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh parent directory to update its size
|
||||
parentPath := filepath.Dir(path)
|
||||
if parentPath != "." && parentPath != "/" && parentPath != path {
|
||||
if err := RefreshIndex(source, parentPath, true, false); err != nil {
|
||||
logger.Debugf("Could not refresh parent directory %s: %v", parentPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getContent reads and returns the file content if it's considered an editable text file.
|
||||
// This is a wrapper around utils.IsTextFile that also returns the file content.
|
||||
func getContent(realPath string) (string, error) {
|
||||
// First check if it's a text file using the consolidated validation logic
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isText {
|
||||
// Not a text file, return empty string (no error, just not text)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// It's a text file, read and return the content
|
||||
content, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Handle empty file (return specific marker string)
|
||||
if len(content) == 0 {
|
||||
return "empty-file-x6OlSil", nil
|
||||
}
|
||||
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
func IsNamedPipe(mode os.FileMode) bool {
|
||||
return mode&os.ModeNamedPipe != 0
|
||||
}
|
||||
|
||||
func IsSymlink(mode os.FileMode) bool {
|
||||
return mode&os.ModeSymlink != 0
|
||||
}
|
||||
|
||||
func Exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
package files
|
||||
|
||||
// This file contains code primarily sourced from::
|
||||
// github.com/kataras/iris
|
||||
|
||||
import (
|
||||
"mime"
|
||||
)
|
||||
|
||||
const (
|
||||
// ContentBinaryHeaderValue header value for binary data.
|
||||
ContentBinaryHeaderValue = "application/octet-stream"
|
||||
// ContentWebassemblyHeaderValue header value for web assembly files.
|
||||
ContentWebassemblyHeaderValue = "application/wasm"
|
||||
// ContentHTMLHeaderValue is the string of text/html response header's content type value.
|
||||
ContentHTMLHeaderValue = "text/html"
|
||||
// ContentJSONHeaderValue header value for JSON data.
|
||||
ContentJSONHeaderValue = "application/json"
|
||||
// ContentJSONProblemHeaderValue header value for JSON API problem error.
|
||||
// Read more at: https://tools.ietf.org/html/rfc7807
|
||||
ContentJSONProblemHeaderValue = "application/problem+json"
|
||||
// ContentXMLProblemHeaderValue header value for XML API problem error.
|
||||
// Read more at: https://tools.ietf.org/html/rfc7807
|
||||
ContentXMLProblemHeaderValue = "application/problem+xml"
|
||||
// ContentJavascriptHeaderValue header value for JSONP & Javascript data.
|
||||
ContentJavascriptHeaderValue = "text/javascript"
|
||||
// ContentTextHeaderValue header value for Text data.
|
||||
ContentTextHeaderValue = "text/plain"
|
||||
// ContentXMLHeaderValue header value for XML data.
|
||||
ContentXMLHeaderValue = "text/xml"
|
||||
// ContentXMLUnreadableHeaderValue obsolete header value for XML.
|
||||
ContentXMLUnreadableHeaderValue = "application/xml"
|
||||
// ContentMarkdownHeaderValue custom key/content type, the real is the text/html.
|
||||
ContentMarkdownHeaderValue = "text/markdown"
|
||||
// ContentYAMLHeaderValue header value for YAML data.
|
||||
ContentYAMLHeaderValue = "application/x-yaml"
|
||||
// ContentYAMLTextHeaderValue header value for YAML plain text.
|
||||
ContentYAMLTextHeaderValue = "text/yaml"
|
||||
// ContentProtobufHeaderValue header value for Protobuf messages data.
|
||||
ContentProtobufHeaderValue = "application/x-protobuf"
|
||||
// ContentMsgPackHeaderValue header value for MsgPack data.
|
||||
ContentMsgPackHeaderValue = "application/msgpack"
|
||||
// ContentMsgPack2HeaderValue alternative header value for MsgPack data.
|
||||
ContentMsgPack2HeaderValue = "application/x-msgpack"
|
||||
// ContentFormHeaderValue header value for post form data.
|
||||
ContentFormHeaderValue = "application/x-www-form-urlencoded"
|
||||
// ContentFormMultipartHeaderValue header value for post multipart form data.
|
||||
ContentFormMultipartHeaderValue = "multipart/form-data"
|
||||
// ContentMultipartRelatedHeaderValue header value for multipart related data.
|
||||
ContentMultipartRelatedHeaderValue = "multipart/related"
|
||||
// ContentGRPCHeaderValue Content-Type header value for gRPC.
|
||||
ContentGRPCHeaderValue = "application/grpc"
|
||||
)
|
||||
|
||||
var types = map[string]string{
|
||||
".3dm": "x-world/x-3dmf",
|
||||
".3dmf": "x-world/x-3dmf",
|
||||
".7z": "application/x-7z-compressed",
|
||||
".a": "application/octet-stream",
|
||||
".aab": "application/x-authorware-bin",
|
||||
".aam": "application/x-authorware-map",
|
||||
".aas": "application/x-authorware-seg",
|
||||
".abc": "text/vndabc",
|
||||
".ace": "application/x-ace-compressed",
|
||||
".acgi": "text/html",
|
||||
".afl": "video/animaflex",
|
||||
".ai": "application/postscript",
|
||||
".aif": "audio/aiff",
|
||||
".aifc": "audio/aiff",
|
||||
".aiff": "audio/aiff",
|
||||
".aim": "application/x-aim",
|
||||
".aip": "text/x-audiosoft-intra",
|
||||
".alz": "application/x-alz-compressed",
|
||||
".ani": "application/x-navi-animation",
|
||||
".aos": "application/x-nokia-9000-communicator-add-on-software",
|
||||
".aps": "application/mime",
|
||||
".apk": "application/vnd.android.package-archive",
|
||||
".arc": "application/x-arc-compressed",
|
||||
".arj": "application/arj",
|
||||
".art": "image/x-jg",
|
||||
".asf": "video/x-ms-asf",
|
||||
".asm": "text/x-asm",
|
||||
".asp": "text/asp",
|
||||
".asx": "application/x-mplayer2",
|
||||
".au": "audio/basic",
|
||||
".avi": "video/x-msvideo",
|
||||
".avs": "video/avs-video",
|
||||
".bcpio": "application/x-bcpio",
|
||||
".bin": "application/mac-binary",
|
||||
".bmp": "image/bmp",
|
||||
".boo": "application/book",
|
||||
".book": "application/book",
|
||||
".boz": "application/x-bzip2",
|
||||
".bsh": "application/x-bsh",
|
||||
".bz2": "application/x-bzip2",
|
||||
".bz": "application/x-bzip",
|
||||
".c++": ContentTextHeaderValue,
|
||||
".c": "text/x-c",
|
||||
".cab": "application/vnd.ms-cab-compressed",
|
||||
".cat": "application/vndms-pkiseccat",
|
||||
".cc": "text/x-c",
|
||||
".ccad": "application/clariscad",
|
||||
".cco": "application/x-cocoa",
|
||||
".cdf": "application/cdf",
|
||||
".cer": "application/pkix-cert",
|
||||
".cha": "application/x-chat",
|
||||
".chat": "application/x-chat",
|
||||
".chrt": "application/vnd.kde.kchart",
|
||||
".class": "application/java",
|
||||
".com": ContentTextHeaderValue,
|
||||
".cpio": "application/x-cpio",
|
||||
".cpp": "text/x-c",
|
||||
".cpt": "application/mac-compactpro",
|
||||
".crl": "application/pkcs-crl",
|
||||
".crt": "application/pkix-cert",
|
||||
".crx": "application/x-chrome-extension",
|
||||
".csh": "text/x-scriptcsh",
|
||||
".css": "text/css",
|
||||
".csv": "text/csv",
|
||||
".cxx": ContentTextHeaderValue,
|
||||
".dar": "application/x-dar",
|
||||
".dcr": "application/x-director",
|
||||
".deb": "application/x-debian-package",
|
||||
".deepv": "application/x-deepv",
|
||||
".def": ContentTextHeaderValue,
|
||||
".der": "application/x-x509-ca-cert",
|
||||
".dif": "video/x-dv",
|
||||
".dir": "application/x-director",
|
||||
".divx": "video/divx",
|
||||
".dl": "video/dl",
|
||||
".dmg": "application/x-apple-diskimage",
|
||||
".doc": "application/msword",
|
||||
".dot": "application/msword",
|
||||
".dp": "application/commonground",
|
||||
".drw": "application/drafting",
|
||||
".dump": "application/octet-stream",
|
||||
".dv": "video/x-dv",
|
||||
".dvi": "application/x-dvi",
|
||||
".dwf": "drawing/x-dwf=(old)",
|
||||
".dwg": "application/acad",
|
||||
".dxf": "application/dxf",
|
||||
".dxr": "application/x-director",
|
||||
".el": "text/x-scriptelisp",
|
||||
".elc": "application/x-bytecodeelisp=(compiled=elisp)",
|
||||
".eml": "message/rfc822",
|
||||
".env": "application/x-envoy",
|
||||
".eps": "application/postscript",
|
||||
".es": "application/x-esrehber",
|
||||
".etx": "text/x-setext",
|
||||
".evy": "application/envoy",
|
||||
".exe": "application/x-msdownload",
|
||||
".dll": "application/x-msdownload",
|
||||
".bat": "application/x-msdos-program",
|
||||
".cmd": "application/x-msdos-program",
|
||||
".msi": "application/x-msdownload",
|
||||
".f77": "text/x-fortran",
|
||||
".f90": "text/x-fortran",
|
||||
".f": "text/x-fortran",
|
||||
".fdf": "application/vndfdf",
|
||||
".fif": "application/fractals",
|
||||
".fli": "video/fli",
|
||||
".flo": "image/florian",
|
||||
".flv": "video/x-flv",
|
||||
".flx": "text/vndfmiflexstor",
|
||||
".fmf": "video/x-atomic3d-feature",
|
||||
".for": "text/x-fortran",
|
||||
".fpx": "image/vndfpx",
|
||||
".frl": "application/freeloader",
|
||||
".funk": "audio/make",
|
||||
".g3": "image/g3fax",
|
||||
".g": ContentTextHeaderValue,
|
||||
".gif": "image/gif",
|
||||
".gl": "video/gl",
|
||||
".gsd": "audio/x-gsm",
|
||||
".gsm": "audio/x-gsm",
|
||||
".gsp": "application/x-gsp",
|
||||
".gss": "application/x-gss",
|
||||
".gtar": "application/x-gtar",
|
||||
".gz": "application/x-compressed",
|
||||
".gzip": "application/x-gzip",
|
||||
".h": "text/x-h",
|
||||
".hdf": "application/x-hdf",
|
||||
".help": "application/x-helpfile",
|
||||
".hgl": "application/vndhp-hpgl",
|
||||
".hh": "text/x-h",
|
||||
".hlb": "text/x-script",
|
||||
".hlp": "application/hlp",
|
||||
".hpg": "application/vndhp-hpgl",
|
||||
".hpgl": "application/vndhp-hpgl",
|
||||
".hqx": "application/binhex",
|
||||
".hta": "application/hta",
|
||||
".htc": "text/x-component",
|
||||
".htm": "text/html",
|
||||
".html": "text/html",
|
||||
".htmls": "text/html",
|
||||
".htt": "text/webviewhtml",
|
||||
".htx": "text/html",
|
||||
".ice": "x-conference/x-cooltalk",
|
||||
".ico": "image/x-icon",
|
||||
".ics": "text/calendar",
|
||||
".icz": "text/calendar",
|
||||
".idc": ContentTextHeaderValue,
|
||||
".ief": "image/ief",
|
||||
".iefs": "image/ief",
|
||||
".iges": "application/iges",
|
||||
".igs": "application/iges",
|
||||
".ima": "application/x-ima",
|
||||
".imap": "application/x-httpd-imap",
|
||||
".inf": "application/inf",
|
||||
".ins": "application/x-internett-signup",
|
||||
".ip": "application/x-ip2",
|
||||
".isu": "video/x-isvideo",
|
||||
".it": "audio/it",
|
||||
".iv": "application/x-inventor",
|
||||
".ivr": "i-world/i-vrml",
|
||||
".ivy": "application/x-livescreen",
|
||||
".jam": "audio/x-jam",
|
||||
".jav": "text/x-java-source",
|
||||
".java": "text/x-java-source",
|
||||
".jcm": "application/x-java-commerce",
|
||||
".jfif-tbnl": "image/jpeg",
|
||||
".jfif": "image/jpeg",
|
||||
".jnlp": "application/x-java-jnlp-file",
|
||||
".jpe": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".jps": "image/x-jps",
|
||||
".js": ContentJavascriptHeaderValue,
|
||||
".mjs": ContentJavascriptHeaderValue,
|
||||
".json": ContentJSONHeaderValue,
|
||||
".jut": "image/jutvision",
|
||||
".kar": "audio/midi",
|
||||
".karbon": "application/vnd.kde.karbon",
|
||||
".kfo": "application/vnd.kde.kformula",
|
||||
".flw": "application/vnd.kde.kivio",
|
||||
".kml": "application/vnd.google-earth.kml+xml",
|
||||
".kmz": "application/vnd.google-earth.kmz",
|
||||
".kon": "application/vnd.kde.kontour",
|
||||
".kpr": "application/vnd.kde.kpresenter",
|
||||
".kpt": "application/vnd.kde.kpresenter",
|
||||
".ksp": "application/vnd.kde.kspread",
|
||||
".kwd": "application/vnd.kde.kword",
|
||||
".kwt": "application/vnd.kde.kword",
|
||||
".ksh": "text/x-scriptksh",
|
||||
".la": "audio/nspaudio",
|
||||
".lam": "audio/x-liveaudio",
|
||||
".latex": "application/x-latex",
|
||||
".lha": "application/x-lzh-compressed",
|
||||
".lhx": "application/x-lzh-compressed",
|
||||
".list": ContentTextHeaderValue,
|
||||
".lma": "audio/nspaudio",
|
||||
".log": ContentTextHeaderValue,
|
||||
".lsp": "text/x-scriptlisp",
|
||||
".lst": ContentTextHeaderValue,
|
||||
".lsx": "text/x-la-asf",
|
||||
".ltx": "application/x-latex",
|
||||
".lzh": "application/octet-stream",
|
||||
".lzx": "application/lzx",
|
||||
".m1v": "video/mpeg",
|
||||
".m2a": "audio/mpeg",
|
||||
".m2v": "video/mpeg",
|
||||
".m": "text/x-m",
|
||||
".man": "application/x-troff-man",
|
||||
".manifest": "text/cache-manifest",
|
||||
".map": "application/x-navimap",
|
||||
".mar": ContentTextHeaderValue,
|
||||
".mbd": "application/mbedlet",
|
||||
".mc$": "application/x-magic-cap-package-10",
|
||||
".mcd": "application/mcad",
|
||||
".mcf": "text/mcf",
|
||||
".mcp": "application/netmc",
|
||||
".me": "application/x-troff-me",
|
||||
".mht": "message/rfc822",
|
||||
".mhtml": "message/rfc822",
|
||||
".mid": "application/x-midi",
|
||||
".midi": "application/x-midi",
|
||||
".mif": "application/x-frame",
|
||||
".mime": "message/rfc822",
|
||||
".mjf": "audio/x-vndaudioexplosionmjuicemediafile",
|
||||
".mjpg": "video/x-motion-jpeg",
|
||||
".mm": "application/base64",
|
||||
".mme": "application/base64",
|
||||
".mod": "audio/mod",
|
||||
".moov": "video/quicktime",
|
||||
".mov": "video/quicktime",
|
||||
".movie": "video/x-sgi-movie",
|
||||
".mp2": "audio/mpeg",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mpa": "audio/mpeg",
|
||||
".mpc": "application/x-project",
|
||||
".mpe": "video/mpeg",
|
||||
".mpeg": "video/mpeg",
|
||||
".mpg": "video/mpeg",
|
||||
".mpga": "audio/mpeg",
|
||||
".mpp": "application/vndms-project",
|
||||
".mpt": "application/x-project",
|
||||
".mpv": "application/x-project",
|
||||
".mpx": "application/x-project",
|
||||
".mrc": "application/marc",
|
||||
".ms": "application/x-troff-ms",
|
||||
".mv": "video/x-sgi-movie",
|
||||
".my": "audio/make",
|
||||
".mzz": "application/x-vndaudioexplosionmzz",
|
||||
".nap": "image/naplps",
|
||||
".naplps": "image/naplps",
|
||||
".nc": "application/x-netcdf",
|
||||
".ncm": "application/vndnokiaconfiguration-message",
|
||||
".nif": "image/x-niff",
|
||||
".niff": "image/x-niff",
|
||||
".nsc": "application/x-conference",
|
||||
".nvd": "application/x-navidoc",
|
||||
".o": "application/octet-stream",
|
||||
".oda": "application/oda",
|
||||
".odb": "application/vnd.oasis.opendocument.database",
|
||||
".odc": "application/vnd.oasis.opendocument.chart",
|
||||
".odf": "application/vnd.oasis.opendocument.formula",
|
||||
".odg": "application/vnd.oasis.opendocument.graphics",
|
||||
".odi": "application/vnd.oasis.opendocument.image",
|
||||
".odm": "application/vnd.oasis.opendocument.text-master",
|
||||
".odp": "application/vnd.oasis.opendocument.presentation",
|
||||
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
||||
".odt": "application/vnd.oasis.opendocument.text",
|
||||
".oga": "audio/ogg",
|
||||
".ogg": "audio/ogg",
|
||||
".ogv": "video/ogg",
|
||||
".omc": "application/x-omc",
|
||||
".omcd": "application/x-omcdatamaker",
|
||||
".omcr": "application/x-omcregerator",
|
||||
".otc": "application/vnd.oasis.opendocument.chart-template",
|
||||
".otf": "application/vnd.oasis.opendocument.formula-template",
|
||||
".otg": "application/vnd.oasis.opendocument.graphics-template",
|
||||
".oth": "application/vnd.oasis.opendocument.text-web",
|
||||
".oti": "application/vnd.oasis.opendocument.image-template",
|
||||
".otm": "application/vnd.oasis.opendocument.text-master",
|
||||
".otp": "application/vnd.oasis.opendocument.presentation-template",
|
||||
".ots": "application/vnd.oasis.opendocument.spreadsheet-template",
|
||||
".ott": "application/vnd.oasis.opendocument.text-template",
|
||||
".p10": "application/pkcs10",
|
||||
".p12": "application/pkcs-12",
|
||||
".p7a": "application/x-pkcs7-signature",
|
||||
".p7c": "application/pkcs7-mime",
|
||||
".p7m": "application/pkcs7-mime",
|
||||
".p7r": "application/x-pkcs7-certreqresp",
|
||||
".p7s": "application/pkcs7-signature",
|
||||
".part": "application/pro_eng",
|
||||
".pbm": "image/x-portable-bitmap",
|
||||
".pcl": "application/vndhp-pcl",
|
||||
".pct": "image/x-pict",
|
||||
".pcx": "image/x-pcx",
|
||||
".pdb": "chemical/x-pdb",
|
||||
".pdf": "application/pdf",
|
||||
".pdfa": "application/pdf",
|
||||
".pfunk": "audio/make",
|
||||
".pgm": "image/x-portable-graymap",
|
||||
".pic": "image/pict",
|
||||
".pict": "image/pict",
|
||||
".pkg": "application/x-newton-compatible-pkg",
|
||||
".pko": "application/vndms-pkipko",
|
||||
".pl": "text/x-scriptperl",
|
||||
".plx": "application/x-pixclscript",
|
||||
".pm4": "application/x-pagemaker",
|
||||
".pm5": "application/x-pagemaker",
|
||||
".pm": "text/x-scriptperl-module",
|
||||
".png": "image/png",
|
||||
".pnm": "application/x-portable-anymap",
|
||||
".pot": "application/mspowerpoint",
|
||||
".pov": "model/x-pov",
|
||||
".ppa": "application/vndms-powerpoint",
|
||||
".ppm": "image/x-portable-pixmap",
|
||||
".pps": "application/mspowerpoint",
|
||||
".ppt": "application/mspowerpoint",
|
||||
".ppz": "application/mspowerpoint",
|
||||
".pre": "application/x-freelance",
|
||||
".prt": "application/pro_eng",
|
||||
".ps": "application/postscript",
|
||||
".psd": "image/vnd.adobe.photoshop",
|
||||
".pvu": "paleovu/x-pv",
|
||||
".pwz": "application/vndms-powerpoint",
|
||||
".py": "text/x-scriptphyton",
|
||||
".pyc": "application/x-bytecodepython",
|
||||
".qcp": "audio/vndqcelp",
|
||||
".qd3": "x-world/x-3dmf",
|
||||
".qd3d": "x-world/x-3dmf",
|
||||
".qif": "image/x-quicktime",
|
||||
".qt": "video/quicktime",
|
||||
".qtc": "video/x-qtc",
|
||||
".qti": "image/x-quicktime",
|
||||
".qtif": "image/x-quicktime",
|
||||
".ra": "audio/x-pn-realaudio",
|
||||
".ram": "audio/x-pn-realaudio",
|
||||
".rar": "application/x-rar-compressed",
|
||||
".ras": "application/x-cmu-raster",
|
||||
".rast": "image/cmu-raster",
|
||||
".rexx": "text/x-scriptrexx",
|
||||
".rf": "image/vndrn-realflash",
|
||||
".rgb": "image/x-rgb",
|
||||
".rm": "application/vndrn-realmedia",
|
||||
".rmi": "audio/mid",
|
||||
".rmm": "audio/x-pn-realaudio",
|
||||
".rmp": "audio/x-pn-realaudio",
|
||||
".rng": "application/ringing-tones",
|
||||
".rnx": "application/vndrn-realplayer",
|
||||
".roff": "application/x-troff",
|
||||
".rp": "image/vndrn-realpix",
|
||||
".rpm": "audio/x-pn-realaudio-plugin",
|
||||
".rt": "text/vndrn-realtext",
|
||||
".rtf": "text/richtext",
|
||||
".rtx": "text/richtext",
|
||||
".rv": "video/vndrn-realvideo",
|
||||
".s": "text/x-asm",
|
||||
".s3m": "audio/s3m",
|
||||
".s7z": "application/x-7z-compressed",
|
||||
".saveme": "application/octet-stream",
|
||||
".sbk": "application/x-tbook",
|
||||
".scm": "text/x-scriptscheme",
|
||||
".sdml": ContentTextHeaderValue,
|
||||
".sdp": "application/sdp",
|
||||
".sdr": "application/sounder",
|
||||
".sea": "application/sea",
|
||||
".set": "application/set",
|
||||
".sgm": "text/x-sgml",
|
||||
".sgml": "text/x-sgml",
|
||||
".sh": "text/x-scriptsh",
|
||||
".shar": "application/x-bsh",
|
||||
".shtml": "text/x-server-parsed-html",
|
||||
".sid": "audio/x-psid",
|
||||
".skd": "application/x-koan",
|
||||
".skm": "application/x-koan",
|
||||
".skp": "application/x-koan",
|
||||
".skt": "application/x-koan",
|
||||
".sit": "application/x-stuffit",
|
||||
".sitx": "application/x-stuffitx",
|
||||
".sl": "application/x-seelogo",
|
||||
".smi": "application/smil",
|
||||
".smil": "application/smil",
|
||||
".snd": "audio/basic",
|
||||
".sol": "application/solids",
|
||||
".spc": "text/x-speech",
|
||||
".spl": "application/futuresplash",
|
||||
".spr": "application/x-sprite",
|
||||
".sprite": "application/x-sprite",
|
||||
".spx": "audio/ogg",
|
||||
".src": "application/x-wais-source",
|
||||
".srt": "text/subtitle-srt",
|
||||
".ass": "text/subtitle-ass",
|
||||
".vtt": "text/subtitle-vtt",
|
||||
".ssa": "text/subtitle-ssa",
|
||||
".lrc": "text/lyrics",
|
||||
".sbv": "text/plain",
|
||||
".ssi": "text/x-server-parsed-html",
|
||||
".ssm": "application/streamingmedia",
|
||||
".sst": "application/vndms-pkicertstore",
|
||||
".step": "application/step",
|
||||
".stl": "application/sla",
|
||||
".stp": "application/step",
|
||||
".sv4cpio": "application/x-sv4cpio",
|
||||
".sv4crc": "application/x-sv4crc",
|
||||
".svf": "image/vnddwg",
|
||||
".svg": "image/svg+xml",
|
||||
".svr": "application/x-world",
|
||||
".swf": "application/x-shockwave-flash",
|
||||
".t": "application/x-troff",
|
||||
".talk": "text/x-speech",
|
||||
".tar": "application/x-tar",
|
||||
".tbk": "application/toolbook",
|
||||
".tcl": "text/x-scripttcl",
|
||||
".tcsh": "text/x-scripttcsh",
|
||||
".tex": "application/x-tex",
|
||||
".texi": "application/x-texinfo",
|
||||
".texinfo": "application/x-texinfo",
|
||||
".text": ContentTextHeaderValue,
|
||||
".tgz": "application/gnutar",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".tr": "application/x-troff",
|
||||
".tsi": "audio/tsp-audio",
|
||||
".tsp": "application/dsptype",
|
||||
".tsv": "text/tab-separated-values",
|
||||
".turbot": "image/florian",
|
||||
".txt": ContentTextHeaderValue,
|
||||
".uil": "text/x-uil",
|
||||
".uni": "text/uri-list",
|
||||
".unis": "text/uri-list",
|
||||
".unv": "application/i-deas",
|
||||
".uri": "text/uri-list",
|
||||
".uris": "text/uri-list",
|
||||
".ustar": "application/x-ustar",
|
||||
".uu": "text/x-uuencode",
|
||||
".uue": "text/x-uuencode",
|
||||
".vcd": "application/x-cdlink",
|
||||
".vcf": "text/x-vcard",
|
||||
".vcard": "text/x-vcard",
|
||||
".vcs": "text/x-vcalendar",
|
||||
".vda": "application/vda",
|
||||
".vdo": "video/vdo",
|
||||
".vew": "application/groupwise",
|
||||
".viv": "video/vivo",
|
||||
".vivo": "video/vivo",
|
||||
".vmd": "application/vocaltec-media-desc",
|
||||
".vmf": "application/vocaltec-media-file",
|
||||
".voc": "audio/voc",
|
||||
".vos": "video/vosaic",
|
||||
".vox": "audio/voxware",
|
||||
".vqe": "audio/x-twinvq-plugin",
|
||||
".vqf": "audio/x-twinvq",
|
||||
".vql": "audio/x-twinvq-plugin",
|
||||
".vrml": "application/x-vrml",
|
||||
".vrt": "x-world/x-vrt",
|
||||
".vsd": "application/x-visio",
|
||||
".vst": "application/x-visio",
|
||||
".vsw": "application/x-visio",
|
||||
".w60": "application/wordperfect60",
|
||||
".w61": "application/wordperfect61",
|
||||
".w6w": "application/msword",
|
||||
".wav": "audio/wav",
|
||||
".wb1": "application/x-qpro",
|
||||
".wbmp": "image/vnd.wap.wbmp",
|
||||
".web": "application/vndxara",
|
||||
".wiz": "application/msword",
|
||||
".wk1": "application/x-123",
|
||||
".wmf": "windows/metafile",
|
||||
".wml": "text/vnd.wap.wml",
|
||||
".wmlc": "application/vnd.wap.wmlc",
|
||||
".wmls": "text/vnd.wap.wmlscript",
|
||||
".wmlsc": "application/vnd.wap.wmlscriptc",
|
||||
".word": "application/msword",
|
||||
".wp5": "application/wordperfect",
|
||||
".wp6": "application/wordperfect",
|
||||
".wp": "application/wordperfect",
|
||||
".wpd": "application/wordperfect",
|
||||
".wq1": "application/x-lotus",
|
||||
".wri": "application/mswrite",
|
||||
".wrl": "application/x-world",
|
||||
".wrz": "model/vrml",
|
||||
".wsc": "text/scriplet",
|
||||
".wsrc": "application/x-wais-source",
|
||||
".wtk": "application/x-wintalk",
|
||||
".x-png": "image/png",
|
||||
".xbm": "image/x-xbitmap",
|
||||
".xdr": "video/x-amt-demorun",
|
||||
".xgz": "xgl/drawing",
|
||||
".xif": "image/vndxiff",
|
||||
".xl": "application/excel",
|
||||
".xla": "application/excel",
|
||||
".xlb": "application/excel",
|
||||
".xlc": "application/excel",
|
||||
".xld": "application/excel",
|
||||
".xlk": "application/excel",
|
||||
".xll": "application/excel",
|
||||
".xlm": "application/excel",
|
||||
".xls": "application/excel",
|
||||
".xlt": "application/excel",
|
||||
".xlv": "application/excel",
|
||||
".xlw": "application/excel",
|
||||
".xm": "audio/xm",
|
||||
".xml": ContentXMLHeaderValue,
|
||||
".xmz": "xgl/movie",
|
||||
".xpix": "application/x-vndls-xpix",
|
||||
".xpm": "image/x-xpixmap",
|
||||
".xsr": "video/x-amt-showrun",
|
||||
".xwd": "image/x-xwd",
|
||||
".xyz": "chemical/x-pdb",
|
||||
".z": "application/x-compress",
|
||||
".zip": "application/zip",
|
||||
".zoo": "application/octet-stream",
|
||||
".zsh": "text/x-scriptzsh",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".docm": "application/vnd.ms-word.document.macroEnabled.12",
|
||||
".dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
|
||||
".dotm": "application/vnd.ms-word.template.macroEnabled.12",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
|
||||
".xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template",
|
||||
".xltm": "application/vnd.ms-excel.template.macroEnabled.12",
|
||||
".xlsb": "application/vnd.ms-excel.sheet.binary.macroEnabled.12",
|
||||
".xlam": "application/vnd.ms-excel.addin.macroEnabled.12",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".pptm": "application/vnd.ms-powerpoint.presentation.macroEnabled.12",
|
||||
".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
|
||||
".ppsm": "application/vnd.ms-powerpoint.slideshow.macroEnabled.12",
|
||||
".potx": "application/vnd.openxmlformats-officedocument.presentationml.template",
|
||||
".potm": "application/vnd.ms-powerpoint.template.macroEnabled.12",
|
||||
".ppam": "application/vnd.ms-powerpoint.addin.macroEnabled.12",
|
||||
".sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide",
|
||||
".sldm": "application/vnd.ms-powerpoint.slide.macroEnabled.12",
|
||||
".thmx": "application/vnd.ms-officetheme",
|
||||
".onetoc": "application/onenote",
|
||||
".onetoc2": "application/onenote",
|
||||
".onetmp": "application/onenote",
|
||||
".onepkg": "application/onenote",
|
||||
".xpi": "application/x-xpinstall",
|
||||
".wasm": "application/wasm",
|
||||
".m4a": "audio/mp4",
|
||||
".flac": "audio/flac",
|
||||
".amr": "audio/amr",
|
||||
".aac": "audio/aac",
|
||||
".opus": "audio/ogg", // https://wiki.xiph.org/MIME_Types_and_File_Extensions
|
||||
".ogx": "application/ogg",
|
||||
".m4v": "video/mp4",
|
||||
".mkv": "video/x-matroska",
|
||||
".webm": "video/webm",
|
||||
".weba": "audio/webm",
|
||||
".caf": "audio/x-caf",
|
||||
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
|
||||
".m3u8": "text/x-mpegURL",
|
||||
".pls": "text/x-scpls",
|
||||
".xspf": "application/xspf+xml",
|
||||
".mpd": "application/dash+xml",
|
||||
".webp": "image/webp",
|
||||
".epub": "application/epub+zip",
|
||||
".db": "application/x-db",
|
||||
".db2": "application/x-db",
|
||||
".db3": "application/x-db",
|
||||
".sql": "application/sql",
|
||||
".sqlite": "application/vnd.sqlite3",
|
||||
".sqlite3": "application/vnd.sqlite3",
|
||||
".yaml": "application/yaml",
|
||||
".app": "application/x-application",
|
||||
".geojson": "application/geo+json",
|
||||
".sketch": "application/x-sketch",
|
||||
".bak": "application/backup",
|
||||
".backup": "application/backup",
|
||||
".xfc": "application/x-xcf",
|
||||
".vue": "text/vue",
|
||||
".conf": "text/config-file",
|
||||
".ini": "text/ini",
|
||||
".ps1": "application/x-powershell",
|
||||
".ps2": "application/x-powershell",
|
||||
".ps3": "application/x-powershell",
|
||||
".dart": "application/vnd.dart",
|
||||
".flutter": "text/flutter",
|
||||
".tsx": "text/x-typescript",
|
||||
".ts": "text/x-typescript",
|
||||
".php": "application/x-php",
|
||||
".php2": "application/x-php",
|
||||
".php3": "application/x-php",
|
||||
".phtml": "application/x-php",
|
||||
".p": "text/x-pascal",
|
||||
".pas": "text/x-pascal",
|
||||
".zig": "text/x-zig",
|
||||
".ex": "text/x-elixir",
|
||||
".exs": "text/x-elixir",
|
||||
".json5": "application/json5",
|
||||
".pmx": "model/x-pmx",
|
||||
".nix": "text/nix-lang",
|
||||
".cjs": "text/javascript",
|
||||
".torrent": "application/x-bittorrent",
|
||||
".tmp": "text/tmp",
|
||||
".temp": "text/tmp",
|
||||
// the ones below are some from: https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
|
||||
".adp": "audio/adpcm",
|
||||
".mp4a": "audio/mp4",
|
||||
".mp2a": "audio/mpeg",
|
||||
".m3a": "audio/mpeg",
|
||||
".sil": "audio/silk",
|
||||
".uva": "audio/vnd.dece.audio",
|
||||
".uvva": "audio/vnd.dece.audio",
|
||||
".eol": "audio/vnd.digital-winds",
|
||||
".dra": "audio/vnd.dra",
|
||||
".dts": "audio/vnd.dts",
|
||||
".dtshd": "audio/vnd.dts.hd",
|
||||
".lvp": "audio/vnd.lucent.voice",
|
||||
".pya": "audio/vnd.ms-playready.media.pya",
|
||||
".rip": "audio/vnd.rip",
|
||||
".mka": "audio/x-matroska",
|
||||
".h261": "video/h261",
|
||||
".h263": "video/h263",
|
||||
".h264": "video/h264",
|
||||
".jpgv": "video/jpeg",
|
||||
".jpm": "video/jpm",
|
||||
".jpgm": "video/jpm",
|
||||
".mj2": "video/mj2",
|
||||
".mjp2": "video/mj2",
|
||||
".m2t": "video/mp2t",
|
||||
".m2ts": "video/mp2t",
|
||||
".mts": "video/mp2t",
|
||||
".mp4v": "video/mp4",
|
||||
".mpg4": "video/mp4",
|
||||
".uvh": "video/vnd.dece.hd",
|
||||
".uvvh": "video/vnd.dece.hd",
|
||||
".uvm": "video/vnd.dece.mobile",
|
||||
".uvvm": "video/vnd.dece.mobile",
|
||||
".uvp": "video/vnd.dece.pd",
|
||||
".uvvp": "video/vnd.dece.pd",
|
||||
".uvs": "video/vnd.dece.sd",
|
||||
".uvvs": "video/vnd.dece.sd",
|
||||
".uvv": "video/vnd.dece.video",
|
||||
".uvvv": "video/vnd.dece.video",
|
||||
".dvb": "video/vnd.dvb.file",
|
||||
".fvt": "video/vnd.fvt",
|
||||
".mxu": "video/vnd.mpegurl",
|
||||
".pyv": "video/vnd.ms-playready.media.pyv",
|
||||
".uvu": "video/vnd.uvvu.mp4",
|
||||
".uvvu": "video/vnd.uvvu.mp4",
|
||||
".f4v": "video/x-f4v",
|
||||
".mk3d": "video/x-matroska",
|
||||
".mks": "video/x-matroska",
|
||||
".vob": "video/x-ms-vob",
|
||||
".smv": "video/x-smv",
|
||||
}
|
||||
|
||||
//nolint:gochecknoinits
|
||||
func init() {
|
||||
for ext, typ := range types {
|
||||
// skip errors
|
||||
_ = mime.AddExtensionType(ext, typ)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// MakeUserDir makes the user directory according to settings.
|
||||
func MakeUserDir(fullPath string) error {
|
||||
if err := os.MkdirAll(fullPath, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MakeUserDirs(u *users.User, createDir bool) error {
|
||||
cleanedUserName := users.CleanUsername(u.Username)
|
||||
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
|
||||
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
|
||||
}
|
||||
for i, scope := range u.Scopes {
|
||||
info, ok := users.ResolveSourceKey(scope.Name)
|
||||
if !ok {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
}
|
||||
source, ok := settings.Config.Server.SourceMap[info.Path]
|
||||
if !ok {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
}
|
||||
fullPath := filepath.Join(source.Path, scope.Scope)
|
||||
parentDir := filepath.Dir(fullPath)
|
||||
if createDir {
|
||||
if source.Config.CreateUserDir && filepath.Base(scope.Scope) != cleanedUserName {
|
||||
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
|
||||
fullPath = filepath.Join(fullPath, cleanedUserName)
|
||||
}
|
||||
if !Exists(parentDir) {
|
||||
if err := MakeUserDir(parentDir); err != nil {
|
||||
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
}
|
||||
} else {
|
||||
if !Exists(fullPath) {
|
||||
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
u.Scopes[i] = scope
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
assetFs fs.FS
|
||||
)
|
||||
|
||||
// Custom dirFS to handle both embedded and non-embedded file systems
|
||||
type dirFS struct {
|
||||
http.Dir
|
||||
}
|
||||
|
||||
// Implement the Open method for dirFS, which wraps http.Dir
|
||||
func (d dirFS) Open(name string) (fs.File, error) {
|
||||
return d.Dir.Open(name)
|
||||
}
|
||||
|
||||
// InitAssetFS initializes the asset filesystem for the application
|
||||
// This should be called once during startup before http or preview services start
|
||||
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
|
||||
if useEmbedded {
|
||||
assetFs = embeddedAssets
|
||||
} else {
|
||||
// Dev mode: Serve files from http/dist directory
|
||||
assetFs = dirFS{Dir: http.Dir("http/dist")}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAssetFS returns the initialized asset filesystem
|
||||
func GetAssetFS() fs.FS {
|
||||
return assetFs
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,261 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var PermFile os.FileMode
|
||||
var PermDir os.FileMode
|
||||
|
||||
// SetFsPermissions sets create modes from Unix chmod(2)-style values (for example
|
||||
// values produced by strconv.ParseUint(s, 8, 32)). setuid, setgid, and sticky
|
||||
// bits must be converted for Go's os.FileMode; a plain cast from Unix octal is
|
||||
// incorrect for those bits.
|
||||
func SetFsPermissions(unixFileMode, unixDirMode uint32) {
|
||||
PermFile = unixModeToFileMode(unixFileMode)
|
||||
PermDir = unixModeToFileMode(unixDirMode)
|
||||
}
|
||||
|
||||
// EffectiveDirPerm returns [PermDir] if [SetFsPermissions] was called, otherwise 0o755. On Unix,
|
||||
// [os.Mkdir] with perm 0 fails with permission denied, so use this (or [PermFile]) for creates when
|
||||
// the process has not set globals yet.
|
||||
func EffectiveDirPerm() os.FileMode {
|
||||
if PermDir == 0 {
|
||||
return 0o755
|
||||
}
|
||||
return PermDir
|
||||
}
|
||||
|
||||
// EffectiveFilePerm returns [PermFile] if set, otherwise 0o644.
|
||||
func EffectiveFilePerm() os.FileMode {
|
||||
if PermFile == 0 {
|
||||
return 0o644
|
||||
}
|
||||
return PermFile
|
||||
}
|
||||
|
||||
func unixModeToFileMode(u uint32) os.FileMode {
|
||||
m := os.FileMode(u & 0777)
|
||||
if u&0o4000 != 0 {
|
||||
m |= os.ModeSetuid
|
||||
}
|
||||
if u&0o2000 != 0 {
|
||||
m |= os.ModeSetgid
|
||||
}
|
||||
if u&0o1000 != 0 {
|
||||
m |= os.ModeSticky
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// MoveFile moves a file from src to dst.
|
||||
// By default, the rename system call is used. If src and dst point to different volumes,
|
||||
// the file copy is used as a fallback.
|
||||
func MoveFile(src, dst string) error {
|
||||
err := os.Rename(src, dst)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fallback
|
||||
err = CopyFile(src, dst)
|
||||
if err != nil {
|
||||
logger.Errorf("CopyFile failed %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
err = os.RemoveAll(src)
|
||||
if err != nil {
|
||||
logger.Errorf("os.Remove failed %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyFile copies a file or directory from source to dest and returns an error if any.
|
||||
func CopyFile(source, dest string) error {
|
||||
// Check if the source exists and whether it's a file or directory.
|
||||
info, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// If the source is a directory, copy it recursively.
|
||||
return copyDirectory(source, dest)
|
||||
}
|
||||
|
||||
// If the source is a file, copy the file.
|
||||
return copySingleFile(source, dest)
|
||||
}
|
||||
|
||||
// copySingleFile handles copying a single file.
|
||||
func copySingleFile(source, dest string) error {
|
||||
// Get source file info to preserve permissions
|
||||
srcInfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourcePerms := srcInfo.Mode().Perm()
|
||||
|
||||
// Open the source file.
|
||||
src, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// Create the destination directory if needed.
|
||||
err = os.MkdirAll(filepath.Dir(dest), PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the destination file with source permissions
|
||||
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// Copy the contents of the file.
|
||||
_, err = io.Copy(dst, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Preserve source file permissions
|
||||
// Handle chmod errors gracefully (e.g., in rootless containers where chmod may be restricted)
|
||||
err = os.Chmod(dest, sourcePerms)
|
||||
if err != nil {
|
||||
// Log but don't fail - chmod may be restricted in some environments
|
||||
// The file was copied successfully, so we continue
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyDirectory handles copying directories recursively.
|
||||
func copyDirectory(source, dest string) error {
|
||||
// Create the destination directory.
|
||||
err := os.MkdirAll(dest, PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read the contents of the source directory.
|
||||
entries, err := os.ReadDir(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Iterate over each entry in the directory.
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(source, entry.Name())
|
||||
destPath := filepath.Join(dest, entry.Name())
|
||||
|
||||
if entry.IsDir() {
|
||||
// Recursively copy subdirectories.
|
||||
err = copyDirectory(srcPath, destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Copy files.
|
||||
err = copySingleFile(srcPath, destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommonPrefix returns the common directory path of provided files.
|
||||
func CommonPrefix(sep byte, paths ...string) string {
|
||||
// Handle special cases.
|
||||
switch len(paths) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return path.Clean(paths[0])
|
||||
}
|
||||
|
||||
// Treat string as []byte, not []rune as is often done in Go.
|
||||
c := []byte(path.Clean(paths[0]))
|
||||
|
||||
// Add a trailing sep to handle the case where the common prefix directory
|
||||
// is included in the path list.
|
||||
c = append(c, sep)
|
||||
|
||||
// Ignore the first path since it's already in c.
|
||||
for _, v := range paths[1:] {
|
||||
// Clean up each path before testing it.
|
||||
v = path.Clean(v) + string(sep)
|
||||
|
||||
// Find the first non-common byte and truncate c.
|
||||
if len(v) < len(c) {
|
||||
c = c[:len(v)]
|
||||
}
|
||||
for i := 0; i < len(c); i++ {
|
||||
if v[i] != c[i] {
|
||||
c = c[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing non-separator characters and the final separator.
|
||||
for i := len(c) - 1; i >= 0; i-- {
|
||||
if c[i] == sep {
|
||||
c = c[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func ClearCacheDir(cacheDir string) {
|
||||
entries, err := os.ReadDir(cacheDir)
|
||||
if err != nil {
|
||||
logger.Errorf("failed clear cache dir: %v", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(cacheDir, entry.Name())
|
||||
err = os.RemoveAll(path)
|
||||
if err != nil {
|
||||
logger.Errorf("failed clear cache dir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ClearDirectoryContents removes all files and subdirectories inside dir; dir itself remains.
|
||||
// If dir does not exist, returns nil.
|
||||
func ClearDirectoryContents(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
package fileutils
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnixModeToFileMode_setgid(t *testing.T) {
|
||||
m := unixModeToFileMode(0o2770)
|
||||
if got := m.Perm(); got != 0o770 {
|
||||
t.Fatalf("Perm() = %#o, want 0770", got)
|
||||
}
|
||||
if m&os.ModeSetgid == 0 {
|
||||
t.Fatal("expected ModeSetgid")
|
||||
}
|
||||
if m&os.ModeSetuid != 0 || m&os.ModeSticky != 0 {
|
||||
t.Fatalf("unexpected special bits: %#o", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonPrefix(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build !windows
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// GetPartitionSize returns the filesystem size for Unix systems
|
||||
func GetPartitionSize(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Total size in bytes: Blocks * Block size
|
||||
total := uint64(stat.Blocks) * uint64(stat.Bsize)
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// GetFreeSpace returns the available free space for Unix systems
|
||||
func GetFreeSpace(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Available free space in bytes: Available blocks * Block size
|
||||
free := uint64(stat.Bavail) * uint64(stat.Bsize)
|
||||
return free, nil
|
||||
}
|
||||
|
||||
// GetPartitionUsed returns the used space for Unix systems (total - free)
|
||||
func GetPartitionUsed(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := uint64(stat.Blocks) * uint64(stat.Bsize)
|
||||
free := uint64(stat.Bavail) * uint64(stat.Bsize)
|
||||
used := total - free
|
||||
return used, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build windows
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// GetPartitionSize returns the filesystem size for Windows systems
|
||||
func GetPartitionSize(path string) (uint64, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var freeBytes, totalBytes, totalFreeBytes uint64
|
||||
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalBytes, nil
|
||||
}
|
||||
|
||||
// GetFreeSpace returns the available free space for Windows systems
|
||||
func GetFreeSpace(path string) (uint64, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var freeBytes, totalBytes, totalFreeBytes uint64
|
||||
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return freeBytes, nil
|
||||
}
|
||||
|
||||
// GetPartitionUsed returns the used space for Windows systems (total - free)
|
||||
func GetPartitionUsed(path string) (uint64, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var freeBytes, totalBytes, totalFreeBytes uint64
|
||||
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
used := totalBytes - freeBytes
|
||||
return used, nil
|
||||
}
|
||||
+53
-3
@@ -1,15 +1,65 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/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
|
||||
}
|
||||
|
||||
const FB_ISSUER = "FileBrowser Quantum" // don't change
|
||||
|
||||
// IsRevokedApiToken checks if a token is in the revoked list.
|
||||
func IsRevokedApiToken(accessStore *access.Storage, token string) bool {
|
||||
if accessStore == nil {
|
||||
return false
|
||||
}
|
||||
return accessStore.IsTokenRevoked(token)
|
||||
}
|
||||
|
||||
// RevokeApiToken adds a token to the revoked list.
|
||||
func RevokeApiToken(accessStore *access.Storage, token string) error {
|
||||
if accessStore == nil {
|
||||
return fmt.Errorf("access storage not available")
|
||||
}
|
||||
return accessStore.RevokeToken(token)
|
||||
}
|
||||
|
||||
func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions, minimal bool) (string, users.AuthToken, error) {
|
||||
if _, ok := user.Tokens[name]; ok {
|
||||
return "", users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
|
||||
}
|
||||
now := time.Now()
|
||||
expires := now.Add(duration)
|
||||
// Create minimal token with only JWT standard claims
|
||||
claim := users.AuthToken{
|
||||
MinimalAuthToken: users.MinimalAuthToken{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expires),
|
||||
Issuer: FB_ISSUER,
|
||||
},
|
||||
},
|
||||
}
|
||||
if !minimal {
|
||||
claim.Permissions = perms
|
||||
claim.BelongsTo = user.ID
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
|
||||
tokenString, err := token.SignedString([]byte(settings.Config.Auth.Key))
|
||||
if err != nil {
|
||||
return "", users.AuthToken{}, err
|
||||
}
|
||||
return tokenString, claim, nil
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+45
-28
@@ -2,42 +2,38 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/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"`
|
||||
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
|
||||
DisableOtp bool `json:"disableOtp" yaml:"disableOtp"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
|
||||
username := r.URL.Query().Get("username")
|
||||
recaptcha := r.URL.Query().Get("recaptcha")
|
||||
password := r.Header.Get("X-Password")
|
||||
totpCode := r.Header.Get("X-Secret")
|
||||
// URL-decode password to support special characters in headers
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return nil, os.ErrPermission
|
||||
return nil, fmt.Errorf("invalid password encoding: %v", err)
|
||||
}
|
||||
|
||||
// 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 auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
|
||||
ok, err := auther.ReCaptcha.Ok(recaptcha) //nolint:govet
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -47,17 +43,38 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
|
||||
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
|
||||
|
||||
user, getErr := userStore.Get(username)
|
||||
var passwordHash string
|
||||
if getErr != nil {
|
||||
passwordHash = utils.InvalidPasswordHash
|
||||
} else {
|
||||
passwordHash = user.Password
|
||||
}
|
||||
// always run checkPwd to prevent timing attacks
|
||||
err = utils.CheckPwd(password, passwordHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
}
|
||||
if user.TOTPSecret != "" && !auther.DisableOtp {
|
||||
if totpCode == "" {
|
||||
return nil, errors.ErrNoTotpProvided
|
||||
}
|
||||
err = VerifyTotpCode(user, totpCode, userStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
return nil, errors.ErrWrongLoginMethod
|
||||
}
|
||||
|
||||
return user, nil
|
||||
|
||||
// LoginPage tells that json auth doesn't require a login page.
|
||||
func (a JSONAuth) LoginPage() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
const reCaptchaAPI = "/recaptcha/api/siteverify"
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
// Package auth_test contains timing attack tests for authentication.
|
||||
//
|
||||
// These tests verify that the JSON authentication implementation is protected
|
||||
// against timing attacks that could leak information about valid usernames.
|
||||
//
|
||||
// Timing Attack Background:
|
||||
// A timing attack is a side-channel attack where an attacker measures how long
|
||||
// authentication operations take to determine if a username exists in the system.
|
||||
// If authentication fails faster for non-existent users than for valid users
|
||||
// with wrong passwords, an attacker can enumerate valid usernames.
|
||||
//
|
||||
// Protection Mechanism:
|
||||
// The json.go Auth() implementation protects against timing attacks by:
|
||||
// 1. Always running the bcrypt password comparison, even for non-existent users
|
||||
// 2. Using utils.InvalidPasswordHash (a pre-computed bcrypt hash) when the user doesn't exist
|
||||
// 3. Ensuring both valid and invalid user authentication paths take similar time
|
||||
//
|
||||
// Test Coverage:
|
||||
// - TestJSONAuth_NoTimingAttack: Statistical analysis of timing differences
|
||||
// - TestJSONAuth_InvalidPasswordHashInitialized: Verifies protection is in place
|
||||
// - TestJSONAuth_ValidUserVsInvalidUserDirectComparison: Direct comparison test
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// mockUserBackend is a simple in-memory user storage for testing.
|
||||
type mockUserBackend struct {
|
||||
usersByUsername map[string]*users.User
|
||||
usersByID map[uint]*users.User
|
||||
}
|
||||
|
||||
func newMockUserBackend() *mockUserBackend {
|
||||
return &mockUserBackend{
|
||||
usersByUsername: make(map[string]*users.User),
|
||||
usersByID: make(map[uint]*users.User),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) GetBy(id interface{}) (*users.User, error) {
|
||||
switch v := id.(type) {
|
||||
case string:
|
||||
if user, ok := m.usersByUsername[v]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, fmt.Errorf("user not found: %s", v)
|
||||
case uint:
|
||||
if user, ok := m.usersByID[v]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, fmt.Errorf("user not found: %d", v)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid id type")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) Gets() ([]*users.User, error) {
|
||||
result := make([]*users.User, 0, len(m.usersByID))
|
||||
for _, user := range m.usersByID {
|
||||
result = append(result, user)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) Save(u *users.User, changePass bool, disableScopeChange bool) error {
|
||||
m.usersByUsername[u.Username] = u
|
||||
m.usersByID[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) Update(u *users.User, adminActor bool, fields ...string) error {
|
||||
m.usersByUsername[u.Username] = u
|
||||
m.usersByID[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) DeleteByID(id uint) error {
|
||||
if user, ok := m.usersByID[id]; ok {
|
||||
delete(m.usersByUsername, user.Username)
|
||||
delete(m.usersByID, id)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user not found: %d", id)
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) DeleteByUsername(username string) error {
|
||||
if user, ok := m.usersByUsername[username]; ok {
|
||||
delete(m.usersByID, user.ID)
|
||||
delete(m.usersByUsername, username)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
// setupTestUsers creates a user storage with 2 valid test users
|
||||
func setupTestUsers(t *testing.T) *users.Storage {
|
||||
t.Helper()
|
||||
|
||||
// Reduce bcrypt cost for faster tests (default is 10, min is 4)
|
||||
// This significantly speeds up password hashing during tests
|
||||
originalCost := utils.BcryptCost
|
||||
utils.BcryptCost = 4 // Minimum bcrypt cost for testing
|
||||
t.Cleanup(func() {
|
||||
utils.BcryptCost = originalCost // Restore original cost after test
|
||||
})
|
||||
|
||||
// Initialize InvalidPasswordHash to prevent timing attacks
|
||||
// This simulates the application startup initialization
|
||||
err := utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set invalid password hash: %v", err)
|
||||
}
|
||||
|
||||
backend := newMockUserBackend()
|
||||
storage := users.NewStorage(backend)
|
||||
|
||||
// Create two valid users with hashed passwords
|
||||
password1Hash, err := utils.HashPwd("password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
password2Hash, err := utils.HashPwd("securepass456")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
user1 := &users.User{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password1Hash,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
|
||||
user2 := &users.User{
|
||||
ID: 2,
|
||||
Username: "testuser",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password2Hash,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
|
||||
err = storage.Save(user1, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save user1: %v", err)
|
||||
}
|
||||
|
||||
err = storage.Save(user2, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save user2: %v", err)
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// measureAuthTime measures the time taken to authenticate with given credentials
|
||||
func measureAuthTime(t *testing.T, auther auth.JSONAuth, userStore *users.Storage, username, password string) time.Duration {
|
||||
t.Helper()
|
||||
|
||||
// Create a mock HTTP request
|
||||
req, err := http.NewRequest("POST", "/api/auth/login?username="+url.QueryEscape(username), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Password", url.QueryEscape(password))
|
||||
|
||||
start := time.Now()
|
||||
_, _ = auther.Auth(req, userStore)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
return elapsed
|
||||
}
|
||||
|
||||
// TestJSONAuth_NoTimingAttack tests that authentication timing does not leak user existence
|
||||
func TestJSONAuth_NoTimingAttack(t *testing.T) {
|
||||
storage := setupTestUsers(t)
|
||||
auther := auth.JSONAuth{}
|
||||
|
||||
// Test cases: mix of valid and invalid usernames
|
||||
testCases := []struct {
|
||||
username string
|
||||
isValid bool
|
||||
}{
|
||||
{"admin", true},
|
||||
{"testuser", true},
|
||||
{"nonexistent1", false},
|
||||
{"invaliduser", false},
|
||||
{"notauser", false},
|
||||
{"fakeadmin", false},
|
||||
}
|
||||
|
||||
// Number of samples per test case for statistical analysis
|
||||
samplesPerCase := 5
|
||||
|
||||
// Collect timing measurements
|
||||
measurements := make(map[string][]time.Duration)
|
||||
for _, tc := range testCases {
|
||||
measurements[tc.username] = make([]time.Duration, 0, samplesPerCase)
|
||||
}
|
||||
|
||||
// Run multiple iterations to gather statistical data
|
||||
for i := 0; i < samplesPerCase; i++ {
|
||||
// Randomize order to avoid cache effects
|
||||
for _, tc := range testCases {
|
||||
elapsed := measureAuthTime(t, auther, storage, tc.username, "wrongpassword123")
|
||||
measurements[tc.username] = append(measurements[tc.username], elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate statistics for each test case
|
||||
type stats struct {
|
||||
mean time.Duration
|
||||
stddev time.Duration
|
||||
min time.Duration
|
||||
max time.Duration
|
||||
}
|
||||
|
||||
calculateStats := func(durations []time.Duration) stats {
|
||||
if len(durations) == 0 {
|
||||
return stats{}
|
||||
}
|
||||
|
||||
// Calculate mean
|
||||
var sum time.Duration
|
||||
for _, d := range durations {
|
||||
sum += d
|
||||
}
|
||||
mean := sum / time.Duration(len(durations))
|
||||
|
||||
// Calculate standard deviation
|
||||
var variance float64
|
||||
for _, d := range durations {
|
||||
diff := float64(d - mean)
|
||||
variance += diff * diff
|
||||
}
|
||||
variance /= float64(len(durations))
|
||||
stddev := time.Duration(math.Sqrt(variance))
|
||||
|
||||
// Find min and max
|
||||
min := durations[0]
|
||||
max := durations[0]
|
||||
for _, d := range durations {
|
||||
if d < min {
|
||||
min = d
|
||||
}
|
||||
if d > max {
|
||||
max = d
|
||||
}
|
||||
}
|
||||
|
||||
return stats{mean: mean, stddev: stddev, min: min, max: max}
|
||||
}
|
||||
|
||||
// Analyze results
|
||||
validUserStats := make([]stats, 0)
|
||||
invalidUserStats := make([]stats, 0)
|
||||
|
||||
t.Log("\n=== Timing Analysis Results ===")
|
||||
for _, tc := range testCases {
|
||||
s := calculateStats(measurements[tc.username])
|
||||
t.Logf("%s (valid=%v): mean=%.4fms, stddev=%.4fms, min=%.4fms, max=%.4fms",
|
||||
tc.username, tc.isValid,
|
||||
float64(s.mean.Microseconds())/1000.0,
|
||||
float64(s.stddev.Microseconds())/1000.0,
|
||||
float64(s.min.Microseconds())/1000.0,
|
||||
float64(s.max.Microseconds())/1000.0)
|
||||
|
||||
if tc.isValid {
|
||||
validUserStats = append(validUserStats, s)
|
||||
} else {
|
||||
invalidUserStats = append(invalidUserStats, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall statistics for valid vs invalid users
|
||||
var validMeanSum, invalidMeanSum time.Duration
|
||||
for _, s := range validUserStats {
|
||||
validMeanSum += s.mean
|
||||
}
|
||||
for _, s := range invalidUserStats {
|
||||
invalidMeanSum += s.mean
|
||||
}
|
||||
|
||||
validAvgMean := validMeanSum / time.Duration(len(validUserStats))
|
||||
invalidAvgMean := invalidMeanSum / time.Duration(len(invalidUserStats))
|
||||
|
||||
t.Logf("\n=== Summary ===")
|
||||
t.Logf("Valid users average: %.4fms", float64(validAvgMean.Microseconds())/1000.0)
|
||||
t.Logf("Invalid users average: %.4fms", float64(invalidAvgMean.Microseconds())/1000.0)
|
||||
|
||||
// Calculate the percentage difference
|
||||
diff := float64(validAvgMean - invalidAvgMean)
|
||||
percentDiff := math.Abs(diff) / float64(validAvgMean) * 100.0
|
||||
t.Logf("Difference: %.4fms (%.2f%%)", diff/1e6, percentDiff)
|
||||
|
||||
// Assert: The timing difference should be less than 20%
|
||||
// Bcrypt's constant-time comparison should make both cases take similar time
|
||||
// We allow 20% threshold to account for system noise and variability
|
||||
threshold := 20.0 // percent
|
||||
if percentDiff > threshold {
|
||||
t.Errorf("TIMING ATTACK VULNERABILITY DETECTED: Valid vs invalid user timing differs by %.2f%% (threshold: %.2f%%)",
|
||||
percentDiff, threshold)
|
||||
t.Errorf("This difference could allow an attacker to enumerate valid usernames!")
|
||||
} else {
|
||||
t.Logf("✓ No timing attack vulnerability detected (difference: %.2f%% < threshold: %.2f%%)",
|
||||
percentDiff, threshold)
|
||||
}
|
||||
|
||||
// Additional check: Ensure no individual case is an obvious outlier
|
||||
// Check if any single user's timing is significantly faster than bcrypt operations
|
||||
allStats := append(validUserStats, invalidUserStats...)
|
||||
var allMeansSum time.Duration
|
||||
for _, s := range allStats {
|
||||
allMeansSum += s.mean
|
||||
}
|
||||
overallMean := allMeansSum / time.Duration(len(allStats))
|
||||
|
||||
for _, tc := range testCases {
|
||||
s := calculateStats(measurements[tc.username])
|
||||
deviation := math.Abs(float64(s.mean-overallMean)) / float64(overallMean) * 100.0
|
||||
|
||||
// If any user is more than 30% faster/slower than average, it's suspicious
|
||||
if deviation > 30.0 {
|
||||
t.Errorf("OUTLIER DETECTED: %s deviates by %.2f%% from overall mean (%.4fms vs %.4fms)",
|
||||
tc.username, deviation,
|
||||
float64(s.mean.Microseconds())/1000.0,
|
||||
float64(overallMean.Microseconds())/1000.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJSONAuth_InvalidPasswordHashInitialized verifies that InvalidPasswordHash is properly set
|
||||
func TestJSONAuth_InvalidPasswordHashInitialized(t *testing.T) {
|
||||
// This test ensures that the InvalidPasswordHash is initialized
|
||||
// If it's empty, timing attacks would be possible because invalid users
|
||||
// would skip bcrypt comparison entirely
|
||||
|
||||
// Use lower bcrypt cost for faster testing
|
||||
originalCost := utils.BcryptCost
|
||||
utils.BcryptCost = 4
|
||||
t.Cleanup(func() {
|
||||
utils.BcryptCost = originalCost
|
||||
})
|
||||
|
||||
err := utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set invalid password hash: %v", err)
|
||||
}
|
||||
|
||||
if utils.InvalidPasswordHash == "" {
|
||||
t.Fatal("InvalidPasswordHash is not initialized - timing attack vulnerability exists!")
|
||||
}
|
||||
|
||||
// Verify that the invalid hash can be used for password comparison
|
||||
// This should fail (wrong password) but take the same time as a real bcrypt comparison
|
||||
err = utils.CheckPwd("anypassword", utils.InvalidPasswordHash)
|
||||
if err == nil {
|
||||
t.Fatal("CheckPwd should fail with InvalidPasswordHash")
|
||||
}
|
||||
|
||||
t.Log("✓ InvalidPasswordHash is properly initialized")
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// MethodJwtAuth is used to identify JWT external authentication.
|
||||
const MethodJwtAuth = "jwt"
|
||||
|
||||
// JwtAuth is an external JWT implementation of an auther.
|
||||
// It verifies JWT tokens signed with a shared secret and extracts user identity.
|
||||
type JwtAuth struct {
|
||||
Secret string `json:"secret"` // shared secret for verifying JWT signatures
|
||||
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256, etc.)
|
||||
}
|
||||
|
||||
// ExternalJwtClaims represents the expected claims in an external JWT token
|
||||
type ExternalJwtClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Username string `json:"sub"` // subject claim (username)
|
||||
Email string `json:"email,omitempty"` // email claim
|
||||
Name string `json:"name,omitempty"` // full name claim
|
||||
Groups []string `json:"groups,omitempty"` // groups claim
|
||||
Custom map[string]interface{} `json:"-"` // catch-all for custom claims
|
||||
}
|
||||
|
||||
// Auth authenticates the user via an external JWT token.
|
||||
// The token is verified using the configured secret and algorithm.
|
||||
func (a JwtAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||
// This should not be called directly - JWT auth is handled in middleware
|
||||
// because we need access to the full config (header name, user identifier field, etc.)
|
||||
return nil, fmt.Errorf("JWT auth must be handled in middleware")
|
||||
}
|
||||
|
||||
// VerifyExternalJWT verifies an external JWT token and extracts the username claim
|
||||
func VerifyExternalJWT(tokenString string, secret string, algorithm string, userIdentifierField string) (string, map[string]interface{}, error) {
|
||||
// Parse the token with the secret
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify the signing method matches what we expect
|
||||
expectedMethod := jwt.GetSigningMethod(algorithm)
|
||||
if expectedMethod == nil {
|
||||
return nil, fmt.Errorf("unsupported signing algorithm: %s", algorithm)
|
||||
}
|
||||
|
||||
if token.Method.Alg() != expectedMethod.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return jwtVerificationKey(secret, token.Method, algorithm)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Debugf("JWT token verification failed: %v", err)
|
||||
return "", nil, fmt.Errorf("invalid JWT token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return "", nil, fmt.Errorf("JWT token is invalid")
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("failed to parse JWT claims")
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if err := claims.Valid(); err != nil {
|
||||
logger.Debugf("JWT claims validation failed: %v", err)
|
||||
return "", nil, fmt.Errorf("JWT token is expired or invalid: %w", err)
|
||||
}
|
||||
|
||||
// Extract username from the configured field (default: "sub")
|
||||
var username string
|
||||
if userVal, ok := claims[userIdentifierField]; ok {
|
||||
if userStr, ok := userVal.(string); ok {
|
||||
username = userStr
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return "", nil, fmt.Errorf("JWT token missing required claim: %s", userIdentifierField)
|
||||
}
|
||||
|
||||
// Convert claims to map for additional processing
|
||||
claimsMap := make(map[string]interface{})
|
||||
for k, v := range claims {
|
||||
claimsMap[k] = v
|
||||
}
|
||||
|
||||
logger.Debugf("Successfully verified JWT token for user: %s", username)
|
||||
return username, claimsMap, nil
|
||||
}
|
||||
|
||||
func jwtVerificationKey(secret string, method jwt.SigningMethod, algorithm string) (interface{}, error) {
|
||||
switch method.(type) {
|
||||
case *jwt.SigningMethodHMAC:
|
||||
return []byte(secret), nil
|
||||
case *jwt.SigningMethodRSA:
|
||||
key, err := parsePublicKeyFromPEM(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("JWT secret must contain an RSA public key in PEM format for algorithm %s", algorithm)
|
||||
}
|
||||
return rsaKey, nil
|
||||
case *jwt.SigningMethodECDSA:
|
||||
key, err := parsePublicKeyFromPEM(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ecKey, ok := key.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("JWT secret must contain an ECDSA public key in PEM format for algorithm %s", algorithm)
|
||||
}
|
||||
if err := ecdsaCurveMatchesAlgorithm(algorithm, ecKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ecKey, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported JWT signing method: %s", method.Alg())
|
||||
}
|
||||
}
|
||||
|
||||
func ecdsaCurveMatchesAlgorithm(algorithm string, pub *ecdsa.PublicKey) error {
|
||||
var want elliptic.Curve
|
||||
switch algorithm {
|
||||
case "ES256":
|
||||
want = elliptic.P256()
|
||||
case "ES384":
|
||||
want = elliptic.P384()
|
||||
case "ES512":
|
||||
want = elliptic.P521()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if pub.Curve != want {
|
||||
return fmt.Errorf("ECDSA public key curve does not match algorithm %s", algorithm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePublicKeyFromPEM(pemData string) (interface{}, error) {
|
||||
rest := []byte(pemData)
|
||||
var lastErr error
|
||||
for len(rest) > 0 {
|
||||
block, rem := pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
rest = rem
|
||||
|
||||
var key interface{}
|
||||
var err error
|
||||
switch block.Type {
|
||||
case "CERTIFICATE":
|
||||
var cert *x509.Certificate
|
||||
cert, err = x509.ParseCertificate(block.Bytes)
|
||||
if err == nil {
|
||||
key = cert.PublicKey
|
||||
}
|
||||
case "PUBLIC KEY":
|
||||
key, err = x509.ParsePKIXPublicKey(block.Bytes)
|
||||
case "RSA PUBLIC KEY":
|
||||
key, err = x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
case "EC PUBLIC KEY":
|
||||
key, err = x509.ParsePKIXPublicKey(block.Bytes)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if key != nil {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT public key from PEM: %w", lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("no public key found in JWT secret (expected PEM: CERTIFICATE, PUBLIC KEY, RSA PUBLIC KEY, or EC PUBLIC KEY)")
|
||||
}
|
||||
|
||||
// ExtractGroupsFromClaims extracts groups from JWT claims based on the configured groups claim field
|
||||
func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField string) []string {
|
||||
var groups []string
|
||||
|
||||
if groupsVal, ok := claims[groupsClaimField]; ok {
|
||||
switch v := groupsVal.(type) {
|
||||
case []interface{}:
|
||||
// Groups as array of interfaces
|
||||
for _, g := range v {
|
||||
if groupStr, ok := g.(string); ok {
|
||||
groups = append(groups, groupStr)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
// Groups as array of strings
|
||||
groups = v
|
||||
case string:
|
||||
// Single group as string
|
||||
groups = []string{v}
|
||||
default:
|
||||
// Try to unmarshal as JSON array
|
||||
if jsonBytes, err := json.Marshal(v); err == nil {
|
||||
var groupsArray []string
|
||||
if err := json.Unmarshal(jsonBytes, &groupsArray); err == nil {
|
||||
groups = groupsArray
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
)
|
||||
|
||||
func TestVerifyExternalJWT_ValidToken(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
username := "testuser"
|
||||
|
||||
// Create a valid JWT token
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"email": "test@example.com",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
extractedUsername, extractedClaims, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT() failed: %v", err)
|
||||
}
|
||||
|
||||
if extractedUsername != username {
|
||||
t.Errorf("Expected username %s, got %s", username, extractedUsername)
|
||||
}
|
||||
|
||||
if extractedClaims["email"] != "test@example.com" {
|
||||
t.Errorf("Expected email test@example.com, got %v", extractedClaims["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ExpiredToken(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
|
||||
// Create an expired JWT token
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "testuser",
|
||||
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
|
||||
"iat": time.Now().Add(-2 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token - should fail
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyExternalJWT() should fail for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_InvalidSignature(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
wrongSecret := "wrong-secret-key"
|
||||
algorithm := "HS256"
|
||||
|
||||
// Create a token with one secret
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "testuser",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Try to verify with a different secret - should fail
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, wrongSecret, algorithm, "sub")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyExternalJWT() should fail for invalid signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_MissingUsernameClaim(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
|
||||
// Create a token without the 'sub' claim
|
||||
claims := jwt.MapClaims{
|
||||
"email": "test@example.com",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token - should fail due to missing 'sub' claim
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyExternalJWT() should fail for missing username claim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
username := "testuser"
|
||||
|
||||
// Create a token with custom username field
|
||||
claims := jwt.MapClaims{
|
||||
"username": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token with custom user identifier field
|
||||
extractedUsername, _, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "username")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT() failed: %v", err)
|
||||
}
|
||||
|
||||
if extractedUsername != username {
|
||||
t.Errorf("Expected username %s, got %s", username, extractedUsername)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ES256_PublicKeyPEM(t *testing.T) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
|
||||
username := "ec-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "ES256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_RS256_PublicKeyPEM(t *testing.T) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
|
||||
username := "rsa-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "RS256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ES256_WrongCurveRejected(t *testing.T) {
|
||||
p256Priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
p384Priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
verifyDER, err := x509.MarshalPKIXPublicKey(&p384Priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
verifyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: verifyDER}))
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "u",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(p256Priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, verifyPEM, "ES256", "sub")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PEM public key curve does not match algorithm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_CertificatePEM(t *testing.T) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "jwt-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
||||
|
||||
username := "cert-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, certPEM, "ES256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_ArrayOfStrings(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []string{"admin", "users"},
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" || groups[1] != "users" {
|
||||
t.Errorf("Expected groups [admin, users], got %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_ArrayOfInterfaces(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []interface{}{"admin", "users"},
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" || groups[1] != "users" {
|
||||
t.Errorf("Expected groups [admin, users], got %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_SingleString(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": "admin",
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("Expected 1 group, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" {
|
||||
t.Errorf("Expected group admin, got %s", groups[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_MissingClaim(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"email": "test@example.com",
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 0 {
|
||||
t.Fatalf("Expected 0 groups, got %d", len(groups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_CustomClaimName(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"roles": []string{"admin", "editor"},
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "roles")
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" || groups[1] != "editor" {
|
||||
t.Errorf("Expected groups [admin, editor], got %v", groups)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package auth
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// MethodNoAuth is used to identify no auth.
|
||||
@@ -15,10 +14,5 @@ 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
|
||||
return usr.Get(uint(1))
|
||||
}
|
||||
+3
-10
@@ -4,10 +4,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// MethodProxyAuth is used to identify no auth.
|
||||
@@ -21,15 +19,10 @@ type ProxyAuth struct {
|
||||
// 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)
|
||||
user, err := usr.Get(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
|
||||
}
|
||||
+37
-3
@@ -1,7 +1,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"encoding/base64"
|
||||
|
||||
"crypto/sha256"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// StorageBackend is a storage backend for auth storage.
|
||||
@@ -17,8 +24,35 @@ type Storage struct {
|
||||
}
|
||||
|
||||
// NewStorage creates a auth storage from a backend.
|
||||
func NewStorage(back StorageBackend, userStore *users.Storage) *Storage {
|
||||
return &Storage{back: back, users: userStore}
|
||||
func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error) {
|
||||
store := &Storage{back: back, users: userStore}
|
||||
err := store.Save(&JSONAuth{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = store.Save(&ProxyAuth{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = store.Save(&NoAuth{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if settings.Config.Auth.TotpSecret != "" {
|
||||
key, err := base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
|
||||
keyLen := len(key)
|
||||
|
||||
if err == nil && (keyLen == 16 || keyLen == 24 || keyLen == 32) {
|
||||
// Use the user-provided key if it's valid
|
||||
encryptionKey = key
|
||||
} else {
|
||||
// Otherwise, derive the key from the provided secret string
|
||||
logger.Warningf("totpSecret is not a valid Base64 encoded key. Deriving a key from it. For better security, generate a secret with 'openssl rand -base64 32'.")
|
||||
salt := []byte{0xda, 0x90, 0x45, 0xc3, 0x06, 0xb5, 0x99, 0x9f, 0xb6, 0xae, 0xfc, 0x14, 0xef, 0x27, 0x6e, 0x6a}
|
||||
encryptionKey = pbkdf2.Key([]byte(settings.Config.Auth.TotpSecret), salt, 4096, 32, sha256.New)
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// Get wraps a StorageBackend.Get.
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
// Constants for TOTP (Time-based One-Time Password) configuration.
|
||||
const (
|
||||
// IssuerName is the name of the application or service.
|
||||
IssuerName = "FileBrowser Quantum"
|
||||
|
||||
// TokenValidTime defines the total duration a token is considered valid.
|
||||
// Note: The actual validation window might be slightly longer depending on the period.
|
||||
TokenValidTime = time.Minute * 2
|
||||
|
||||
// TOTPPeriod is the standard duration in seconds that a TOTP code is valid.
|
||||
TOTPPeriod uint = 30
|
||||
|
||||
// TOTPSecretSize is the byte length of the shared secret.
|
||||
TOTPSecretSize uint = 20
|
||||
|
||||
// TOTPDigits specifies the number of digits in the OTP code.
|
||||
TOTPDigits = otp.DigitsSix
|
||||
|
||||
// TOTPAlgorithm is the hashing algorithm to use.
|
||||
TOTPAlgorithm = otp.AlgorithmSHA1
|
||||
)
|
||||
|
||||
var (
|
||||
// TOTPSkew allows for a certain number of periods of clock drift.
|
||||
// We calculate it to best match the TokenValidTime.
|
||||
// (2 * Skew + 1) * Period >= TokenValidTime
|
||||
// For a 2-minute (120s) window with a 30s period, we need a Skew of 2.
|
||||
// (2*2 + 1) * 30s = 150s (2.5 minutes). This is the closest we can get.
|
||||
TOTPSkew uint = uint(TokenValidTime.Seconds()) / (2 * uint(TOTPPeriod))
|
||||
encryptionKey []byte
|
||||
TotpCache = cache.NewCache[string](5 * time.Minute)
|
||||
)
|
||||
|
||||
func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, error) {
|
||||
// Generate a new TOTP key using the defined constants.
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: IssuerName,
|
||||
AccountName: user.Username,
|
||||
Period: TOTPPeriod,
|
||||
SecretSize: TOTPSecretSize,
|
||||
Digits: TOTPDigits,
|
||||
Algorithm: TOTPAlgorithm,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error generating TOTP key: %w", err)
|
||||
}
|
||||
|
||||
secretText := key.Secret()
|
||||
nonce := ""
|
||||
secretToStore := secretText
|
||||
if settings.Config.Auth.TotpSecret != "" {
|
||||
// If an encryption key is provided, encrypt the secret.
|
||||
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
|
||||
}
|
||||
}
|
||||
// set cache so verify can attempt to use it but not require it for user yet.
|
||||
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
|
||||
// Use the original base32 secret in the OTP URL, not the encrypted version
|
||||
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// encryptSecret uses AES-GCM to encrypt a plaintext secret.
|
||||
// It returns the base64-encoded ciphertext and nonce, or an error.
|
||||
func encryptSecret(secret string, key []byte) (string, string, error) {
|
||||
if len(key) != 32 {
|
||||
return "", "", fmt.Errorf("invalid encryption key length: must be 32 bytes")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nil, nonce, []byte(secret), nil)
|
||||
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), base64.StdEncoding.EncodeToString(nonce), nil
|
||||
}
|
||||
|
||||
// decryptSecret uses AES-GCM to decrypt a ciphertext using its key and nonce.
|
||||
// It returns the plaintext secret or an error.
|
||||
func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
|
||||
if len(encryptionKey) != 32 {
|
||||
return "", fmt.Errorf("invalid encryption key length: must be 32 bytes")
|
||||
}
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(b64Ciphertext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
|
||||
}
|
||||
|
||||
nonce, err := base64.StdEncoding.DecodeString(b64Nonce)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode nonce: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
// This error often means the key is wrong or the data is corrupt
|
||||
return "", fmt.Errorf("failed to decrypt secret: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
|
||||
cachedSecret, found := TotpCache.Get(user.Username)
|
||||
if !found && user.TOTPSecret == "" {
|
||||
return fmt.Errorf("OTP token not found in cache, please generate a new one")
|
||||
}
|
||||
useCache := found && user.TOTPSecret == ""
|
||||
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
|
||||
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
|
||||
if useCache {
|
||||
splitSecret := strings.Split(cachedSecret, "||")
|
||||
if len(splitSecret) < 2 {
|
||||
return fmt.Errorf("invalid cached OTP token format")
|
||||
}
|
||||
totpSecret = splitSecret[0]
|
||||
totpNonce = splitSecret[1]
|
||||
}
|
||||
secretToValidate := totpSecret
|
||||
if settings.Config.Auth.TotpSecret != "" {
|
||||
// If an encryption key is configured, we must decrypt the secret first.
|
||||
if totpNonce == "" {
|
||||
return fmt.Errorf("secret is encrypted but nonce is missing")
|
||||
}
|
||||
decryptedSecret, err := decryptSecret(totpSecret, totpNonce)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt TOTP secret: %w", err)
|
||||
}
|
||||
secretToValidate = decryptedSecret
|
||||
}
|
||||
|
||||
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
|
||||
Period: TOTPPeriod,
|
||||
Skew: TOTPSkew,
|
||||
Digits: TOTPDigits,
|
||||
Algorithm: TOTPAlgorithm,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
|
||||
}
|
||||
if !valid {
|
||||
logger.Warningf("Invalid TOTP code for user %s", user.Username)
|
||||
return fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
if totpSecret != "" || !user.OtpEnabled {
|
||||
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
|
||||
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
|
||||
user.OtpEnabled = true // Enable OTP for the user
|
||||
// save user
|
||||
if err := userStore.Update(user, true, "TOTPSecret", "TOTPNonce", "OtpEnabled"); err != nil {
|
||||
logger.Error("error updating user with OTP token:", err)
|
||||
return fmt.Errorf("error updating user with OTP token: %w", err)
|
||||
}
|
||||
TotpCache.Delete(user.Username)
|
||||
} else {
|
||||
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
type webAuthnSession struct {
|
||||
SessionData *webauthn.SessionData
|
||||
UserID uint
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type WebAuthnService struct {
|
||||
wa *webauthn.WebAuthn
|
||||
sessions sync.Map
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
var webAuthnInstance *WebAuthnService
|
||||
|
||||
func InitWebAuthn() error {
|
||||
cfg := &settings.Config.Auth.Methods.PasskeyAuth
|
||||
if !cfg.Enabled {
|
||||
webAuthnInstance = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
waCfg, err := deriveWebAuthnConfig(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webauthn config derivation failed: %w", err)
|
||||
}
|
||||
|
||||
wa, err := webauthn.New(waCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize WebAuthn: %w", err)
|
||||
}
|
||||
|
||||
webAuthnInstance = &WebAuthnService{
|
||||
wa: wa,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go webAuthnInstance.cleanupExpiredSessions()
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetWebAuthn() *WebAuthnService {
|
||||
return webAuthnInstance
|
||||
}
|
||||
|
||||
func IsWebAuthnEnabled() bool {
|
||||
return webAuthnInstance != nil
|
||||
}
|
||||
|
||||
// ValidateOrigin checks if the given origin is in the configured whitelist.
|
||||
// Returns true if the origin is allowed, false otherwise.
|
||||
func (s *WebAuthnService) ValidateOrigin(origin string) bool {
|
||||
for _, allowedOrigin := range s.wa.Config.RPOrigins {
|
||||
if allowedOrigin == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func generateSessionID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return hex.EncodeToString([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) BeginMFALogin(username, password string, rpID string, userStore *users.Storage) (sessionID string, assertion *protocol.CredentialAssertion, err error) {
|
||||
user, getErr := userStore.Get(username)
|
||||
passwordHash := utils.InvalidPasswordHash
|
||||
if getErr == nil {
|
||||
passwordHash = user.Password
|
||||
}
|
||||
// Always run password check to prevent timing-based username enumeration
|
||||
if err = utils.CheckPwd(password, passwordHash); err != nil {
|
||||
return "", nil, errors.ErrUnauthorized
|
||||
}
|
||||
if getErr != nil {
|
||||
return "", nil, errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
if !user.HasPasskeyMFA() {
|
||||
return "", nil, errors.ErrPasskeyNoCredential
|
||||
}
|
||||
|
||||
var opts []webauthn.LoginOption
|
||||
if rpID != "" {
|
||||
opts = append(opts, webauthn.WithLoginRelyingPartyID(rpID))
|
||||
}
|
||||
assertion, sessionData, err := s.wa.BeginLogin(user, opts...)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to begin WebAuthn login: %w", err)
|
||||
}
|
||||
|
||||
sessionID = generateSessionID()
|
||||
s.sessions.Store(sessionID, &webAuthnSession{
|
||||
SessionData: sessionData,
|
||||
UserID: user.ID,
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
|
||||
return sessionID, assertion, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) FinishMFALogin(sessionID string, r *http.Request, userStore *users.Storage) (*users.User, error) {
|
||||
sessionVal, ok := s.sessions.Load(sessionID)
|
||||
if !ok {
|
||||
return nil, errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
sess, ok := sessionVal.(*webAuthnSession)
|
||||
if !ok {
|
||||
return nil, errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
defer s.sessions.Delete(sessionID)
|
||||
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
return nil, errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
|
||||
user, err := userStore.Get(sess.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
credential, err := s.wa.FinishLogin(user, *sess.SessionData, r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", errors.ErrPasskeyVerification, err)
|
||||
}
|
||||
|
||||
credB64 := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||
for i := range user.PasskeyCredentials {
|
||||
if user.PasskeyCredentials[i].ID == credB64 {
|
||||
users.UpdateCredentialFromLibrary(&user.PasskeyCredentials[i], credential)
|
||||
if err := userStore.Update(user, false, "PasskeyCredentials"); err != nil {
|
||||
return nil, fmt.Errorf("failed to update credential: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) BeginRegistration(user *users.User, rpID string) (sessionID string, creation *protocol.CredentialCreation, err error) {
|
||||
var opts []webauthn.RegistrationOption
|
||||
if rpID != "" {
|
||||
opts = append(opts, webauthn.WithRegistrationRelyingPartyID(rpID))
|
||||
}
|
||||
creation, sessionData, err := s.wa.BeginRegistration(user, opts...)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
|
||||
}
|
||||
|
||||
sessionID = generateSessionID()
|
||||
s.sessions.Store(sessionID, &webAuthnSession{
|
||||
SessionData: sessionData,
|
||||
UserID: user.ID,
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
|
||||
return sessionID, creation, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) FinishRegistration(user *users.User, sessionID string, credentialName string, r *http.Request) error {
|
||||
sessionVal, ok := s.sessions.Load(sessionID)
|
||||
if !ok {
|
||||
return errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
sess, ok := sessionVal.(*webAuthnSession)
|
||||
if !ok {
|
||||
return errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
defer s.sessions.Delete(sessionID)
|
||||
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
return errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
|
||||
if sess.UserID != user.ID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
credential, err := s.wa.FinishRegistration(user, *sess.SessionData, r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to finish WebAuthn registration: %w", err)
|
||||
}
|
||||
|
||||
credB64 := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||
for _, c := range user.PasskeyCredentials {
|
||||
if c.ID == credB64 {
|
||||
return errors.ErrPasskeyExists
|
||||
}
|
||||
}
|
||||
|
||||
newCred := users.CredentialFromLibrary(credentialName, credential)
|
||||
user.PasskeyCredentials = append(user.PasskeyCredentials, newCred)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) DeleteCredential(user *users.User, credentialID string) error {
|
||||
for i, c := range user.PasskeyCredentials {
|
||||
if c.ID == credentialID {
|
||||
user.PasskeyCredentials = append(user.PasskeyCredentials[:i], user.PasskeyCredentials[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.ErrPasskeyNoCredential
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) cleanupExpiredSessions() {
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
now := time.Now()
|
||||
s.sessions.Range(func(key, value interface{}) bool {
|
||||
sess, ok := value.(*webAuthnSession)
|
||||
if !ok {
|
||||
s.sessions.Delete(key)
|
||||
return true
|
||||
}
|
||||
if now.After(sess.ExpiresAt) {
|
||||
s.sessions.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deriveWebAuthnConfig(cfg *settings.PasskeyAuthConfig) (*webauthn.Config, error) {
|
||||
rpID := cfg.RPID
|
||||
if rpID == "" {
|
||||
if settings.Config.Server.ExternalUrl != "" {
|
||||
if u, err := url.Parse(settings.Config.Server.ExternalUrl); err == nil {
|
||||
rpID = u.Hostname()
|
||||
}
|
||||
}
|
||||
if rpID == "" {
|
||||
rpID = "localhost"
|
||||
}
|
||||
}
|
||||
|
||||
displayName := cfg.RPDisplayName
|
||||
if displayName == "" {
|
||||
displayName = settings.Config.Frontend.Name
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = "FileBrowser Quantum"
|
||||
}
|
||||
|
||||
origins := cfg.RPOrigins
|
||||
if len(origins) == 0 {
|
||||
if settings.Config.Server.ExternalUrl != "" {
|
||||
origins = append(origins, settings.Config.Server.ExternalUrl)
|
||||
}
|
||||
if settings.Config.Server.InternalUrl != "" && settings.Config.Server.InternalUrl != settings.Config.Server.ExternalUrl {
|
||||
origins = append(origins, settings.Config.Server.InternalUrl)
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
origins = append(origins, "http://localhost")
|
||||
}
|
||||
}
|
||||
|
||||
return &webauthn.Config{
|
||||
RPID: rpID,
|
||||
RPDisplayName: displayName,
|
||||
RPOrigins: origins,
|
||||
AttestationPreference: protocol.PreferNoAttestation,
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
UserVerification: protocol.VerificationPreferred,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -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,116 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// validateAccessRules migrates old-style access rules (without trailing slashes) to new format
|
||||
func validateAccessRules() {
|
||||
if store.Access == nil {
|
||||
return
|
||||
}
|
||||
// Get all sources
|
||||
for sourcePath := range settings.Config.Server.SourceMap {
|
||||
// Get all rules for this source
|
||||
rules, err := store.Access.GetAllRules(sourcePath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get access rules for source %s: %v", sourcePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if there are any rules that need migration
|
||||
needsMigration := false
|
||||
for oldPath := range rules {
|
||||
if oldPath != "/" && !strings.HasSuffix(oldPath, "/") {
|
||||
needsMigration = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !needsMigration {
|
||||
continue
|
||||
}
|
||||
|
||||
migratedCount := 0
|
||||
for oldPath, rule := range rules {
|
||||
// Check if this path needs migration (doesn't have trailing slash and isn't root)
|
||||
if oldPath != "/" && !strings.HasSuffix(oldPath, "/") {
|
||||
// Create the new path with trailing slash
|
||||
newPath := oldPath + "/"
|
||||
|
||||
// Migrate the rule to the new path
|
||||
if err := migrateAccessRule(sourcePath, oldPath, newPath, rule); err != nil {
|
||||
logger.Errorf("Failed to migrate rule from %s to %s: %v", oldPath, newPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove the old rule
|
||||
if err := removeOldAccessRule(sourcePath, oldPath); err != nil {
|
||||
logger.Errorf("Failed to remove old rule %s: %v", oldPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
migratedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// After migration, clear cache
|
||||
if migratedCount > 0 {
|
||||
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// migrateAccessRule creates a new access rule with the new path format
|
||||
func migrateAccessRule(sourcePath, oldPath, newPath string, rule access.FrontendAccessRule) error {
|
||||
// Add deny users
|
||||
for _, user := range rule.Deny.Users {
|
||||
if err := store.Access.DenyUser(sourcePath, newPath, user); err != nil && err != errors.ErrExist {
|
||||
return fmt.Errorf("failed to add deny user %s: %w", user, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add deny groups
|
||||
for _, group := range rule.Deny.Groups {
|
||||
if err := store.Access.DenyGroup(sourcePath, newPath, group); err != nil && err != errors.ErrExist {
|
||||
return fmt.Errorf("failed to add deny group %s: %w", group, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add allow users
|
||||
for _, user := range rule.Allow.Users {
|
||||
if err := store.Access.AllowUser(sourcePath, newPath, user); err != nil && err != errors.ErrExist {
|
||||
return fmt.Errorf("failed to add allow user %s: %w", user, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add allow groups
|
||||
for _, group := range rule.Allow.Groups {
|
||||
if err := store.Access.AllowGroup(sourcePath, newPath, group); err != nil && err != errors.ErrExist {
|
||||
return fmt.Errorf("failed to add allow group %s: %w", group, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add deny all rule if needed
|
||||
if rule.DenyAll {
|
||||
if err := store.Access.DenyAll(sourcePath, newPath); err != nil && err != errors.ErrExist {
|
||||
return fmt.Errorf("failed to add deny all rule: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeOldAccessRule removes the old access rule by directly accessing the internal storage
|
||||
func removeOldAccessRule(sourcePath, oldPath string) error {
|
||||
// Access the internal storage directly to remove the old rule
|
||||
// We need to use the unnormalized path since that's how it was stored originally
|
||||
store.Access.RemoveRuleByPath(sourcePath, oldPath)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
configPath string
|
||||
)
|
||||
|
||||
// return bool to indicate if the program should continue running
|
||||
func runCLI() (bool, bool) {
|
||||
|
||||
generateYaml()
|
||||
|
||||
// Global flags
|
||||
var help bool
|
||||
// Override the default usage output to use generalUsage()
|
||||
flag.Usage = generalUsage
|
||||
flag.StringVar(&configPath, "c", "", "Path to the config file, default: config.yaml")
|
||||
flag.BoolVar(&help, "h", false, "Get help about commands")
|
||||
|
||||
if configPath == "" {
|
||||
configPath = os.Getenv("FILEBROWSER_CONFIG")
|
||||
// backwards compatibility in docker, prefer config.yaml if it exists
|
||||
if configPath != "" {
|
||||
_, err := os.Stat(configPath)
|
||||
if 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", configPath)
|
||||
}
|
||||
} else {
|
||||
configPath = "config.yaml"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse global flags (before subcommands)
|
||||
flag.Parse() // print generalUsage on error
|
||||
|
||||
// Show help if requested
|
||||
if help {
|
||||
generalUsage()
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Create a new FlagSet for the 'set' subcommand
|
||||
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
|
||||
var user, dbConfig string
|
||||
var asAdmin bool
|
||||
var sourceName, indexPath, ruleCategory, value string
|
||||
var allow bool
|
||||
|
||||
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
|
||||
setCmd.BoolVar(&asAdmin, "a", false, "Create a user as admin user, used in combination with -u")
|
||||
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
|
||||
setCmd.StringVar(&sourceName, "s", "", "source name to apply the rule to")
|
||||
setCmd.StringVar(&indexPath, "p", "", "Index path (e.g. /secret) for rule command")
|
||||
setCmd.StringVar(&ruleCategory, "r", "", "Rule category: 'user', 'group', or 'all' (for deny only)")
|
||||
setCmd.StringVar(&value, "v", "", "Value: username or groupname (not required if ruleCategory is 'all')")
|
||||
setCmd.BoolVar(&allow, "allow", false, "Allow access (default: false, which means deny)")
|
||||
|
||||
storeCfg := configPath
|
||||
cliSubcmd := ""
|
||||
var setKind string
|
||||
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "version":
|
||||
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 false, false
|
||||
case "setup":
|
||||
cliSubcmd = "setup"
|
||||
case "set":
|
||||
cliSubcmd = "set"
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
setKind = os.Args[2]
|
||||
var err error
|
||||
// Handle old syntax: "set -u user,pass -c config.yaml"
|
||||
if setKind == "-u" {
|
||||
err = setCmd.Parse(os.Args[2:])
|
||||
} else {
|
||||
// New pattern: subcommand-based (e.g., "set user" or "set rule")
|
||||
err = setCmd.Parse(os.Args[3:])
|
||||
}
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
storeCfg = dbConfig
|
||||
}
|
||||
}
|
||||
|
||||
switch cliSubcmd {
|
||||
case "setup":
|
||||
createConfig(configPath)
|
||||
return false, false
|
||||
case "set":
|
||||
if setKind == "-u" {
|
||||
dbExists := getStore(storeCfg)
|
||||
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
|
||||
err := setUser(dbConfig, asAdmin)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false, dbExists
|
||||
}
|
||||
switch setKind {
|
||||
case "rule":
|
||||
dbExists := getStore(storeCfg)
|
||||
sourceInfo, ok := settings.Config.Server.NameToSource[sourceName] // backend source is path
|
||||
if !ok {
|
||||
fmt.Printf("error: invalid source name: %s\n", sourceName)
|
||||
os.Exit(1)
|
||||
}
|
||||
err := setRule(dbConfig, sourceInfo.Path, indexPath, ruleCategory, value, allow)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false, dbExists
|
||||
default:
|
||||
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", setKind)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false, false
|
||||
default:
|
||||
dbExists := getStore(storeCfg)
|
||||
return true, dbExists
|
||||
}
|
||||
}
|
||||
|
||||
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
|
||||
// Validate required parameters
|
||||
if fsPath == "" {
|
||||
return fmt.Errorf("real filesystem path is required: use -f <fsPath>")
|
||||
}
|
||||
if indexPath == "" {
|
||||
return fmt.Errorf("index path is required: use -p <indexPath>")
|
||||
}
|
||||
if ruleCategory == "" {
|
||||
return fmt.Errorf("ruleCategory is required: use -r <user|group|all>")
|
||||
}
|
||||
if ruleCategory != "all" && value == "" {
|
||||
return fmt.Errorf("value is required when ruleCategory is 'user' or 'group': use -v <username|groupname>")
|
||||
}
|
||||
// Apply the rule based on allow flag and ruleCategory
|
||||
var err error
|
||||
if allow {
|
||||
switch ruleCategory {
|
||||
case "user":
|
||||
err = store.Access.AllowUser(fsPath, indexPath, value)
|
||||
case "group":
|
||||
err = store.Access.AllowGroup(fsPath, indexPath, value)
|
||||
default:
|
||||
return fmt.Errorf("invalid ruleCategory for allow: must be 'user' or 'group'")
|
||||
}
|
||||
} else {
|
||||
switch ruleCategory {
|
||||
case "user":
|
||||
err = store.Access.DenyUser(fsPath, indexPath, value)
|
||||
case "group":
|
||||
err = store.Access.DenyGroup(fsPath, indexPath, value)
|
||||
case "all":
|
||||
err = store.Access.DenyAll(fsPath, indexPath)
|
||||
default:
|
||||
return fmt.Errorf("invalid ruleCategory: 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, fsPath)
|
||||
} else {
|
||||
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, fsPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setUser(dbConfig string, asAdmin bool) error {
|
||||
if len(os.Args) < 4 {
|
||||
return fmt.Errorf("missing username and password for 'set user'. Use 'set -u username,password'")
|
||||
}
|
||||
userInfo := strings.Split(os.Args[3], ",")
|
||||
if len(userInfo) < 2 {
|
||||
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", userInfo)
|
||||
}
|
||||
username := userInfo[0]
|
||||
password := userInfo[1]
|
||||
user, err := store.Users.Get(username)
|
||||
if err != nil {
|
||||
newUser := users.User{
|
||||
Username: username,
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
if source.Config.DefaultEnabled {
|
||||
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
|
||||
Name: source.Path,
|
||||
Scope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create the user logic
|
||||
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 = storage.CreateUser(newUser, newUser.Permissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create user: %v", err)
|
||||
}
|
||||
fmt.Printf("successfully created user")
|
||||
return nil
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
|
||||
}
|
||||
user.Password = password
|
||||
user.TOTPSecret = "" // reset TOTP secret if it exists
|
||||
user.TOTPNonce = "" // reset TOTP nonce if it exists
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
if asAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
// Ensure version is set for existing users being updated
|
||||
if user.Version == 0 {
|
||||
user.Version = users.CurrentUserMigrationVersion
|
||||
}
|
||||
err = store.Users.Save(user, true, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not update user: %v", err)
|
||||
}
|
||||
fmt.Printf("successfully updated user: %s\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// askQuestion displays a prompt and reads a line of input from the user.
|
||||
// It returns the defaultValue if the user's input is empty.
|
||||
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
|
||||
}
|
||||
|
||||
// askYesNoQuestion prompts the user with a yes/no question and returns a boolean.
|
||||
// It retries until valid input ("yes", "y", "no", "n") is received.
|
||||
// The defaultValue must be "yes" or "no".
|
||||
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'.")
|
||||
}
|
||||
}
|
||||
|
||||
// createConfig orchestrates the configuration process by asking the user a series of questions.
|
||||
func createConfig(configpath string) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
config := settings.SetDefaults(false)
|
||||
config.Server.Sources = []*settings.Source{{Path: "./"}}
|
||||
|
||||
fmt.Println("--- Starting Configuration Setup ---")
|
||||
realPath := ""
|
||||
// 1. Ask for the source filesystem path (with validation)
|
||||
for {
|
||||
config.Server.Sources[0].Path = askQuestion(reader, "What is the source filesystem path?", "./")
|
||||
// Convert relative path to absolute path
|
||||
absolutePath, err := filepath.Abs(config.Server.Sources[0].Path)
|
||||
if err == nil {
|
||||
var isDir bool
|
||||
// Resolve symlinks and get the real path
|
||||
realPath, isDir, _ = iteminfo.ResolveSymlinks(absolutePath)
|
||||
if realPath != "" && isDir {
|
||||
break // Valid path found, exit loop
|
||||
}
|
||||
}
|
||||
fmt.Printf("Error: The path '%s' does not exist or isn't valid. Please try again.\n", config.Server.Sources[0].Path)
|
||||
}
|
||||
// 2. Ask for the source name
|
||||
defaultSourceName := filepath.Base(realPath)
|
||||
sourceName := askQuestion(reader, "What should the first source name be?", defaultSourceName)
|
||||
if sourceName != defaultSourceName {
|
||||
config.Server.Sources[0].Name = sourceName
|
||||
}
|
||||
|
||||
// 3. Ask for server port (with validation)
|
||||
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.Server.Port = port
|
||||
break // Port is valid, exit loop
|
||||
}
|
||||
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 {
|
||||
config.Server.Database = askQuestion(reader, "What should the file name and path be for the database?", "./database.db")
|
||||
if strings.HasSuffix(config.Server.Database, ".db") {
|
||||
break // Valid path found, exit loop
|
||||
}
|
||||
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .db", config.Server.Database)
|
||||
}
|
||||
// 4. Ask for the application brand name
|
||||
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
|
||||
|
||||
// 5. Ask for admin username and password
|
||||
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")
|
||||
|
||||
// 6. Ask boolean (Yes/No) questions using the helper
|
||||
config.UserDefaults.Account.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
|
||||
config.UserDefaults.Account.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
|
||||
|
||||
fmt.Println("--- Configuration Complete ---")
|
||||
|
||||
// marshall yaml and write to file
|
||||
// Marshal the struct to YAML bytes
|
||||
yamlData, err := yaml.Marshal(&config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write the YAML data to the file
|
||||
err = os.WriteFile("config.yaml", yamlData, 0644) // 0644 provides read/write for owner, read for others
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// cleanup database if it exists
|
||||
if _, err := os.Stat(config.Server.Database); err == nil {
|
||||
response := askYesNoQuestion(reader, "Database specified already exists. Move database file to backup to start fresh?", "no")
|
||||
if !response {
|
||||
return
|
||||
}
|
||||
// move database file to backup
|
||||
backupPath := config.Server.Database + ".bak"
|
||||
err = os.Rename(config.Server.Database, 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
Loaded 100 of 682 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user