mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 21:22:00 -05:00
Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6c23c477 | ||
|
|
19a0d47e71 | ||
|
|
9646af5911 | ||
|
|
c426af57a1 | ||
|
|
90f809ba85 | ||
|
|
98c854a796 | ||
|
|
917f74d7f3 | ||
|
|
c91deadf74 | ||
|
|
d9d4f9564a | ||
|
|
7d5ab2e3b6 | ||
|
|
8add1e9531 | ||
|
|
d2201d874e | ||
|
|
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 |
No files matched your search
+8
-1
@@ -1,5 +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/**
|
||||
+110
-73
@@ -3,85 +3,127 @@ name: PR Request
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "dev/v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "beta/v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "stable/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
- "main"
|
||||
- "dev/v*.*.*"
|
||||
- "beta/v*.*.*"
|
||||
- "stable/v*.*.*"
|
||||
- "staging/v*.*.*"
|
||||
jobs:
|
||||
prime_docker_cache:
|
||||
name: Prime docker cache
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Prime Cache
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
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:
|
||||
name: Test Playwright - general
|
||||
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
|
||||
- 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
|
||||
- name: Build and Test
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
test_playwright_proxy:
|
||||
name: Test Playwright - proxy
|
||||
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-proxy
|
||||
push: false
|
||||
test_playwright_noauth:
|
||||
name: Test Playwright - noauth
|
||||
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-noauth
|
||||
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
|
||||
@@ -90,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: ./_docker/Dockerfile
|
||||
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
|
||||
@@ -86,25 +86,3 @@ jobs:
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
|
||||
|
||||
# - name: Delete beta branch after successful merge
|
||||
# run: |
|
||||
# VERSION="${{ github.event.inputs.version }}"
|
||||
# BETA_BRANCH="beta/v$VERSION"
|
||||
#
|
||||
# # Delete beta branch locally and remotely
|
||||
# git branch -d "$BETA_BRANCH"
|
||||
# git push origin --delete "$BETA_BRANCH"
|
||||
#
|
||||
# echo "✅ Successfully deleted '$BETA_BRANCH'."
|
||||
|
||||
- name: Create Pull Request to Main
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
commit-message: "Promote stable/v${{ github.event.inputs.version }} to main"
|
||||
title: "Promote stable/v${{ github.event.inputs.version }} to main"
|
||||
body: "This PR promotes stable/v${{ github.event.inputs.version }} to the main branch."
|
||||
base: main
|
||||
branch: stable/v${{ github.event.inputs.version }}
|
||||
delete-branch: false
|
||||
@@ -86,14 +86,3 @@ jobs:
|
||||
git push origin "$BETA_BRANCH"
|
||||
|
||||
echo "✅ Successfully merged '$DEV_BRANCH' into '$BETA_BRANCH'."
|
||||
|
||||
# - name: Delete dev branch after successful merge
|
||||
# run: |
|
||||
# VERSION="${{ github.event.inputs.version }}"
|
||||
# DEV_BRANCH="dev/v$VERSION"
|
||||
#
|
||||
# # Delete dev branch locally and remotely
|
||||
# git branch -d "$DEV_BRANCH"
|
||||
# git push origin --delete "$DEV_BRANCH"
|
||||
#
|
||||
# echo "✅ Successfully deleted '$DEV_BRANCH'."
|
||||
@@ -1,7 +1,7 @@
|
||||
name: regular tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
@@ -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: 'stable'
|
||||
- uses: golangci/golangci-lint-action@v5
|
||||
go-version: 'stable'
|
||||
- uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: 'v1.64'
|
||||
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
|
||||
@@ -59,8 +59,8 @@ jobs:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false # change this to false when stable gets released
|
||||
make_latest: true # change this to false when stable gets released
|
||||
prerelease: true
|
||||
make_latest: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
@@ -1,62 +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 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 version names
|
||||
id: modify-json
|
||||
run: |
|
||||
TAGS="${{ steps.meta.outputs.tags }}"
|
||||
# Apply modifications to both docker.io and ghcr.io tags
|
||||
CLEANED_TAGS=$(echo "$TAGS" | sed 's/filebrowser:dev-v/filebrowser:/g' | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev/g')
|
||||
# Add dev tag to both registries
|
||||
CLEANED_TAGS="$CLEANED_TAGS,gtstef/filebrowser:dev,ghcr.io/gtsteffaniak/filebrowser:dev"
|
||||
echo "cleaned_tag<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CLEANED_TAGS" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $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
|
||||
file: ./_docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,125 +0,0 @@
|
||||
name: Manual Docker Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Tag or Branch (format: v0.0.0-beta)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
push_release_to_registry:
|
||||
name: Push release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }} # Uses the Personal Access Token
|
||||
ref: "${{ github.event.inputs.branch }}"
|
||||
- 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
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.inputs.branch }}
|
||||
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
|
||||
id: modify-json
|
||||
run: |
|
||||
JSON='${{ steps.meta.outputs.json }}'
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq '
|
||||
.tags |= map(sub(":v"; ":")) |
|
||||
if any(.tags[]; test("beta")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:beta",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta",
|
||||
"gtstef/filebrowser:latest",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:latest"
|
||||
]
|
||||
else . end |
|
||||
if any(.tags[]; test("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,linux/arm/v7
|
||||
file: ./_docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
- name: Modify version names
|
||||
id: modify-json-media
|
||||
run: |
|
||||
JSON='${{ steps.meta.outputs.json }}'
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq '
|
||||
.tags |= map(
|
||||
sub(":v"; ":") + "-media"
|
||||
) |
|
||||
if any(.tags[]; test("beta-media")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:beta-media",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta-media",
|
||||
"gtstef/filebrowser:media",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:media"
|
||||
]
|
||||
else . end |
|
||||
if any(.tags[]; test("stable-media")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable-media",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable-media",
|
||||
"gtstef/filebrowser:media",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:media"
|
||||
]
|
||||
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 media 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'] }}
|
||||
file: ./_docker/Dockerfile.media
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.modify-json-media.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright
|
||||
name: Test Playwright - regular
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -60,6 +60,7 @@ jobs:
|
||||
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 }}
|
||||
+113
-10
@@ -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:
|
||||
@@ -58,21 +59,40 @@ jobs:
|
||||
images: |
|
||||
gtstef/filebrowser
|
||||
ghcr.io/gtsteffaniak/filebrowser
|
||||
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
|
||||
- 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"; ":")) |
|
||||
if any(.tags[]; test("beta")) then
|
||||
.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",
|
||||
"gtstef/filebrowser:latest",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:latest"
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta"
|
||||
]
|
||||
else . end |
|
||||
if any(.tags[]; test("stable")) then
|
||||
# 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",
|
||||
@@ -91,8 +111,91 @@ jobs:
|
||||
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
|
||||
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
|
||||
+15
-3
@@ -8,20 +8,27 @@ rice-box.go
|
||||
/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/playwright-files
|
||||
/frontend/screenshots
|
||||
/frontend/generated
|
||||
/frontend/tests/playwright-files/demo*
|
||||
/frontend/loginAuth.json
|
||||
/frontend/package-lock.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/http/embed
|
||||
/backend/debug
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
@@ -46,3 +53,8 @@ yarn-error.log*
|
||||
bin/
|
||||
build/
|
||||
*__debug*
|
||||
backend/custom.css
|
||||
|
||||
# Runtime cache directories created by tests/application
|
||||
backend/preview/heic/
|
||||
backend/preview/thumbnails/
|
||||
+1226
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`
|
||||
@@ -2,60 +2,68 @@
|
||||
|
||||
[](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
|
||||
[](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://hub.docker.com/r/gtstef/filebrowser)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
[](https://www.paypal.com/donate/?business=W5XKNXHJM2WPE&no_recurring=0¤cy_code=USD)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A#is-there-a-way-to-donate-or-support-this-project)
|
||||
|
||||
<img width="150" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" title="Logo">
|
||||
<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/95af22fa-7760-4f7b-9c20-51fdcfe8f4ea" title="Main Screenshot">
|
||||
<img width="800" src="https://github.com/user-attachments/assets/162d7a95-33b7-49bd-976c-dd6822c0d22b">
|
||||
</div>
|
||||
|
||||
> [!WARNING]
|
||||
> There is no stable version -- planned for 2025.
|
||||
## Pinned
|
||||
|
||||
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
|
||||
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
## About
|
||||
|
||||
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 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.
|
||||
|
||||
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.
|
||||
|
||||
Ready to try it out? See [Getting Started Docs](https://filebrowserquantum.com/en/docs/getting-started/).
|
||||
|
||||
## How its different
|
||||
|
||||
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
|
||||
|
||||
1. ✅ Multiple sources support
|
||||
2. ✅ Login support for OIDC, password, and proxy.
|
||||
3. ✅ Revamped UI
|
||||
4. ✅ Simplified configuration via `config.yaml` config file.
|
||||
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
|
||||
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
|
||||
- More file type previews, such as **office** and **video** file previews
|
||||
- Instantly switches view modes and sort order without reloading data.
|
||||
- Folder sizes are displayed.
|
||||
- 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. ✅ Developer API support
|
||||
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):
|
||||
|
||||
- ❌ jobs are not supported yet (planned).
|
||||
- ❌ rules are not supported yet (planned).
|
||||
- ❌ shell commands are completely removed and will not be returned.
|
||||
|
||||
## About
|
||||
|
||||
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.
|
||||
|
||||
This version is called "Quantum" because it packs tons of advanced features in a tiny executable file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
|
||||
|
||||
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.
|
||||
|
||||
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
|
||||
|
||||
## Look
|
||||
## The UI
|
||||
|
||||
The UI has a simple three-component navigation system:
|
||||
|
||||
@@ -72,80 +80,59 @@ a pop-up menu.
|
||||
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
|
||||
</p>
|
||||
|
||||
## Install and Configuration
|
||||
## Official Docs
|
||||
|
||||
See the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration)
|
||||
|
||||
## Command Line Usage
|
||||
|
||||
See the [CLI Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
|
||||
|
||||
## API Usage
|
||||
|
||||
See the [API Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/API)
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is done via the `config.yaml`, see the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration) for available configuration options and other help.
|
||||
|
||||
## Office File Support
|
||||
|
||||
See [Office Support Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Office-Support#adding-open-office-integration-for-docker) on how to enable office file editing and office related features.
|
||||
|
||||
## Migration from the original filebrowser
|
||||
|
||||
See the [Migration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Migration)
|
||||
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="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" > 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 | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Docker image size | 200 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Min. Memory Requirements | 256 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
|
||||
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
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 | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
|
||||
Advanced Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Indexed Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
LDAP 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 | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
||||
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
activity log | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
trash support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
activity log | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Starred/pinned files | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can convert documents | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can convert videos | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
Can convert photos | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
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: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
+23
-32
@@ -1,58 +1,49 @@
|
||||
FROM golang:1.24-alpine AS base
|
||||
FROM gtstef/ffmpeg:8.1-decode AS ffmpeg
|
||||
FROM golang: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 \
|
||||
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:slim AS nbuild
|
||||
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
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache curl xz && \
|
||||
ARCH=$(uname -m) && \
|
||||
case "$ARCH" in \
|
||||
x86_64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ;; \
|
||||
aarch64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" ;; \
|
||||
armv7l) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||
esac && \
|
||||
mkdir -p /tmp/ffmpeg && \
|
||||
curl -L "$URL" | tar -xJ -C /tmp/ffmpeg && \
|
||||
mv /tmp/ffmpeg/ffmpeg-*/ffmpeg /usr/local/bin/ && \
|
||||
mv /tmp/ffmpeg/ffmpeg-*/ffprobe /usr/local/bin/ && \
|
||||
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \
|
||||
rm -rf /tmp/ffmpeg && \
|
||||
apk del curl xz && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
|
||||
ENV FILEBROWSER_NO_EMBEDED="true"
|
||||
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
|
||||
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 --from=base --chown=filebrowser:1000 /app/filebrowser* ./
|
||||
COPY --from=base --chown=filebrowser:1000 /app/config.media.yaml ./config.yaml
|
||||
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
|
||||
|
||||
# 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" ]
|
||||
|
||||
USER root
|
||||
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" ]
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM node:22-slim
|
||||
WORKDIR /app/frontend
|
||||
RUN npm i @playwright/test
|
||||
RUN npx playwright install --with-deps firefox
|
||||
|
||||
RUN npm i @playwright/test oauth2-mock-server @types/node
|
||||
RUN npx playwright install --with-deps chromium
|
||||
@@ -1,8 +1,23 @@
|
||||
FROM gtstef/playwright-base
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/general/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./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
|
||||
@@ -1,8 +1,18 @@
|
||||
FROM gtstef/playwright-base
|
||||
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/", "./" ]
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
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
|
||||
@@ -1,11 +1,15 @@
|
||||
FROM gtstef/playwright-base
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
COPY [ "./_docker/src/proxy/", "./" ]
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./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,22 @@
|
||||
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 -f /tests/playwright-files -p / -r user -v admin -allow -c config.yaml
|
||||
RUN ./filebrowser set rule -f /tests/playwright-files -p /excluded -r user -v admin -c config.yaml
|
||||
@@ -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" ]
|
||||
@@ -1,4 +1,19 @@
|
||||
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
|
||||
@@ -6,11 +21,31 @@ services:
|
||||
- "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/config.yaml"
|
||||
- "./src/proxy/backend/config.yaml:/home/filebrowser/data/config.yaml"
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./_docker/Dockerfile
|
||||
dockerfile: ./_docker/Dockerfile.slim
|
||||
@@ -1,16 +1,17 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
- path: "."
|
||||
name: "docker"
|
||||
<<: *server-main
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
<<: *frontend-main
|
||||
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
models: true
|
||||
office: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: 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,15 @@
|
||||
server-main: &server-main
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
disableIndexing: true
|
||||
defaultEnabled: true
|
||||
@@ -10,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests-playwright/global-setup",
|
||||
timeout: 5000,
|
||||
testDir: "./tests-playwright/general",
|
||||
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. */
|
||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
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. */
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
server:
|
||||
port: 8080
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -5,9 +5,43 @@ server:
|
||||
- levels: "info|error|debug"
|
||||
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
|
||||
@@ -15,6 +49,17 @@ frontend:
|
||||
- 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:
|
||||
realtime: true
|
||||
download: true
|
||||
create: true
|
||||
@@ -10,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests-playwright/noauth-setup",
|
||||
timeout: 5000,
|
||||
testDir: "./tests-playwright/noauth",
|
||||
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. */
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
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,35 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
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/
|
||||
@@ -1,6 +1,6 @@
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/"
|
||||
baseURL: "/subpath"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
@@ -16,20 +16,32 @@ frontend:
|
||||
- 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: false
|
||||
api: false
|
||||
share: true
|
||||
api: false
|
||||
download: true
|
||||
create: true
|
||||
@@ -7,13 +7,26 @@ server {
|
||||
listen 80;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
location / {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,23 +10,23 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
//globalSetup: "./global-setup",
|
||||
timeout: 3000,
|
||||
testDir: "./tests-playwright/proxy",
|
||||
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: 2,
|
||||
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: 3000,
|
||||
//storageState: "loginAuth.json",
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
@@ -0,0 +1,69 @@
|
||||
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: 2,
|
||||
/* 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',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
name: "light-screenshots",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
disableIndexing: true
|
||||
defaultEnabled: 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: 3000,
|
||||
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,25 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
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$
|
||||
+56
-16
@@ -3,18 +3,54 @@ 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/backend/common/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/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:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
hooks:
|
||||
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"
|
||||
@@ -24,6 +60,11 @@ builds:
|
||||
|
||||
# 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
|
||||
@@ -35,6 +76,16 @@ builds:
|
||||
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
|
||||
@@ -43,17 +94,7 @@ builds:
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
# Build configuration for freebsd without arm & upx
|
||||
- id: freeBSD
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
goos:
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
|
||||
archives:
|
||||
@@ -63,8 +104,7 @@ archives:
|
||||
{{- else -}}
|
||||
{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}}
|
||||
{{- end -}}
|
||||
format: binary
|
||||
formats: [ 'binary' ]
|
||||
|
||||
checksum:
|
||||
disable: true
|
||||
|
||||
@@ -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,13 +32,61 @@ type FileCache struct {
|
||||
}
|
||||
}
|
||||
|
||||
// NewFileCache creates a new FileCache
|
||||
func NewFileCache(dir string) (*FileCache, error) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "thumbnails", "video"), 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 {
|
||||
@@ -44,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
|
||||
}
|
||||
|
||||
@@ -110,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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"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"
|
||||
)
|
||||
@@ -98,11 +103,11 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "File2.txt"},
|
||||
{Name: "File10.txt"},
|
||||
{Name: "File1"},
|
||||
{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{
|
||||
@@ -112,11 +117,11 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "banana"},
|
||||
{Name: "File1"},
|
||||
{Name: "File10.txt"},
|
||||
{Name: "File2.txt"},
|
||||
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"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -128,10 +133,10 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "apple"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "Zebra"},
|
||||
{Name: "apple"},
|
||||
{Name: "cat"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
|
||||
},
|
||||
},
|
||||
expected: iteminfo.FileInfo{
|
||||
@@ -140,10 +145,10 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "dog.txt"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "apple"},
|
||||
{Name: "cat"},
|
||||
{Name: "Zebra"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -153,7 +158,7 @@ func TestSortItems(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.input.SortItems()
|
||||
|
||||
getNames := func(items []iteminfo.ItemInfo) []string {
|
||||
getFolderNames := func(items []iteminfo.ItemInfo) []string {
|
||||
names := []string{}
|
||||
for _, folder := range items {
|
||||
names = append(names, folder.Name)
|
||||
@@ -161,15 +166,23 @@ func TestSortItems(t *testing.T) {
|
||||
return names
|
||||
}
|
||||
|
||||
actualFolderNames := getNames(test.input.Folders)
|
||||
expectedFolderNames := getNames(test.expected.Folders)
|
||||
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 := getNames(test.input.Files)
|
||||
expectedFileNames := getNames(test.expected.Files)
|
||||
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)
|
||||
@@ -177,3 +190,439 @@ func TestSortItems(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 tryAcquireAndScan)
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+730
-173
File diff suppressed because it is too large.
Load diff
@@ -108,7 +108,6 @@ var types = map[string]string{
|
||||
".chrt": "application/vnd.kde.kchart",
|
||||
".class": "application/java",
|
||||
".com": ContentTextHeaderValue,
|
||||
".conf": ContentTextHeaderValue,
|
||||
".cpio": "application/x-cpio",
|
||||
".cpp": "text/x-c",
|
||||
".cpt": "application/mac-compactpro",
|
||||
@@ -149,7 +148,11 @@ var types = map[string]string{
|
||||
".es": "application/x-esrehber",
|
||||
".etx": "text/x-setext",
|
||||
".evy": "application/envoy",
|
||||
".exe": "application/octet-stream",
|
||||
".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",
|
||||
@@ -225,7 +228,6 @@ var types = map[string]string{
|
||||
".js": ContentJavascriptHeaderValue,
|
||||
".mjs": ContentJavascriptHeaderValue,
|
||||
".json": ContentJSONHeaderValue,
|
||||
".vue": ContentJavascriptHeaderValue,
|
||||
".jut": "image/jutvision",
|
||||
".kar": "audio/midi",
|
||||
".karbon": "application/vnd.kde.karbon",
|
||||
@@ -243,8 +245,8 @@ var types = map[string]string{
|
||||
".la": "audio/nspaudio",
|
||||
".lam": "audio/x-liveaudio",
|
||||
".latex": "application/x-latex",
|
||||
".lha": "application/lha",
|
||||
".lhx": "application/octet-stream",
|
||||
".lha": "application/x-lzh-compressed",
|
||||
".lhx": "application/x-lzh-compressed",
|
||||
".list": ContentTextHeaderValue,
|
||||
".lma": "audio/nspaudio",
|
||||
".log": ContentTextHeaderValue,
|
||||
@@ -257,7 +259,6 @@ var types = map[string]string{
|
||||
".m1v": "video/mpeg",
|
||||
".m2a": "audio/mpeg",
|
||||
".m2v": "video/mpeg",
|
||||
".m3u": "audio/x-mpegurl",
|
||||
".m": "text/x-m",
|
||||
".man": "application/x-troff-man",
|
||||
".manifest": "text/cache-manifest",
|
||||
@@ -307,7 +308,6 @@ var types = map[string]string{
|
||||
".ncm": "application/vndnokiaconfiguration-message",
|
||||
".nif": "image/x-niff",
|
||||
".niff": "image/x-niff",
|
||||
".nix": "application/x-mix-transfer",
|
||||
".nsc": "application/x-conference",
|
||||
".nvd": "application/x-navidoc",
|
||||
".o": "application/octet-stream",
|
||||
@@ -343,15 +343,14 @@ var types = map[string]string{
|
||||
".p7m": "application/pkcs7-mime",
|
||||
".p7r": "application/x-pkcs7-certreqresp",
|
||||
".p7s": "application/pkcs7-signature",
|
||||
".p": "text/x-pascal",
|
||||
".part": "application/pro_eng",
|
||||
".pas": "text/pascal",
|
||||
".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",
|
||||
@@ -375,7 +374,7 @@ var types = map[string]string{
|
||||
".pre": "application/x-freelance",
|
||||
".prt": "application/pro_eng",
|
||||
".ps": "application/postscript",
|
||||
".psd": "application/octet-stream",
|
||||
".psd": "image/vnd.adobe.photoshop",
|
||||
".pvu": "paleovu/x-pv",
|
||||
".pwz": "application/vndms-powerpoint",
|
||||
".py": "text/x-scriptphyton",
|
||||
@@ -443,9 +442,11 @@ var types = map[string]string{
|
||||
".sprite": "application/x-sprite",
|
||||
".spx": "audio/ogg",
|
||||
".src": "application/x-wais-source",
|
||||
".srt": "text/plain",
|
||||
".srt": "text/subtitle-srt",
|
||||
".ass": "text/subtitle-ass",
|
||||
".vtt": "text/subtitle-vtt",
|
||||
".ssa": "text/subtitle-ssa",
|
||||
".sbv": "text/plain",
|
||||
".ssa": "text/plain",
|
||||
".ssi": "text/x-server-parsed-html",
|
||||
".ssm": "application/streamingmedia",
|
||||
".sst": "application/vndms-pkicertstore",
|
||||
@@ -590,17 +591,112 @@ var types = map[string]string{
|
||||
".xpi": "application/x-xpinstall",
|
||||
".wasm": "application/wasm",
|
||||
".m4a": "audio/mp4",
|
||||
".flac": "audio/x-flac",
|
||||
".flac": "audio/flac",
|
||||
".amr": "audio/amr",
|
||||
".aac": "audio/aac",
|
||||
".opus": "video/ogg",
|
||||
".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",
|
||||
".m3u8": "application/x-mpegURL",
|
||||
".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
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"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.
|
||||
@@ -17,47 +19,39 @@ func MakeUserDir(fullPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func MakeUserDirs(u *users.User, disableScopeChange bool) error {
|
||||
func MakeUserDirs(u *users.User, createDir bool) error {
|
||||
cleanedUserName := users.CleanUsername(u.Username)
|
||||
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
|
||||
return fmt.Errorf("create user: invalid user for home dir creation: [%s]", u.Username)
|
||||
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
|
||||
}
|
||||
for i, scope := range u.Scopes {
|
||||
source, ok := settings.Config.Server.SourceMap[scope.Name]
|
||||
if !ok {
|
||||
return fmt.Errorf("create user: source not found: %s", scope.Name)
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
}
|
||||
// create directory and append user name
|
||||
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
|
||||
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
|
||||
parentDir := filepath.Join(source.Path, scope.Scope)
|
||||
// validate that scope path exists
|
||||
if !Exists(parentDir) {
|
||||
return fmt.Errorf("create user: scope path does not exist: %s", scope.Scope)
|
||||
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)
|
||||
}
|
||||
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user: failed to create user home dir: %s", err)
|
||||
}
|
||||
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
|
||||
// create directory exactly as specified
|
||||
fullPath := filepath.Join(source.Path, scope.Scope)
|
||||
parentDir := filepath.Dir(fullPath)
|
||||
if !Exists(parentDir) {
|
||||
return fmt.Errorf("create user: scope folder does not exist: %s", 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("create user: failed to create user home dir: %s", err)
|
||||
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
}
|
||||
} else {
|
||||
// just assigning scope to path provided, so just check that it exists
|
||||
path := filepath.Join(source.Path, scope.Scope)
|
||||
if !Exists(path) {
|
||||
return fmt.Errorf("create user: scope folder does not exist: %s", path)
|
||||
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
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var PermFile os.FileMode
|
||||
var PermDir os.FileMode
|
||||
|
||||
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
|
||||
PermFile = PermFileOctal
|
||||
PermDir = PermDirOctal
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -23,14 +29,14 @@ func MoveFile(src, dst string) error {
|
||||
// fallback
|
||||
err = CopyFile(src, dst)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("CopyFile failed %v %v %v ", src, dst, err))
|
||||
logger.Errorf("CopyFile failed %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
err = os.RemoveAll(src)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("os.Remove failed %v %v ", src, err))
|
||||
logger.Errorf("os.Remove failed %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -56,6 +62,13 @@ func CopyFile(source, dest string) error {
|
||||
|
||||
// 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 {
|
||||
@@ -64,13 +77,13 @@ func copySingleFile(source, dest string) error {
|
||||
defer src.Close()
|
||||
|
||||
// Create the destination directory if needed.
|
||||
err = os.MkdirAll(filepath.Dir(dest), 0775) //nolint:gomnd
|
||||
err = os.MkdirAll(filepath.Dir(dest), PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the destination file.
|
||||
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) //nolint:gomnd
|
||||
// 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
|
||||
}
|
||||
@@ -82,14 +95,13 @@ func copySingleFile(source, dest string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy the mode.
|
||||
info, err := os.Stat(source)
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
err = os.Chmod(dest, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
// 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
|
||||
@@ -98,7 +110,7 @@ func copySingleFile(source, dest string) error {
|
||||
// copyDirectory handles copying directories recursively.
|
||||
func copyDirectory(source, dest string) error {
|
||||
// Create the destination directory.
|
||||
err := os.MkdirAll(dest, 0775) //nolint:gomnd
|
||||
err := os.MkdirAll(dest, PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -177,18 +189,17 @@ func CommonPrefix(sep byte, paths ...string) string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func ClearCacheDir() {
|
||||
cacheDir := settings.Config.Server.CacheDir
|
||||
func ClearCacheDir(cacheDir string) {
|
||||
entries, err := os.ReadDir(cacheDir)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("failed clear cache dir: %v", err))
|
||||
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.Error(fmt.Sprintf("failed clear cache dir: %v", err))
|
||||
logger.Errorf("failed clear cache dir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+48
-15
@@ -1,32 +1,65 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
revokedApiKeyList map[string]bool
|
||||
revokeMu sync.Mutex
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func IsRevokedApiKey(key string) bool {
|
||||
_, exists := revokedApiKeyList[key]
|
||||
return exists
|
||||
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)
|
||||
}
|
||||
|
||||
func RevokeAPIKey(key string) {
|
||||
revokeMu.Lock()
|
||||
delete(revokedApiKeyList, key)
|
||||
revokeMu.Unlock()
|
||||
// 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,277 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/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 {
|
||||
logger.Error("decode body error")
|
||||
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":
|
||||
logger.Error("block error")
|
||||
|
||||
return nil, os.ErrPermission
|
||||
case "pass":
|
||||
logger.Error("pass error")
|
||||
|
||||
u, err := a.Users.Get(a.Cred.Username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
}
|
||||
err = users.CheckPwd(cred.Password, u.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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.Cred.Username)
|
||||
if err != nil && err != errors.ErrNotExist {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if u == nil {
|
||||
// create user with the provided credentials
|
||||
d := &users.User{
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: a.Cred.Password,
|
||||
Locale: a.Settings.UserDefaults.Locale,
|
||||
ViewMode: a.Settings.UserDefaults.ViewMode,
|
||||
SingleClick: a.Settings.UserDefaults.SingleClick,
|
||||
ShowHidden: a.Settings.UserDefaults.ShowHidden,
|
||||
},
|
||||
Username: a.Cred.Username,
|
||||
Permissions: a.Settings.UserDefaults.Permissions,
|
||||
}
|
||||
u = a.GetUser(d)
|
||||
|
||||
err = a.Users.Save(u, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
err = users.CheckPwd(a.Cred.Password, u.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(a.Fields.Values) > 1 {
|
||||
u = a.GetUser(u)
|
||||
// update user with provided fields
|
||||
err := a.Users.Update(u, u.Permissions.Admin)
|
||||
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.Permissions.Admin
|
||||
perms := users.Permissions{
|
||||
Admin: isAdmin,
|
||||
Modify: isAdmin || d.Permissions.Modify,
|
||||
Share: isAdmin || d.Permissions.Share,
|
||||
}
|
||||
user := users.User{
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: d.Password,
|
||||
Locale: a.Fields.GetString("user.locale", d.Locale),
|
||||
ViewMode: a.Fields.GetString("user.viewMode", d.ViewMode),
|
||||
SingleClick: a.Fields.GetBoolean("user.singleClick", d.SingleClick),
|
||||
ShowHidden: a.Fields.GetBoolean("user.showHidden", d.ShowHidden),
|
||||
},
|
||||
ID: d.ID,
|
||||
Username: d.Username,
|
||||
Scopes: d.Scopes,
|
||||
Permissions: 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.showHidden",
|
||||
"user.perm.admin",
|
||||
"user.perm.modify",
|
||||
"user.perm.share",
|
||||
"user.perm.api",
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
+39
-28
@@ -8,38 +8,31 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// Auth authenticates the user via a json in content body.
|
||||
func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
|
||||
var cred jsonCred
|
||||
|
||||
if r.Body == nil {
|
||||
logger.Error("nil body error")
|
||||
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")
|
||||
// URL-decode password to support special characters in headers
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
logger.Error("decode body error")
|
||||
return nil, os.ErrPermission
|
||||
return nil, fmt.Errorf("invalid password encoding: %v", err)
|
||||
}
|
||||
totpCode := r.Header.Get("X-Secret")
|
||||
|
||||
// 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
|
||||
@@ -49,21 +42,39 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
}
|
||||
u, err := userStore.Get(cred.Username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
|
||||
user, getErr := userStore.Get(username)
|
||||
var passwordHash string
|
||||
if getErr != nil {
|
||||
passwordHash = utils.InvalidPasswordHash
|
||||
} else {
|
||||
passwordHash = user.Password
|
||||
}
|
||||
err = users.CheckPwd(cred.Password, u.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)
|
||||
}
|
||||
// check for OTP for password
|
||||
if user.TOTPSecret != "" {
|
||||
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,134 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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 the secret key for HMAC algorithms
|
||||
// For RSA/ECDSA, this would need to be a public key
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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,221 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"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 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)
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,3 @@ type NoAuth struct{}
|
||||
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||
return usr.Get(uint(1))
|
||||
}
|
||||
|
||||
// LoginPage tells that no auth doesn't require a login page.
|
||||
func (a NoAuth) LoginPage() bool {
|
||||
return false
|
||||
}
|
||||
@@ -26,8 +26,3 @@ func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
// LoginPage tells that proxy auth doesn't require a login page.
|
||||
func (a ProxyAuth) LoginPage() bool {
|
||||
return false
|
||||
}
|
||||
+21
-4
@@ -1,7 +1,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"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.
|
||||
@@ -27,14 +34,24 @@ func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = store.Save(&HookAuth{})
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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 {
|
||||
// get data from cache
|
||||
cachedSecret, found := TotpCache.Get(user.Username)
|
||||
if !found && user.TOTPSecret == "" {
|
||||
return fmt.Errorf("OTP token not found in cache, please generate a new one")
|
||||
}
|
||||
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
|
||||
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
|
||||
if found {
|
||||
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
|
||||
}
|
||||
// --- END: ADD THIS DECRYPTION LOGIC ---
|
||||
|
||||
// Validate the token using the (now plaintext) secret.
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
|
||||
== Running benchmark ==
|
||||
/usr/local/go/bin/go
|
||||
? github.com/gtsteffaniak/filebrowser/backend [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.005s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
|
||||
/home/graham/git/filebrowser /home/graham/git/filebrowser
|
||||
/home/graham/git/filebrowser/files/file.go /home/graham/git/filebrowser
|
||||
/home/graham/git/filebrowser/mnt/doesnt/exist /home/graham/git/filebrowser
|
||||
2025/01/04 14:04:55 Initializing index and assessing file system complexity
|
||||
2025/01/04 14:04:55 Starting full scan
|
||||
2025/01/04 14:04:55 Index assessment : complexity=simple directories=0 files=0
|
||||
2025/01/04 14:04:55 Time Spent Indexing : 0 seconds
|
||||
2025/01/04 14:04:55 Next scan in 1m0s
|
||||
2025/01/04 14:04:56 Initializing index and assessing file system complexity
|
||||
2025/01/04 14:04:56 Starting full scan
|
||||
2025/01/04 14:04:56 Index assessment : complexity=simple directories=0 files=0
|
||||
2025/01/04 14:04:56 Time Spent Indexing : 0 seconds
|
||||
2025/01/04 14:04:56 Next scan in 1m0s
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
BenchmarkFillIndex-8 2025/01/04 14:04:57 Initializing index and assessing file system complexity
|
||||
2025/01/04 14:04:57 Starting full scan
|
||||
2025/01/04 14:04:57 Index assessment : complexity=simple directories=0 files=0
|
||||
2025/01/04 14:04:57 Time Spent Indexing : 0 seconds
|
||||
2025/01/04 14:04:57 Next scan in 1m0s
|
||||
10 3515090 ns/op 34273 B/op 451 allocs/op
|
||||
BenchmarkCheckIndexExclude-8 10 156.2 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkCheckIndexConditionsInclude-8 10 98.00 ns/op 0 B/op 0 allocs/op
|
||||
2025/01/04 14:04:58 Initializing index and assessing file system complexity
|
||||
2025/01/04 14:04:58 Starting full scan
|
||||
2025/01/04 14:04:58 Index assessment : complexity=simple directories=0 files=0
|
||||
2025/01/04 14:04:58 Time Spent Indexing : 0 seconds
|
||||
2025/01/04 14:04:58 Next scan in 1m0s
|
||||
BenchmarkSearchAllIndexes-8 2025/01/04 14:04:59 Initializing index and assessing file system complexity
|
||||
2025/01/04 14:04:59 Starting full scan
|
||||
2025/01/04 14:04:59 Index assessment : complexity=simple directories=0 files=0
|
||||
2025/01/04 14:04:59 Time Spent Indexing : 0 seconds
|
||||
2025/01/04 14:04:59 Next scan in 1m0s
|
||||
10 766822 ns/op 34230 B/op 900 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 5.094s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.002s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/http 0.184s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/preview/img 0.123s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/runner 0.004s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.005s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.003s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.002s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/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
|
||||
}
|
||||
+416
-58
@@ -1,16 +1,21 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
|
||||
"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 (
|
||||
@@ -19,6 +24,9 @@ var (
|
||||
|
||||
// return bool to indicate if the program should continue running
|
||||
func runCLI() bool {
|
||||
|
||||
generateYaml()
|
||||
|
||||
// Global flags
|
||||
var help bool
|
||||
// Override the default usage output to use generalUsage()
|
||||
@@ -28,10 +36,15 @@ func runCLI() bool {
|
||||
|
||||
if configPath == "" {
|
||||
configPath = os.Getenv("FILEBROWSER_CONFIG")
|
||||
}
|
||||
|
||||
if configPath == "" {
|
||||
configPath = "config.yaml"
|
||||
// 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)
|
||||
@@ -45,83 +58,428 @@ func runCLI() bool {
|
||||
|
||||
// Create a new FlagSet for the 'set' subcommand
|
||||
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
|
||||
var user, scope, dbConfig string
|
||||
var user, dbConfig string
|
||||
var asAdmin bool
|
||||
var fsPath, 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 user as admin user, used in combination with -u")
|
||||
setCmd.StringVar(&scope, "s", "", "Specify a user scope, otherwise default user config scope is used")
|
||||
setCmd.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(&fsPath, "f", "", "Real filesystem path (e.g. /mnt/storage) for rule command")
|
||||
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)")
|
||||
|
||||
// Parse subcommand flags only if a subcommand is specified
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "setup":
|
||||
createConfig(configPath)
|
||||
return false
|
||||
case "set":
|
||||
err := setCmd.Parse(os.Args[2:])
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
userInfo := strings.Split(user, ",")
|
||||
if len(userInfo) < 2 {
|
||||
fmt.Printf("not enough info to create user: \"set -u username,password\", only provided %v\n", userInfo)
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
username := userInfo[0]
|
||||
password := userInfo[1]
|
||||
ok := getStore(dbConfig)
|
||||
if !ok {
|
||||
logger.Fatal("could not load db info")
|
||||
}
|
||||
user, err := store.Users.Get(username)
|
||||
if err != nil {
|
||||
newUser := users.User{
|
||||
Username: username,
|
||||
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.Name,
|
||||
Scope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
}
|
||||
subcommand := os.Args[2]
|
||||
|
||||
// Create the user logic
|
||||
if asAdmin {
|
||||
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
|
||||
} else {
|
||||
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
|
||||
}
|
||||
err = storage.CreateUser(newUser, asAdmin)
|
||||
// Handle old syntax: "set -u user,pass -c config.yaml"
|
||||
if subcommand == "-u" {
|
||||
// Parse flags starting from os.Args[2:] to include the -u flag
|
||||
err := setCmd.Parse(os.Args[2:])
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("could not create user: %v", err))
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
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
|
||||
}
|
||||
user.Password = password
|
||||
if asAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
err = store.Users.Save(user, true)
|
||||
|
||||
// New pattern: subcommand-based (e.g., "set user" or "set rule")
|
||||
// Parse flags starting from os.Args[3:] to skip the subcommand
|
||||
err := setCmd.Parse(os.Args[3:])
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("could not update user: %v", err))
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
switch subcommand {
|
||||
case "rule":
|
||||
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
default:
|
||||
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", subcommand)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("successfully updated user: %s\n", username)
|
||||
return false
|
||||
|
||||
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 : %v
|
||||
Commit : %v
|
||||
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
|
||||
`, version.Version, version.CommitSHA, version.Version)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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>")
|
||||
}
|
||||
|
||||
// Initialize store and settings
|
||||
_ = getStore(dbConfig) // ignore bool check
|
||||
|
||||
// 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]
|
||||
_ = getStore(dbConfig) // ignore bool check
|
||||
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.Name,
|
||||
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.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 = 1
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// UserDefaults defines default settings for new users.
|
||||
type UserDefaults struct {
|
||||
Permissions users.Permissions `yaml:"permissions"`
|
||||
}
|
||||
|
||||
// Frontend defines settings related to the web interface.
|
||||
type Frontend struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
}
|
||||
|
||||
// Source defines a directory to be served.
|
||||
type Source struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
// Server defines server-specific configurations.
|
||||
type Server struct {
|
||||
Port int `yaml:"port"`
|
||||
Database string `yaml:"database"`
|
||||
Sources []Source `yaml:"sources"`
|
||||
Logging []LogConfig `json:"logging"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
|
||||
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
|
||||
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
|
||||
NoColors bool `json:"noColors"` // disable colors in the output
|
||||
Utc bool `json:"utc"` // use UTC time in the output instead of local time
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
AdminUsername string `yaml:"adminUsername"`
|
||||
AdminPassword string `yaml:"adminPassword"`
|
||||
}
|
||||
|
||||
// Settings is the top-level configuration structure.
|
||||
type Settings struct {
|
||||
Server Server `yaml:"server"`
|
||||
Frontend Frontend `yaml:"frontend,omitempty"`
|
||||
Auth Auth `yaml:"auth"`
|
||||
UserDefaults UserDefaults `yaml:"userDefaults"`
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// check if config file exists
|
||||
if _, err := os.Stat("config.yaml"); err == nil {
|
||||
fmt.Println("Config file 'config.yaml' already exists, skipping setup.")
|
||||
return
|
||||
}
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
config := Settings{
|
||||
Server: Server{
|
||||
Logging: []LogConfig{
|
||||
{
|
||||
Levels: "info|warning|error",
|
||||
ApiLevels: "info|warning|error",
|
||||
Output: "stdout",
|
||||
NoColors: false,
|
||||
Utc: false,
|
||||
},
|
||||
},
|
||||
Sources: []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.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
|
||||
config.UserDefaults.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
|
||||
})
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package cmd
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// healthcheck attempt to save test file against configured url
|
||||
|
||||
+118
-22
@@ -3,31 +3,38 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "net/http/pprof"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/icons"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/swaggo/swag"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
)
|
||||
|
||||
var store *storage.Storage
|
||||
var store *bolt.BoltStore
|
||||
|
||||
func getStore(configFile string) bool {
|
||||
// Use the config file (global flag)
|
||||
settings.Initialize(configFile)
|
||||
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
|
||||
if err != nil {
|
||||
logger.Fatal(fmt.Sprintf("could not load db info: %v", err))
|
||||
logger.Fatalf("could not load db info: %v", err)
|
||||
}
|
||||
store = s
|
||||
return hasDB
|
||||
@@ -51,6 +58,15 @@ func StartFilebrowser() {
|
||||
if !keepGoing {
|
||||
return
|
||||
}
|
||||
if !settings.Config.Server.DisableUpdateCheck {
|
||||
info, _ := utils.CheckForUpdates()
|
||||
if info.LatestVersion != "" {
|
||||
logger.Infof("A new version is available: %s (current: %s)", info.LatestVersion, info.CurrentVersion)
|
||||
logger.Infof("Release notes: %s", info.ReleaseNotes)
|
||||
}
|
||||
go utils.StartCheckForUpdates()
|
||||
}
|
||||
|
||||
// Create context and channels for graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -65,15 +81,38 @@ func StartFilebrowser() {
|
||||
if !dbExists {
|
||||
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
|
||||
}
|
||||
|
||||
// Dev mode enables development features like template hot-reloading
|
||||
_, err := os.Stat("http/dist")
|
||||
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
|
||||
if !settings.Env.IsDevMode {
|
||||
settings.Env.EmbeddedFs = os.IsNotExist(err)
|
||||
}
|
||||
|
||||
err = utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to set security hash: %v", err)
|
||||
}
|
||||
|
||||
sourceList := []string{}
|
||||
for path, source := range settings.Config.Server.SourceMap {
|
||||
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
|
||||
}
|
||||
logger.Info(fmt.Sprintf("Initializing FileBrowser Quantum (%v)", version.Version))
|
||||
logger.Info(fmt.Sprintf("Using Config file : %v", configPath))
|
||||
logger.Info(fmt.Sprintf("Auth Methods : %v", settings.Config.Auth.AuthMethods))
|
||||
logger.Infof("Initializing FileBrowser Quantum (%v)", version.Version)
|
||||
logger.Infof("Using Config file : %v", configPath)
|
||||
logger.Infof("Auth Methods : %v", settings.Config.Auth.AuthMethods)
|
||||
logger.Info(database)
|
||||
logger.Info(fmt.Sprintf("Sources : %v", sourceList))
|
||||
logger.Infof("Sources : %v", sourceList)
|
||||
logger.Debugf("Using Embedded FS : %v", settings.Env.EmbeddedFs)
|
||||
walModeStr := "OFF"
|
||||
if settings.Config.Server.IndexSqlConfig.WalMode {
|
||||
walModeStr = "WAL"
|
||||
}
|
||||
logger.Infof("SQL Journal Mode : %v", walModeStr)
|
||||
if settings.Config.Server.CacheDirCleanup {
|
||||
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
serverConfig := settings.Config.Server
|
||||
swagInfo := docs.SwaggerInfo
|
||||
swagInfo.BasePath = serverConfig.BaseURL
|
||||
@@ -82,15 +121,34 @@ func StartFilebrowser() {
|
||||
if len(settings.Config.Server.SourceMap) == 0 {
|
||||
logger.Fatal("No sources configured, exiting...")
|
||||
}
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
go indexing.Initialize(source, false)
|
||||
|
||||
// Initialize shared index database before starting HTTP service
|
||||
isNewDb, err := indexing.InitializeIndexDB()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to initialize index database: %v", err)
|
||||
}
|
||||
validateUserInfo()
|
||||
|
||||
// Set indexing storage for persistence
|
||||
if store != nil && store.Indexing != nil {
|
||||
indexing.SetIndexingStorage(store.Indexing)
|
||||
if isNewDb {
|
||||
if err := store.Indexing.ResetAllComplexities(); err != nil {
|
||||
logger.Errorf("Failed to reset index complexities: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
go indexing.Initialize(source, false, isNewDb)
|
||||
}
|
||||
validateUserInfo(!dbExists)
|
||||
validateOfficeIntegration()
|
||||
validateAccessRules()
|
||||
validateShareInfo()
|
||||
// Start the rootCMD in a goroutine
|
||||
go func() {
|
||||
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
|
||||
logger.Fatal(fmt.Sprintf("Error starting filebrowser: %v", err))
|
||||
logger.Fatalf("Error starting filebrowser: %v", err)
|
||||
}
|
||||
close(done) // Signal that the server has stopped
|
||||
}()
|
||||
@@ -102,25 +160,63 @@ func StartFilebrowser() {
|
||||
case <-done:
|
||||
logger.Info("Server stopped unexpectedly. Shutting down...")
|
||||
}
|
||||
fileutils.ClearCacheDir()
|
||||
|
||||
<-shutdownComplete // Ensure we don't exit prematurely
|
||||
// Wait for the server to stop
|
||||
// Stop all indexing scanners before closing the database
|
||||
indexing.StopAllScanners()
|
||||
|
||||
// Give scanners a moment to finish their current scan operations
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// cleanup temp databases
|
||||
indexDB := indexing.GetIndexDB()
|
||||
if indexDB != nil {
|
||||
indexDB.Close()
|
||||
}
|
||||
if settings.Config.Server.CacheDirCleanup {
|
||||
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
<-shutdownComplete
|
||||
logger.Info("Shutdown complete.")
|
||||
}
|
||||
|
||||
func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
|
||||
func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
|
||||
if serverConfig.NumImageProcessors < 1 {
|
||||
logger.Fatal("Image resize workers count could not be < 1")
|
||||
}
|
||||
cacheDir := settings.Config.Server.CacheDir
|
||||
numWorkers := settings.Config.Server.NumImageProcessors
|
||||
ffpmpegPath := settings.Config.Integrations.Media.FfmpegPath
|
||||
// setup disk cache
|
||||
err := preview.Start(numWorkers, ffpmpegPath, cacheDir)
|
||||
if err != nil {
|
||||
logger.Fatal(fmt.Sprintf("Error starting preview service: %v", err))
|
||||
ffmpeg.SetFFmpegPaths()
|
||||
|
||||
// Initialize asset filesystem before starting services
|
||||
if settings.Env.EmbeddedFs {
|
||||
embeddedAssets := fbhttp.GetEmbeddedAssets()
|
||||
subAssets, err := fs.Sub(embeddedAssets, "embed")
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to create sub filesystem: %v", err)
|
||||
}
|
||||
fileutils.InitAssetFS(subAssets, true)
|
||||
} else {
|
||||
fileutils.InitAssetFS(nil, false)
|
||||
}
|
||||
|
||||
// Start preview service
|
||||
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
|
||||
if err != nil {
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
|
||||
logger.Debugf("Exiftool Enabled : %v", settings.Config.Integrations.Media.ExiftoolPath != "")
|
||||
|
||||
// Generate PWA icons after preview service is initialized
|
||||
if err := icons.GeneratePWAIcons(); err != nil {
|
||||
logger.Warningf("Failed to generate PWA icons: %v", err)
|
||||
}
|
||||
|
||||
// Initialize PWA manifest after icons are generated
|
||||
icons.InitializePWAManifest()
|
||||
|
||||
fbhttp.StartHttp(ctx, store, shutdownComplete)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// validateShareInfo migrates share links to add default sidebar links
|
||||
func validateShareInfo() {
|
||||
if store.Share == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all shares
|
||||
shares, err := store.Share.All()
|
||||
if err != nil {
|
||||
logger.Debugf("No shares found or error getting shares: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
migratedCount := 0
|
||||
for _, link := range shares {
|
||||
// Check if this share needs migration (version not set or 0)
|
||||
if link.Version == 0 {
|
||||
// Add default sidebar links
|
||||
link.SidebarLinks = []users.SidebarLink{
|
||||
{
|
||||
Name: "Share QR Code and Info",
|
||||
Category: "shareInfo",
|
||||
Target: "#",
|
||||
Icon: "qr_code",
|
||||
},
|
||||
{
|
||||
Name: "Download",
|
||||
Category: "download",
|
||||
Target: "#",
|
||||
Icon: "download",
|
||||
},
|
||||
}
|
||||
|
||||
// Set version to 1 to indicate migration is complete
|
||||
link.Version = 1
|
||||
|
||||
// Save the updated share
|
||||
if err := store.Share.Save(link); err != nil {
|
||||
logger.Errorf("Failed to migrate share %s: %v", link.Hash, err)
|
||||
continue
|
||||
}
|
||||
|
||||
migratedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if migratedCount > 0 {
|
||||
logger.Infof("Migrated %d share links with default sidebar links", migratedCount)
|
||||
}
|
||||
}
|
||||
+163
-83
@@ -2,28 +2,27 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var createBackup = []bool{}
|
||||
var createBackup = false
|
||||
|
||||
func validateUserInfo() {
|
||||
func validateUserInfo(newDB bool) {
|
||||
// update source info for users if names/sources/paths might have changed
|
||||
usersList, err := store.Users.Gets()
|
||||
if err != nil {
|
||||
logger.Fatal(fmt.Sprintf("could not load users: %v", err))
|
||||
logger.Fatalf("could not load users: %v", err)
|
||||
}
|
||||
for _, user := range usersList {
|
||||
changePass := false
|
||||
updateUser := false
|
||||
if user.Username == "publicUser" {
|
||||
settings.ApplyUserDefaults(user)
|
||||
updateUser = true
|
||||
}
|
||||
if updateUserScopes(user) {
|
||||
updateUser = true
|
||||
}
|
||||
@@ -36,111 +35,99 @@ func validateUserInfo() {
|
||||
if updateLoginType(user) {
|
||||
updateUser = true
|
||||
}
|
||||
if updateShowFirstLogin(user) {
|
||||
updateUser = true
|
||||
}
|
||||
if updateSidebarLinks(user) {
|
||||
updateUser = true
|
||||
}
|
||||
if updateTokens(user) {
|
||||
updateUser = true
|
||||
}
|
||||
adminUser := settings.Config.Auth.AdminUsername
|
||||
adminPass := settings.Config.Auth.AdminPassword
|
||||
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
|
||||
if user.Username == adminUser && adminPass != "" && passwordEnabled {
|
||||
logger.Info("Resetting admin user to default username and password.")
|
||||
user.Permissions.Admin = true
|
||||
user.Password = settings.Config.Auth.AdminPassword
|
||||
updateUser = true
|
||||
changePass = true
|
||||
}
|
||||
if updateUser {
|
||||
if len(createBackup) == 1 {
|
||||
skipCreateBackup := os.Getenv("FILEBROWSER_DISABLE_AUTOMATIC_BACKUP") == "true" || newDB
|
||||
if createBackup && !skipCreateBackup {
|
||||
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
|
||||
err = fileutils.CopyFile(settings.Config.Server.Database, fmt.Sprintf("%s.bak", settings.Config.Server.Database))
|
||||
if err != nil {
|
||||
logger.Fatal(fmt.Sprintf("Unable to create automatic backup of database due to error: %v", err))
|
||||
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
|
||||
}
|
||||
}
|
||||
err := store.Users.Save(user, false)
|
||||
err := store.Users.Save(user, changePass, true)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("could not update user: %v", err))
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateUserScopes(user *users.User) bool {
|
||||
updateUser := false
|
||||
finalScopes := []users.SourceScope{}
|
||||
seenNames := make(map[string]bool)
|
||||
newScopes := []users.SourceScope{}
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
// Step 1: Start by including all existing scopes, updating Name if source.Path matches
|
||||
for _, existingScope := range user.Scopes {
|
||||
if existingScope.Scope != "/" {
|
||||
existingScope.Scope = strings.TrimSuffix(existingScope.Scope, "/")
|
||||
}
|
||||
if existingScope.Scope == "" {
|
||||
updateUser = true
|
||||
continue
|
||||
}
|
||||
if !seenNames[existingScope.Name] {
|
||||
finalScopes = append(finalScopes, existingScope)
|
||||
seenNames[existingScope.Name] = true
|
||||
}
|
||||
// Build map for existing scopes by Name
|
||||
existing := make(map[string]users.SourceScope)
|
||||
for _, s := range user.Scopes {
|
||||
existing[s.Name] = s
|
||||
}
|
||||
|
||||
// Step 2: Add missing scopes from SourceMap if admin or DefaultEnabled
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
if seenNames[source.Path] {
|
||||
// Preserve order by using Config.Server.Sources
|
||||
for _, src := range settings.Config.Server.Sources {
|
||||
existingScope, ok := existing[src.Path]
|
||||
if ok {
|
||||
// If scope is empty and there's a default, apply default
|
||||
if existingScope.Scope == "" {
|
||||
existingScope.Scope = src.Config.DefaultUserScope
|
||||
}
|
||||
} else if src.Config.DefaultEnabled {
|
||||
existingScope.Scope = src.Config.DefaultUserScope
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
// Check if scope exists already via path
|
||||
scopePath, _ := settings.GetScopeFromSourcePath(user.Scopes, source.Path)
|
||||
if scopePath == "" {
|
||||
// must be new scope for user
|
||||
// If user has no access, skip unless default-enabled
|
||||
if !source.Config.DefaultEnabled {
|
||||
continue
|
||||
}
|
||||
// Determine scope path
|
||||
if scopePath == "" {
|
||||
scopePath = source.Config.DefaultUserScope
|
||||
}
|
||||
if scopePath == "" {
|
||||
scopePath = "/"
|
||||
}
|
||||
if scopePath != "/" {
|
||||
scopePath = strings.TrimSuffix(scopePath, "/")
|
||||
}
|
||||
}
|
||||
|
||||
// Add new scope
|
||||
finalScopes = append(finalScopes, users.SourceScope{
|
||||
Scope: scopePath,
|
||||
Name: source.Path,
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: src.Path,
|
||||
Scope: existingScope.Scope,
|
||||
})
|
||||
seenNames[source.Path] = true
|
||||
updateUser = true
|
||||
seen[src.Path] = struct{}{}
|
||||
}
|
||||
|
||||
// Step 3: If no scopes, apply default
|
||||
if len(finalScopes) == 0 {
|
||||
defaultScope := users.SourceScope{
|
||||
Scope: settings.Config.Server.DefaultSource.Config.DefaultUserScope,
|
||||
Name: settings.Config.Server.DefaultSource.Path,
|
||||
// Preserve user-defined scopes not matching current sources, append to end
|
||||
for _, s := range user.Scopes {
|
||||
if _, ok := seen[s.Name]; !ok {
|
||||
newScopes = append(newScopes, s)
|
||||
}
|
||||
finalScopes = append(finalScopes, defaultScope)
|
||||
updateUser = true
|
||||
}
|
||||
|
||||
// Step 4: Assign only if different
|
||||
if !scopesEqual(user.Scopes, finalScopes) {
|
||||
user.Scopes = finalScopes
|
||||
updateUser = true
|
||||
}
|
||||
|
||||
return updateUser
|
||||
changed := !reflect.DeepEqual(user.Scopes, newScopes)
|
||||
user.Scopes = newScopes
|
||||
return changed
|
||||
}
|
||||
|
||||
// scopesEqual does a deep comparison to avoid unnecessary writes
|
||||
func scopesEqual(a, b []users.SourceScope) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
func updateShowFirstLogin(user *users.User) bool {
|
||||
if user.ShowFirstLogin && !settings.Env.IsFirstLoad {
|
||||
user.ShowFirstLogin = false
|
||||
return true
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Scope != b[i].Scope || a[i].Name != b[i].Name {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// func to convert legacy user with perm key to permissions
|
||||
func updatePermissions(user *users.User) bool {
|
||||
updateUser := false
|
||||
if user.Version >= 1 {
|
||||
return false
|
||||
}
|
||||
updateUser := true
|
||||
user.Permissions.Download = true
|
||||
// if any keys are true, set the permissions to true
|
||||
if user.Perm.Api {
|
||||
user.Permissions.Api = true
|
||||
@@ -162,8 +149,29 @@ func updatePermissions(user *users.User) bool {
|
||||
user.Perm.Share = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Perm.Create {
|
||||
user.Permissions.Create = true
|
||||
user.Perm.Create = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Perm.Create {
|
||||
user.Permissions.Create = true
|
||||
user.Perm.Create = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Perm.Download {
|
||||
user.Permissions.Download = true
|
||||
user.Perm.Download = false
|
||||
updateUser = true
|
||||
}
|
||||
if user.Permissions.Modify {
|
||||
user.Permissions.Create = true
|
||||
user.Permissions.Delete = true
|
||||
updateUser = true
|
||||
}
|
||||
user.Version = 2
|
||||
if updateUser {
|
||||
createBackup = append(createBackup, true)
|
||||
createBackup = true
|
||||
}
|
||||
return updateUser
|
||||
}
|
||||
@@ -185,3 +193,75 @@ func updatePreviewSettings(user *users.User) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// updateSidebarLinks checks if user has stale source links and rebuilds them if needed
|
||||
func updateSidebarLinks(user *users.User) bool {
|
||||
// Count source links and check if any are still valid
|
||||
sourceLinksCount := 0
|
||||
validSourceLinksCount := 0
|
||||
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
sourceLinksCount++
|
||||
// Check if this source still exists
|
||||
if link.SourceName != "" {
|
||||
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
|
||||
validSourceLinksCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If user has no source links, don't update anything
|
||||
if sourceLinksCount == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// If user has source links but NONE are valid, rebuild from their scopes
|
||||
if validSourceLinksCount == 0 {
|
||||
logger.Infof("User %s has %d stale source links, rebuilding from scopes", user.Username, sourceLinksCount)
|
||||
|
||||
// Remove all existing source links
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if !strings.HasPrefix(link.Category, "source") {
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new source links based on user's scopes
|
||||
for _, scope := range user.Scopes {
|
||||
if source, ok := settings.Config.Server.SourceMap[scope.Name]; ok {
|
||||
// User has access to this source, add it to sidebar
|
||||
newLinks = append(newLinks, users.SidebarLink{
|
||||
Name: source.Name,
|
||||
Category: "source",
|
||||
Target: "/",
|
||||
Icon: "",
|
||||
SourceName: source.Path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
user.SidebarLinks = newLinks
|
||||
return true
|
||||
}
|
||||
|
||||
// User has at least one valid source link, no update needed
|
||||
return false
|
||||
}
|
||||
|
||||
func updateTokens(user *users.User) bool {
|
||||
if user.Version >= 2 {
|
||||
return false
|
||||
}
|
||||
if user.ApiKeys != nil {
|
||||
user.Tokens = make(map[string]users.AuthToken)
|
||||
for name, token := range user.ApiKeys {
|
||||
token.Token = token.Key
|
||||
user.Tokens[name] = token
|
||||
}
|
||||
}
|
||||
user.Version = 2
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
|
||||
sourceA := settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
|
||||
DefaultEnabled: false,
|
||||
DefaultUserScope: "/defaultA",
|
||||
}}
|
||||
sourceB := settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
|
||||
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
"/pathA": &sourceA,
|
||||
"/pathB": &sourceB,
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"sourceA": &sourceA,
|
||||
"sourceB": &sourceB,
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Test Cases Definition
|
||||
// ---------------------
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *users.User
|
||||
expectedPhase1 []users.SourceScope
|
||||
expectedPhase2 []users.SourceScope
|
||||
}{
|
||||
{
|
||||
name: "Single valid scope",
|
||||
user: &users.User{
|
||||
Scopes: []users.SourceScope{
|
||||
{Scope: "/home", Name: "/pathA"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/home", Name: "/pathA"},
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/home", Name: "/pathA"},
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Single empty scope path",
|
||||
user: &users.User{
|
||||
Scopes: []users.SourceScope{
|
||||
{Scope: "", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/defaultA", Name: "/pathA"},
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Two scopes, one includes username in path",
|
||||
user: &users.User{
|
||||
Username: "user123",
|
||||
Scopes: []users.SourceScope{
|
||||
{Scope: "/home/user123", Name: "/pathA"},
|
||||
{Scope: "/data", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/home/user123", Name: "/pathA"},
|
||||
{Scope: "/data", Name: "/pathB"},
|
||||
},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/home/user123", Name: "/pathA"},
|
||||
{Scope: "/data", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Two scopes, one with empty name",
|
||||
user: &users.User{
|
||||
Scopes: []users.SourceScope{
|
||||
{Scope: "/home", Name: "/pathB"},
|
||||
{Scope: "/data", Name: "/somethingElse"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/home", Name: "/pathB"},
|
||||
{Scope: "/data", Name: "/somethingElse"},
|
||||
},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/defaultA", Name: "/pathA"},
|
||||
{Scope: "/home", Name: "/pathB"},
|
||||
{Scope: "/data", Name: "/somethingElse"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "No scopes at all",
|
||||
user: &users.User{
|
||||
Scopes: []users.SourceScope{},
|
||||
},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/defaultA", Name: "/pathA"},
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All user Scope and source change",
|
||||
user: &users.User{
|
||||
Username: "user123",
|
||||
Scopes: []users.SourceScope{
|
||||
{Scope: "/defaultC/user123", Name: "/pathC"},
|
||||
{Scope: "/defaultA/user123", Name: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/defaultA/user123", Name: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Name: "/pathB"},
|
||||
{Scope: "/defaultC/user123", Name: "/pathC"},
|
||||
},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/defaultA/user123", Name: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Name: "/pathB"},
|
||||
{Scope: "/defaultC/user123", Name: "/pathC"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Phase 1 Tests
|
||||
// ---------------------
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name+"_Phase1", func(t *testing.T) {
|
||||
originalScopes := tc.user.Scopes
|
||||
updated := updateUserScopes(tc.user)
|
||||
assert.Equal(t, tc.expectedPhase1, tc.user.Scopes, "Phase1 scope mismatch for test case: %s", tc.name)
|
||||
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.Scopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.Scopes, tc.expectedPhase1)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Phase 2: rename sources but keep same paths
|
||||
// ---------------------
|
||||
sourceA = settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
|
||||
DefaultEnabled: true,
|
||||
DefaultUserScope: "/defaultA",
|
||||
}}
|
||||
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
|
||||
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
"/pathA": &sourceA,
|
||||
"/pathB": &sourceB,
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"sourceA": &sourceA,
|
||||
"sourceB": &sourceB,
|
||||
}
|
||||
|
||||
// Run again without resetting user objects to test idempotency + renaming
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name+"_Phase2", func(t *testing.T) {
|
||||
originalScopes := tc.user.Scopes
|
||||
updated := updateUserScopes(tc.user)
|
||||
assert.Equal(t, tc.expectedPhase2, tc.user.Scopes, "Phase2 scope mismatch for test case: %s", tc.name)
|
||||
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.Scopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.Scopes, tc.expectedPhase1)
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
-88
@@ -1,88 +0,0 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
DiskUsage = NewCache(30*time.Second, 24*time.Hour)
|
||||
RealPath = NewCache(48*time.Hour, 72*time.Hour)
|
||||
SearchResults = NewCache(15*time.Second, 1*time.Hour)
|
||||
OnlyOffice = NewCache(48*time.Hour, 1*time.Hour)
|
||||
JwtCache = NewCache(1*time.Hour, 72*time.Hour)
|
||||
)
|
||||
|
||||
func NewCache(expires time.Duration, cleanup time.Duration) *KeyCache {
|
||||
newCache := KeyCache{
|
||||
data: make(map[string]cachedValue),
|
||||
expiresAfter: expires, // default
|
||||
}
|
||||
go newCache.cleanupExpiredJob(cleanup)
|
||||
return &newCache
|
||||
}
|
||||
|
||||
type KeyCache struct {
|
||||
data map[string]cachedValue
|
||||
mu sync.RWMutex
|
||||
expiresAfter time.Duration
|
||||
}
|
||||
|
||||
type cachedValue struct {
|
||||
value interface{}
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func (c *KeyCache) Set(key string, value interface{}) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.data[key] = cachedValue{
|
||||
value: value,
|
||||
expiresAt: time.Now().Add(c.expiresAfter),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *KeyCache) Delete(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.data, key)
|
||||
}
|
||||
|
||||
func (c *KeyCache) SetWithExp(key string, value interface{}, exp time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.data[key] = cachedValue{
|
||||
value: value,
|
||||
expiresAt: time.Now().Add(exp),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *KeyCache) Get(key string) interface{} {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
cached, ok := c.data[key]
|
||||
if !ok || time.Now().After(cached.expiresAt) {
|
||||
return nil
|
||||
}
|
||||
return cached.value
|
||||
}
|
||||
|
||||
func (c *KeyCache) cleanupExpired() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
now := time.Now()
|
||||
for key, cached := range c.data {
|
||||
if now.After(cached.expiresAt) {
|
||||
delete(c.data, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// should automatically run for all cache types as part of init.
|
||||
func (c *KeyCache) cleanupExpiredJob(frequency time.Duration) {
|
||||
ticker := time.NewTicker(frequency)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
c.cleanupExpired()
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,15 @@ var (
|
||||
ErrInvalidOption = errors.New("invalid option")
|
||||
ErrInvalidAuthMethod = errors.New("invalid auth method")
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
ErrAccessDenied = errors.New("access denied")
|
||||
ErrInvalidRequestParams = errors.New("invalid request params")
|
||||
ErrSourceIsParent = errors.New("source is parent")
|
||||
ErrRootUserDeletion = errors.New("user with id 1 can't be deleted")
|
||||
ErrNoTotpProvided = errors.New("OTP code is required for user")
|
||||
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
|
||||
ErrUnauthorized = errors.New("user unauthorized")
|
||||
ErrNotIndexed = errors.New("directory or item excluded from indexing")
|
||||
ErrNotViewable = errors.New("directory or item is not viewable")
|
||||
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
|
||||
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
|
||||
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
|
||||
)
|
||||
@@ -1,141 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Logger wraps the standard log.Logger with log level functionality
|
||||
type Logger struct {
|
||||
logger *log.Logger
|
||||
levels []LogLevel
|
||||
apiLevels []LogLevel
|
||||
stdout bool
|
||||
disabled bool
|
||||
debugEnabled bool
|
||||
disabledAPI bool
|
||||
colors bool
|
||||
}
|
||||
|
||||
var stdOutLoggerExists bool
|
||||
|
||||
// NewLogger creates a new Logger instance with separate file and stdout loggers
|
||||
func NewLogger(filepath string, levels, apiLevels []LogLevel, noColors bool) (*Logger, error) {
|
||||
var fileWriter io.Writer = io.Discard
|
||||
stdout := filepath == ""
|
||||
// Configure file logging
|
||||
if !stdout {
|
||||
file, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open log file: %v", err)
|
||||
}
|
||||
fileWriter = file
|
||||
}
|
||||
var flags int
|
||||
if slices.Contains(levels, DEBUG) {
|
||||
flags |= log.Lshortfile
|
||||
}
|
||||
logger := log.New(os.Stdout, "", flags)
|
||||
if filepath != "" {
|
||||
logger = log.New(fileWriter, "", flags)
|
||||
}
|
||||
if stdout {
|
||||
stdOutLoggerExists = true
|
||||
}
|
||||
return &Logger{
|
||||
logger: logger,
|
||||
levels: levels,
|
||||
apiLevels: apiLevels,
|
||||
disabled: slices.Contains(levels, DISABLED),
|
||||
debugEnabled: slices.Contains(levels, DEBUG),
|
||||
disabledAPI: slices.Contains(apiLevels, DISABLED),
|
||||
colors: !noColors,
|
||||
stdout: stdout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetupLogger configures the logger with file and stdout options and their respective log levels
|
||||
func SetupLogger(output, levels, apiLevels string, noColors bool) error {
|
||||
upperLevels := []LogLevel{}
|
||||
for _, level := range SplitByMultiple(levels) {
|
||||
if level == "" {
|
||||
break
|
||||
}
|
||||
upperLevel := strings.ToUpper(level)
|
||||
if upperLevel == "WARNING" || upperLevel == "WARN" {
|
||||
upperLevel = "WARN "
|
||||
}
|
||||
if upperLevel == "INFO" {
|
||||
upperLevel = "INFO "
|
||||
}
|
||||
// Convert level strings to LogLevel
|
||||
level, ok := stringToLevel[upperLevel]
|
||||
if !ok {
|
||||
loggers = []*Logger{}
|
||||
return fmt.Errorf("invalid file log level: %s", upperLevel)
|
||||
}
|
||||
upperLevels = append(upperLevels, level)
|
||||
}
|
||||
if len(upperLevels) == 0 {
|
||||
upperLevels = []LogLevel{INFO, ERROR, WARNING}
|
||||
}
|
||||
upperApiLevels := []LogLevel{}
|
||||
for _, level := range SplitByMultiple(apiLevels) {
|
||||
if level == "" {
|
||||
break
|
||||
}
|
||||
upperLevel := strings.ToUpper(level)
|
||||
if upperLevel == "WARNING" || upperLevel == "WARN" {
|
||||
upperLevel = "WARN "
|
||||
}
|
||||
if upperLevel == "INFO" {
|
||||
upperLevel = "INFO "
|
||||
}
|
||||
// Convert level strings to LogLevel
|
||||
level, ok := stringToLevel[strings.ToUpper(upperLevel)]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid api log level: %s", upperLevel)
|
||||
}
|
||||
upperApiLevels = append(upperApiLevels, level)
|
||||
}
|
||||
if len(upperApiLevels) == 0 {
|
||||
upperApiLevels = []LogLevel{INFO, ERROR, WARNING}
|
||||
}
|
||||
if slices.Contains(upperLevels, DISABLED) && slices.Contains(upperApiLevels, DISABLED) {
|
||||
// both disabled, not creating a logger
|
||||
loggers = []*Logger{}
|
||||
return nil
|
||||
}
|
||||
outputStdout := strings.ToUpper(output)
|
||||
if outputStdout == "STDOUT" {
|
||||
output = ""
|
||||
}
|
||||
if output == "" && stdOutLoggerExists {
|
||||
// stdout logger already exists... don't create another
|
||||
return fmt.Errorf("stdout logger already exists, could not set config levels=[%v] apiLevels=[%v] noColors=[%v]", levels, apiLevels, noColors)
|
||||
}
|
||||
// Create the logger
|
||||
logger, err := NewLogger(output, upperLevels, upperApiLevels, noColors)
|
||||
if err != nil {
|
||||
loggers = []*Logger{}
|
||||
return err
|
||||
}
|
||||
loggers = append(loggers, logger)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
DISABLED LogLevel = 0
|
||||
ERROR LogLevel = 1
|
||||
FATAL LogLevel = 1
|
||||
WARNING LogLevel = 2
|
||||
INFO LogLevel = 3
|
||||
DEBUG LogLevel = 4
|
||||
API LogLevel = 10
|
||||
// COLORS
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
GRAY = "\033[37m"
|
||||
)
|
||||
|
||||
var (
|
||||
loggers []*Logger
|
||||
)
|
||||
|
||||
type levelConsts struct {
|
||||
INFO string
|
||||
FATAL string
|
||||
ERROR string
|
||||
WARNING string
|
||||
DEBUG string
|
||||
API string
|
||||
DISABLED string
|
||||
}
|
||||
|
||||
var levels = levelConsts{
|
||||
INFO: "INFO ", // with consistent space padding
|
||||
FATAL: "FATAL",
|
||||
ERROR: "ERROR",
|
||||
WARNING: "WARN ", // with consistent space padding
|
||||
DEBUG: "DEBUG",
|
||||
DISABLED: "DISABLED",
|
||||
API: "API",
|
||||
}
|
||||
|
||||
// stringToLevel maps string representation to LogLevel
|
||||
var stringToLevel = map[string]LogLevel{
|
||||
"DEBUG": DEBUG,
|
||||
"INFO ": INFO, // with consistent space padding
|
||||
"ERROR": ERROR,
|
||||
"DISABLED": DISABLED,
|
||||
"WARN ": WARNING, // with consistent space padding
|
||||
"FATAL": FATAL,
|
||||
"API": API,
|
||||
}
|
||||
|
||||
// Log prints a log message if its level is greater than or equal to the logger's levels
|
||||
func Log(level string, msg string, prefix, api bool, color string) {
|
||||
LEVEL := stringToLevel[level]
|
||||
for _, logger := range loggers {
|
||||
if api {
|
||||
if logger.disabledAPI || !slices.Contains(logger.apiLevels, LEVEL) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if logger.disabled || !slices.Contains(logger.levels, LEVEL) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if logger.stdout && level == "FATAL" {
|
||||
continue
|
||||
}
|
||||
writeOut := msg
|
||||
formattedTime := time.Now().Format("2006/01/02 15:04:05")
|
||||
if logger.colors && color != "" {
|
||||
formattedTime = formattedTime + color
|
||||
}
|
||||
if prefix || logger.debugEnabled {
|
||||
logger.logger.SetPrefix(fmt.Sprintf("%s [%s] ", formattedTime, level))
|
||||
} else {
|
||||
logger.logger.SetPrefix(formattedTime + " ")
|
||||
}
|
||||
if logger.colors && color != "" {
|
||||
writeOut = writeOut + "\033[0m"
|
||||
}
|
||||
err := logger.logger.Output(3, writeOut) // 3 skips this function for correct file:line
|
||||
if err != nil {
|
||||
log.Printf("failed to log message '%v' with error `%v`", msg, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Api(msg string, statusCode int) {
|
||||
// redirects are not warnings anymore
|
||||
if statusCode > 302 && statusCode < 500 {
|
||||
Log(levels.WARNING, msg, false, true, YELLOW)
|
||||
} else if statusCode >= 500 {
|
||||
Log(levels.ERROR, msg, false, true, RED)
|
||||
} else {
|
||||
Log(levels.INFO, msg, false, true, GREEN)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for specific log levels
|
||||
func Debug(msg string) {
|
||||
if len(loggers) > 0 {
|
||||
Log(levels.DEBUG, msg, true, false, GRAY)
|
||||
} else {
|
||||
log.Println("[DEBUG] ", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Info(msg string) {
|
||||
if len(loggers) > 0 {
|
||||
Log(levels.INFO, msg, false, false, "")
|
||||
} else {
|
||||
log.Println(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Warning(msg string) {
|
||||
if len(loggers) > 0 {
|
||||
Log(levels.WARNING, msg, true, false, YELLOW)
|
||||
} else {
|
||||
log.Println("[WARN ] ", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Error(msg string) {
|
||||
if len(loggers) > 0 {
|
||||
Log(levels.ERROR, msg, true, false, RED)
|
||||
} else {
|
||||
log.Println("[ERROR]", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Fatal(msg string) {
|
||||
if len(loggers) > 0 {
|
||||
Log(levels.FATAL, msg, true, false, RED)
|
||||
}
|
||||
log.Fatal("[FATAL] ", msg)
|
||||
}
|
||||
+269
-20
@@ -1,11 +1,56 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
ldap "github.com/go-ldap/ldap/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// userAgentTransport wraps an http.RoundTripper to add a User-Agent header
|
||||
type userAgentTransport struct {
|
||||
base http.RoundTripper
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// RoundTrip implements the http.RoundTripper interface
|
||||
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Clone the request to avoid modifying the original
|
||||
reqCopy := req.Clone(req.Context())
|
||||
// Set User-Agent header if not already set
|
||||
if reqCopy.Header.Get("User-Agent") == "" {
|
||||
reqCopy.Header.Set("User-Agent", t.userAgent)
|
||||
}
|
||||
return t.base.RoundTrip(reqCopy)
|
||||
}
|
||||
|
||||
// AuthCommon contains fields shared across multiple authentication methods
|
||||
type AuthCommon struct {
|
||||
Enabled bool `json:"enabled"` // whether to enable this authentication method.
|
||||
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges
|
||||
UserGroups []string `json:"userGroups"` // if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
|
||||
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
|
||||
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
|
||||
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification (insecure, for testing only)
|
||||
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
CreateUser bool `json:"createUser"` // deprecated: always true for supported authentication methods
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
TokenExpirationHours int `json:"tokenExpirationHours"` // the number of hours until the token expires. Default is 2 hours.
|
||||
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
|
||||
Methods LoginMethods `json:"methods"`
|
||||
Key string `json:"key"` // the key used to sign the JWT tokens. If not set, a random key will be generated.
|
||||
AdminUsername string `json:"adminUsername"` // the username of the admin user. If not set, the default is "admin".
|
||||
AdminPassword string `json:"adminPassword"` // the password of the admin user. If not set, the default is "admin".
|
||||
Key string `json:"key"` // secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
|
||||
AdminUsername string `json:"adminUsername"` // secret: the username of the admin user. If not set, the default is "admin".
|
||||
AdminPassword string `json:"adminPassword"` // secret: the password of the admin user. If not set, the default is "admin".
|
||||
TotpSecret string `json:"totpSecret"` // secret: secret used to encrypt TOTP secrets
|
||||
AuthMethods []string `json:"-"`
|
||||
}
|
||||
|
||||
@@ -14,19 +59,21 @@ type LoginMethods struct {
|
||||
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
|
||||
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
|
||||
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
|
||||
LdapAuth LdapConfig `json:"ldap" validate:"omitempty"`
|
||||
JwtAuth JwtAuthConfig `json:"jwt" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type PasswordAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MinLength int `json:"minLength" validate:"omitempty,min=5"`
|
||||
Signup bool `json:"signup" validate:"omitempty"` // currently not used by filebrowser
|
||||
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
|
||||
Enabled bool `json:"enabled"`
|
||||
MinLength int `json:"minLength" validate:"omitempty"` // minimum pasword length required, default is 5.
|
||||
Signup bool `json:"signup" validate:"omitempty"` // allow signups on login page if enabled -- not secure.
|
||||
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
|
||||
EnforcedOtp bool `json:"enforcedOtp"` // if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
|
||||
}
|
||||
|
||||
type ProxyAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CreateUser bool `json:"createUser"` // create user if not exists
|
||||
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
|
||||
AuthCommon `json:",inline"`
|
||||
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
|
||||
}
|
||||
|
||||
type Recaptcha struct {
|
||||
@@ -37,13 +84,215 @@ type Recaptcha struct {
|
||||
|
||||
// OpenID OAuth2.0
|
||||
type OidcConfig struct {
|
||||
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
|
||||
ClientID string `json:"clientId" validate:"required"` // client id of the OIDC application
|
||||
ClientSecret string `json:"clientSecret" validate:"required"` // client secret of the OIDC application
|
||||
AuthorizationUrl string `json:"authorizationUrl" validate:"required"` // authorization URL of the OIDC provider
|
||||
TokenUrl string `json:"tokenUrl" validate:"required"` // token URL of the OIDC provider
|
||||
UserInfoUrl string `json:"userInfoUrl" validate:"required"` // user info URL of the OIDC provider
|
||||
Scopes string `json:"scopes" validate:"required"` // space separated list of scopes to request from the OIDC provider
|
||||
UserIdentifier string `json:"userIdentifier"` // optional: which attribute should be used as the username? options: email, username, name, phone_number, sub
|
||||
JwksUrl string `json:"jwksUrl"` // currently not used by filebrowser
|
||||
AuthCommon `json:",inline"`
|
||||
ClientID string `json:"clientId"` // secret: client id of the OIDC application
|
||||
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
|
||||
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
|
||||
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
|
||||
Provider *oidc.Provider `json:"-"` // OIDC provider
|
||||
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
|
||||
}
|
||||
|
||||
type LdapConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Server string `json:"server"` // scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
|
||||
BaseDN string `json:"baseDN"` // LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
|
||||
UserDN string `json:"userDN"` // Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
|
||||
UserPassword string `json:"userPassword"` // Bind password for service account
|
||||
UserFilter string `json:"userFilter"` // Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
|
||||
Port int `json:"-"` // derived from server
|
||||
Scheme string `json:"-"` // derived from server
|
||||
Host string `json:"-"` // derived from server
|
||||
}
|
||||
|
||||
// JwtAuthConfig configures external JWT token authentication
|
||||
// Similar to Grafana's JWT auth: accepts external JWT tokens signed with a shared secret
|
||||
// The query parameter is hardcoded to "jwt" (e.g. ?jwt=<token>)
|
||||
type JwtAuthConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
|
||||
Secret string `json:"secret"` // secret: shared secret key for verifying JWT token signatures (required)
|
||||
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
|
||||
}
|
||||
|
||||
// ValidateLdapAuth checks LDAP config and sets defaults. Call when LDAP is enabled.
|
||||
func ValidateLdapAuth() error {
|
||||
ldapCfg := &Config.Auth.Methods.LdapAuth
|
||||
if ldapCfg.Server == "" {
|
||||
return fmt.Errorf("LDAP server is required when LDAP is enabled")
|
||||
}
|
||||
scheme, host, port, err := parseLdapServer(ldapCfg.Server)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP server is invalid: %w", err)
|
||||
}
|
||||
ldapCfg.Server = fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
ldapCfg.Scheme = scheme
|
||||
ldapCfg.Host = host
|
||||
ldapCfg.Port = port
|
||||
|
||||
if ldapCfg.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP baseDN is required when LDAP is enabled")
|
||||
}
|
||||
if ldapCfg.UserDN == "" {
|
||||
return fmt.Errorf("LDAP userDN (bind DN) is required when LDAP is enabled")
|
||||
}
|
||||
if ldapCfg.UserFilter == "" {
|
||||
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
|
||||
}
|
||||
if err := verifyLdapConnection(); err != nil {
|
||||
logger.Fatalf("LDAP connection check failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
|
||||
func validateOidcAuth() error {
|
||||
oidcCfg := &Config.Auth.Methods.OidcAuth // Use a pointer to modify the original config
|
||||
if oidcCfg.GroupsClaim == "" {
|
||||
oidcCfg.GroupsClaim = "groups"
|
||||
}
|
||||
if oidcCfg.UserIdentifier == "" {
|
||||
oidcCfg.UserIdentifier = "preferred_username"
|
||||
}
|
||||
if oidcCfg.Scopes == "" {
|
||||
oidcCfg.Scopes = "openid email profile"
|
||||
}
|
||||
|
||||
if !oidcCfg.Enabled {
|
||||
return errors.New("OIDC is not enabled")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a custom HTTP client with proper User-Agent to avoid being blocked by bot protection
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment, // Respect HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables
|
||||
}
|
||||
|
||||
// If disableVerifyTLS is true, disable TLS verification
|
||||
if oidcCfg.DisableVerifyTLS {
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
logger.Warning("OIDC TLS verification is disabled.")
|
||||
}
|
||||
|
||||
// Create custom client with User-Agent header to bypass bot protection (like Cloudflare Bot Fight Mode)
|
||||
customClient := &http.Client{
|
||||
Transport: &userAgentTransport{
|
||||
base: transport,
|
||||
userAgent: fmt.Sprintf("FileBrowser Quantum - %s (OIDC Client)", version.Version),
|
||||
},
|
||||
}
|
||||
|
||||
// Use oidc.ClientContext to pass the custom client to the OIDC library
|
||||
ctx = oidc.ClientContext(ctx, customClient)
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, oidcCfg.IssuerUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("url '%v' failed to create OIDC provider: %w", oidcCfg.IssuerUrl, err)
|
||||
}
|
||||
oidcCfg.Provider = provider
|
||||
oidcCfg.Verifier = provider.Verifier(&oidc.Config{ClientID: oidcCfg.ClientID})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLdapServer(server string) (scheme, host string, port int, err error) {
|
||||
parts := strings.Split(server, "://")
|
||||
if len(parts) != 2 {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP server: %s", server)
|
||||
}
|
||||
scheme = strings.ToLower(parts[0])
|
||||
if scheme != "ldap" && scheme != "ldaps" {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP scheme: %s", scheme)
|
||||
}
|
||||
hostParts := strings.Split(parts[1], ":")
|
||||
host = strings.TrimSuffix(hostParts[0], "/")
|
||||
if len(hostParts) > 1 && hostParts[1] != "" {
|
||||
port, err = strconv.Atoi(strings.TrimSuffix(hostParts[1], "/"))
|
||||
if err != nil || port == 0 {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP port: %s", hostParts[1])
|
||||
}
|
||||
} else {
|
||||
if scheme == "ldaps" {
|
||||
port = 636
|
||||
} else {
|
||||
port = 389
|
||||
}
|
||||
}
|
||||
return scheme, host, port, nil
|
||||
}
|
||||
|
||||
// VerifyLdapConnection tests that the LDAP server is reachable, can bind (if credentials set), and responds to a search.
|
||||
func verifyLdapConnection() error {
|
||||
if Config.Auth.Methods.LdapAuth.Server == "" {
|
||||
return fmt.Errorf("LDAP server is required when LDAP is enabled")
|
||||
}
|
||||
if Config.Auth.Methods.LdapAuth.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP baseDN is required for connection verification")
|
||||
}
|
||||
var opts []ldap.DialOpt
|
||||
scheme := Config.Auth.Methods.LdapAuth.Scheme
|
||||
host := Config.Auth.Methods.LdapAuth.Host
|
||||
port := Config.Auth.Methods.LdapAuth.Port
|
||||
if Config.Auth.Methods.LdapAuth.DisableVerifyTLS && scheme == "ldaps" {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
|
||||
}
|
||||
fullURL := fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
conn, err := ldap.DialURL(fullURL, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP connection failed (is the LDAP outpost running at %s? use port 389 for LDAP, 636 for LDAPS; this is not the HTTP port): %w", fullURL, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if Config.Auth.Methods.LdapAuth.UserDN != "" && Config.Auth.Methods.LdapAuth.UserPassword != "" {
|
||||
if err = conn.Bind(Config.Auth.Methods.LdapAuth.UserDN, Config.Auth.Methods.LdapAuth.UserPassword); err != nil {
|
||||
return fmt.Errorf("LDAP bind failed (check userDN and userPassword): %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Unbind() }()
|
||||
}
|
||||
// Dummy search to verify the server responds to LDAP search (same path that fails at login if misconfigured).
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
Config.Auth.Methods.LdapAuth.BaseDN,
|
||||
ldap.ScopeBaseObject,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
"(objectClass=*)",
|
||||
[]string{"1.1"}, // no attributes
|
||||
nil,
|
||||
)
|
||||
_, err = conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP search test failed (server may require StartTLS or bind before search): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJwtAuth checks JWT config and sets defaults. Call when JWT auth is enabled.
|
||||
func ValidateJwtAuth() error {
|
||||
jwtCfg := &Config.Auth.Methods.JwtAuth
|
||||
if jwtCfg.Secret == "" {
|
||||
return fmt.Errorf("JWT secret is required when JWT auth is enabled")
|
||||
}
|
||||
if jwtCfg.Header == "" {
|
||||
jwtCfg.Header = "X-JWT-Assertion"
|
||||
}
|
||||
if jwtCfg.Algorithm == "" {
|
||||
jwtCfg.Algorithm = "HS256"
|
||||
}
|
||||
if jwtCfg.GroupsClaim == "" {
|
||||
jwtCfg.GroupsClaim = "groups"
|
||||
}
|
||||
if jwtCfg.UserIdentifier == "" {
|
||||
jwtCfg.UserIdentifier = "sub"
|
||||
}
|
||||
// Validate algorithm
|
||||
validAlgos := map[string]bool{
|
||||
"HS256": true, "HS384": true, "HS512": true,
|
||||
"RS256": true, "RS384": true, "RS512": true,
|
||||
"ES256": true, "ES384": true, "ES512": true,
|
||||
}
|
||||
if !validAlgos[jwtCfg.Algorithm] {
|
||||
return fmt.Errorf("unsupported JWT algorithm: %s. Supported: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512", jwtCfg.Algorithm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLdapServer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
server string
|
||||
wantScheme string
|
||||
wantHost string
|
||||
wantPort int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ldap with host and port",
|
||||
server: "ldap://localhost:389",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "localhost",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldaps with host and port",
|
||||
server: "ldaps://ldap.example.com:636",
|
||||
wantScheme: "ldaps",
|
||||
wantHost: "ldap.example.com",
|
||||
wantPort: 636,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldap with trailing slash",
|
||||
server: "ldap://host:389/",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "host",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldap port not provided defaults to 389",
|
||||
server: "ldap://localhost",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "localhost",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldaps port not provided defaults to 636",
|
||||
server: "ldaps://ldap.example.com",
|
||||
wantScheme: "ldaps",
|
||||
wantHost: "ldap.example.com",
|
||||
wantPort: 636,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "port provided but invalid returns error",
|
||||
server: "ldap://host:bad",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "port provided but invalid (ldaps) returns error",
|
||||
server: "ldaps://host:xyz",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "port zero returns error",
|
||||
server: "ldap://host:0",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty server",
|
||||
server: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no scheme",
|
||||
server: "localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme",
|
||||
server: "http://localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme ftp",
|
||||
server: "ftp://localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotScheme, gotHost, gotPort, err := parseLdapServer(tt.server)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseLdapServer() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr {
|
||||
if gotScheme != tt.wantScheme {
|
||||
t.Errorf("parseLdapServer() scheme = %v, want %v", gotScheme, tt.wantScheme)
|
||||
}
|
||||
if gotHost != tt.wantHost {
|
||||
t.Errorf("parseLdapServer() host = %v, want %v", gotHost, tt.wantHost)
|
||||
}
|
||||
if gotPort != tt.wantPort {
|
||||
t.Errorf("parseLdapServer() port = %v, want %v", gotPort, tt.wantPort)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLdapConnection_ValidationOnly(t *testing.T) {
|
||||
// Save and restore global config so we don't affect other tests
|
||||
saved := Config.Auth.Methods.LdapAuth
|
||||
defer func() { Config.Auth.Methods.LdapAuth = saved }()
|
||||
|
||||
t.Run("empty server returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
}
|
||||
err := verifyLdapConnection()
|
||||
if err == nil {
|
||||
t.Fatal("verifyLdapConnection() expected error for empty server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "LDAP server is required") {
|
||||
t.Errorf("verifyLdapConnection() error = %v, want message containing 'LDAP server is required'", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty baseDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
Scheme: "ldap",
|
||||
Host: "localhost",
|
||||
Port: 389,
|
||||
BaseDN: "",
|
||||
}
|
||||
err := verifyLdapConnection()
|
||||
if err == nil {
|
||||
t.Fatal("verifyLdapConnection() expected error for empty baseDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "baseDN is required") {
|
||||
t.Errorf("verifyLdapConnection() error = %v, want message containing 'baseDN is required'", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateLdapAuth_ConfigValidation(t *testing.T) {
|
||||
saved := Config.Auth.Methods.LdapAuth
|
||||
defer func() { Config.Auth.Methods.LdapAuth = saved }()
|
||||
|
||||
t.Run("empty server returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "server is required") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid server URL returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "not-a-valid-ldap-url",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for invalid server URL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty baseDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
BaseDN: "",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty baseDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "baseDN is required") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty userDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty userDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "userDN") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
+789
-185
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
||||
package settings
|
||||
|
||||
func CanConvertImage(ext string) bool {
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func CanConvertVideo(ext string) bool {
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func MediaEnabled() bool {
|
||||
return Env.FFmpegPath != "" && Env.FFprobePath != ""
|
||||
}
|
||||
Loaded 100 of 570 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user