Compare commits

...
46 Commits
Author SHA1 Message Date
Graham Steffaniak 018be705a6 updated with hotloading (#1153) 2025-08-28 15:00:12 -05:00
Graham Steffaniak ff5010ed4d updated changelog 2025-08-27 13:14:50 -05:00
Graham Steffaniak 226038b11d moved css to search component (#1149) 2025-08-27 13:12:02 -05:00
Graham Steffaniak 206180de0d Beta/v0.8.2 (#1123) 2025-08-25 10:58:31 -05:00
Graham Steffaniak 5b0f388fa5 Beta/v0.8.1 (#1106) 2025-08-22 17:45:44 -05:00
7d0707ebe0 Beta/v0.8.0 (#1080)
Co-authored-by: Kurami <178341547+Kurami32@users.noreply.github.com>
Co-authored-by: Socramk <155040241+socramcaro2@users.noreply.github.com>
2025-08-20 10:04:44 -05:00
Kurami 41cb97a1c5 Updated translation for spanish (#1076) 2025-08-19 16:03:10 -05:00
Socramk 0b212ce1b4 Traduccion es (#1063) 2025-08-16 11:44:49 -05:00
Kurami 7f0a0209af Update translation for spanish (#1055) 2025-08-12 09:25:38 -05:00
Graham Steffaniakandmordillo 642333ec68 Beta/v0.7.18 (#1034)
Co-authored-by: mordillo <121123040+mordilloSan@users.noreply.github.com>
2025-07-30 17:50:07 -05:00
Graham Steffaniakandmordillo ec43926d3c Beta/v0.7.17 (#1017)
Co-authored-by: mordillo <121123040+mordilloSan@users.noreply.github.com>
2025-07-25 17:46:00 -05:00
Graham Steffaniak 9202043000 Update pr.yaml to run against main (#998) 2025-07-22 13:53:21 -05:00
Graham Steffaniak 4d04e02c31 v0.7.16-beta (#991) (#992) 2025-07-21 18:21:31 -05:00
Graham Steffaniak 04d72998b6 Beta/v0.7.15 (#988) 2025-07-20 11:38:03 -05:00
Graham Steffaniak f836a2c4df Beta/v0.7.14 (#964) 2025-07-12 16:57:31 -05:00
cc0212b754 Beta/v0.7.13 (#947)
Co-authored-by: mordillo <121123040+mordilloSan@users.noreply.github.com>
Co-authored-by: Valentyn Lavrinenko <Valentin.Lavrinenko@gmail.com>
2025-07-11 15:34:57 -05:00
Graham Steffaniak f66a4d9313 Beta/v0.7.12 (#912) 2025-07-05 17:50:22 -05:00
Graham Steffaniak 3e59bf90f7 Beta/v0.7.11 (#863) 2025-06-30 13:07:16 -05:00
Graham Steffaniak 812f30cccd Beta/v0.7.10 retagged changes (#833) 2025-06-23 10:20:04 -05:00
Graham Steffaniak 93a2e3e05e Beta/v0.7.10 (#823) 2025-06-21 15:51:00 -05:00
Jakub P.andGraham Steffaniak 1f1d1c5b44 oidc: distinguish between username and preferred_username claims (#789)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2025-06-16 19:37:05 -05:00
Graham Steffaniak c581ea760e updated for main (#800) 2025-06-16 19:35:04 -05:00
Graham Steffaniak e3dc484c28 Update README.md (#788) 2025-06-15 16:27:27 -05:00
Graham Steffaniak 0ada9eeffa Gtsteffaniak patch 1 (#780) 2025-06-14 16:35:26 -05:00
Graham Steffaniak 412c68232c Update README.md (#779) 2025-06-14 16:29:52 -05:00
Graham Steffaniak 5abbe1d64b Beta/v0.7.9 (#778) 2025-06-14 16:04:18 -05:00
Adrian-Ryan AcalaandGraham Steffaniak 80b1259dd1 docs: add comprehensive CONTRIBUTING.md guide (#748)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2025-06-11 17:13:34 -05:00
Graham SteffaniakandGilles 96cf2d6a5e Beta/v0.7.8 (#742)
Co-authored-by: Gilles <gilles@wittezaele.fr>
2025-06-09 20:21:33 -05:00
Graham Steffaniak a69ad4b879 update workflow 2025-06-06 20:06:38 -05:00
Graham SteffaniakandGilles f40209751e Beta/v0.7.7 (#728)
Co-authored-by: Gilles <gilles@wittezaele.fr>
2025-06-06 19:31:23 -05:00
Gilles 9f156fdefb Update fr with no ai translation (#723) 2025-06-06 10:12:54 -05:00
Graham Steffaniak 093acb91b1 updated goreleaser (#716) 2025-06-04 17:23:10 -05:00
Graham Steffaniak 36e826af7f updated releaser 3 (#712) 2025-06-04 16:17:44 -05:00
Graham Steffaniak 02650e62c4 updated (#711) 2025-06-04 16:10:17 -05:00
Graham Steffaniak b1f97d3afa Try all goreleasers (#710) 2025-06-04 15:48:55 -05:00
Graham Steffaniak 8faaeb39db build-all-goreleaser (#709) 2025-06-04 15:19:18 -05:00
Graham Steffaniak 04911ca7b0 Update releaser (#708) 2025-06-04 14:56:15 -05:00
Graham Steffaniak 0a9af58b07 Beta/v0.7.6 (#697) 2025-06-02 09:31:01 -05:00
Graham Steffaniak 40374752a3 Beta/v0.7.5 (#683) 2025-05-27 20:19:29 -05:00
Philippe c667218e30 Update fr.json (#654) 2025-05-18 14:12:26 -05:00
Graham Steffaniak 183d2d89e2 updated with fix for create user (#649) (#651) 2025-05-18 14:11:32 -05:00
Christoph Auer 9c1d1a58cd Corrections to German translation (#644) 2025-05-17 16:37:42 -05:00
Graham Steffaniak c4a5b0dcf9 Update README.md (#646) 2025-05-16 16:44:49 -05:00
Graham SteffaniakandFileRun 7ae5a0fdcb Beta/v0.7.3 release (#640)
Co-authored-by: FileRun <filerun@users.noreply.github.com>
2025-05-15 14:07:38 -05:00
Graham Steffaniak f73ab72d46 Update README.md (#630) 2025-05-11 19:09:02 -05:00
Graham Steffaniak 1ba5d35ba8 revert enforced login method (#612) (#614) 2025-05-09 11:52:04 -05:00
300 changed files with 31759 additions and 9828 deletions

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
backend/**.db
backend/tmp
backend/backend*
backend/vendor/**
frontend/dist/**
frontend/node_modules/**
+100 -66
View File
@@ -3,85 +3,117 @@ 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*.*.*"
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
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
if: github.event.pull_request.head.repo.fork == false
name: Build docker image
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -104,10 +136,12 @@ jobs:
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
+4 -3
View File
@@ -1,7 +1,7 @@
name: regular tests
on:
push:
pull_request:
branches:
- "**"
@@ -22,10 +22,11 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- uses: golangci/golangci-lint-action@v5
- 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:
+1 -1
View File
@@ -55,7 +55,7 @@ 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
platforms: linux/amd64,linux/arm64
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
-125
View File
@@ -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 }}
+73 -4
View File
@@ -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:
@@ -63,7 +64,10 @@ jobs:
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(sub(":v"; ":")) |
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not)
) |
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta",
@@ -91,8 +95,73 @@ 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
) |
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta-slim",
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
]
else . end |
if any(.tags[]; test("stable")) 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 }}
+3
View File
@@ -10,6 +10,7 @@ rice-box.go
/backend/filebrowser.exe
/backend/backend.exe
/backend/tmp
/backend/dist
/frontend/dist
/frontend/pkg
/frontend/test-results
@@ -19,6 +20,7 @@ rice-box.go
/backend/vendor
/backend/*.cov
/backend/test_config.yaml
/backend/generated.yaml
/backend/srv
/backend/http/dist
/backend/http/embed/*
@@ -46,3 +48,4 @@ yarn-error.log*
bin/
build/
*__debug*
backend/custom.css
+457
View File
@@ -2,6 +2,463 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.8.3-beta
**BugFixes**:
- fixed search bar style bug in mobile #1147
## v0.8.2-beta
**New Features**:
- added `source.config.denyByDefault` configuration to enable a deny-by-default access rule. A source enabled with this will deny access unless an "allow" rule was specifically created. (Similar to creating a root-level denyAll rule)
- allow oidc user source access and permission based on username and groups is fulfilled by denyByDefault source with access rules https://github.com/gtsteffaniak/filebrowser/issues/824
- "open parent folder" in context menu and search results https://github.com/gtsteffaniak/filebrowser/issues/1121
- added friendly "share not found" page.
**Notes**:
- 8.0 ffmpeg version bundled with docker
- go 1.25 upgrade with green tea GC enabled
- totp secrets accept non-secure strings, only throwing warning
- adjusted download limit so it also counts viewing text "content" of files (like in editor). You can also "disable file viewing" to stop the editor from showing. lower quality file image previews are not counted as downloads.
- updated invalid share message to be more clear https://github.com/gtsteffaniak/filebrowser/issues/1120
**BugFixes**:
- fixed /public/static routes issue
- shares redirect to login - https://github.com/gtsteffaniak/filebrowser/issues/1109
- some static assets not available to anonymous user - https://github.com/gtsteffaniak/filebrowser/issues/1102
- more safari style issues https://github.com/gtsteffaniak/filebrowser/issues/1110
- fix public share download issues https://github.com/gtsteffaniak/filebrowser/issues/1118 https://github.com/gtsteffaniak/filebrowser/issues/1089
- fixed disable file viewer setting and enforced on backend
## v0.8.1-beta
**New Features**:
- api for generate download link (see swagger) https://github.com/gtsteffaniak/filebrowser/issues/1007
- added `source.config.disabled` option to disable a source without removing it from config file.
- added `source.config.private` option to designate as private -- currently just means no sharing permitted.
- hide share card in share
- download count for a share shows up on share management
**Notes**:
- updated description for indexingIntervalMinutes https://github.com/gtsteffaniak/filebrowser/issues/1067
**BugFixes**:
- fixed styling issues https://github.com/gtsteffaniak/filebrowser/issues/1086 https://github.com/gtsteffaniak/filebrowser/issues/1081 https://github.com/gtsteffaniak/filebrowser/issues/1082 https://github.com/gtsteffaniak/filebrowser/issues/1098
- fix download limit issue https://github.com/gtsteffaniak/filebrowser/issues/1085
- fixed oidc user defaults for new user https://github.com/gtsteffaniak/filebrowser/issues/1071
- shares get updated when files moved in ui https://github.com/gtsteffaniak/filebrowser/issues/760
- click listing behavior doesn't clear (introduced in 0.8.0) https://github.com/gtsteffaniak/filebrowser/issues/1101
- show download count and limit in share list in settings https://github.com/gtsteffaniak/filebrowser/issues/1103
- fix windows alt+arrow movement issue https://github.com/gtsteffaniak/filebrowser/issues/1094
- nav memory issue for filenames with brackets https://github.com/gtsteffaniak/filebrowser/issues/1092
- files with "+"" in name issue https://github.com/gtsteffaniak/filebrowser/issues/1089
- fixed editor bug in share view https://github.com/gtsteffaniak/filebrowser/issues/1084
- other share related issues https://github.com/gtsteffaniak/filebrowser/issues/1087 https://github.com/gtsteffaniak/filebrowser/issues/1064
## v0.8.0-beta
This is a major release, new features and changes could introduce breaking behavior. Here are the known potentially breaking changes:
- all public api and share url's get a `/public` prefix, making it easier to use with a reverse proxy. Any existing share link will still work but get redirected.
- a small change to styling you may need to update your custom styling, for example the id `#input` was renamed `#search-input`
**New Features**:
- New access control system. You can add new allow / deny / denyAll rules for users/groups for specific paths on specific sources.
- groups currently only works with provided oidc groups, but will add a full group management option for manual creation. https://github.com/gtsteffaniak/filebrowser/issues/545
- share view changes -- now aligns with the standard listing view. This means files can be viewed and edited (if permission allows) just like a normal listing.
- many share links customization enhancements
- only share to certain authenticated users https://github.com/gtsteffaniak/filebrowser/issues/656 https://github.com/gtsteffaniak/filebrowser/issues/985
- one-time download links
- customize share theme https://github.com/gtsteffaniak/filebrowser/issues/827 https://github.com/gtsteffaniak/filebrowser/issues/1029
- share link public changes https://github.com/gtsteffaniak/filebrowser/issues/473
- shares can be modified/configured after creation.
- download throttling for shares
**Notes**:
- hover effect on list/compact view https://github.com/gtsteffaniak/filebrowser/issues/1036
**BugFixes**:
- fix new file "true" content issue https://github.com/gtsteffaniak/filebrowser/issues/1048
- editor allows device default popup https://github.com/gtsteffaniak/filebrowser/issues/1049
## v0.7.18-beta
**Notes**:
- desktop context menu "select multiple" enabled as optional user default (#1000)
- onlyoffice readonly document types (".pages", ".numbers", ".key") list (#1018)
- onlyoffice tweaks to make more consistent, added logging (#1015)
**BugFixes**:
- fix lightBackground issue (#1021)
- fix user save issues (#1020, #1027)
- fix image preview cache issue (#989)
- fix file/folder count issue (#989)
- only first file was upload on drag-n-drop (#1024)
## v0.7.17-beta
See an example of custom css styling that uses the reduce-rounded-corners.css by default and allows users to choose other themes. You can add your own themes as well that users can choose from in profile settings:
```
frontend:
styling:
lightBackground: "#f0f0f0" # or names of css colors
darkBackground: "#121212"
customCSS: "custom.css" # custom css file always applies first, then user themes on top of that.
customThemes:
"default": # if "default" is specified as the name, it will be the default option
description: "Reduce rounded corners"
css: "reduce-rounded-corners.css" # path to css file to use
"original":
description: "Original rounded theme"
css: "" # you could default to no styling changes this way.
```
**New Features**:
- more custom styling options (thanks @mordilloSan for #997)
- background colors can be easily set in config
- provided an example `reduce-rounded-corners.css` available by default in docker. (#986, #837)
- added feature to specify multiple css themes that users can choose from in profile settings
- swipe between photos on mobile (#825)
**Notes**:
- changed partition calculations on linux for total disk size (#982)
- upload conflict detection for folders offers "replace all" if the folder already exists in target location.
**BugFixes**:
- TOTP prompt not showing generated code issue https://github.com/gtsteffaniak/filebrowser/issues/996
- select mulitple deselect on mobile (#1002)
- viewing svg images.
## v0.7.16-beta
**Notes**:
- more server logging for uploads when debug logging is enabled
**BugFixes**:
- fix onlyoffice integration viewing bug (#990)
- fix uploading files with exec permissions (#984)
- fix redirect on no source path (#989)
- refresh file info on rename (#989)
- listing refreshes when uploads finish (#989)
- disable edit mode for certain onlyoffice files (#971)
## v0.7.15-beta
**New Features**:
- added userDefault `disableViewingExt`. The new properties apply to all files, not just office.
- code blocks in markdown viewer have line numbers and each line is highlightable
**Notes**:
- replaced `disableOfficePreviewExt` with more generally applicable `disablePreviewExt` to disable preview for any specific file type.
- more tooltip descriptions for settings options
**BugFixes**:
- fix chinese and other language error (#972, #969)
- fix docker dockerfile for `docker run` (#973)
- fix double slash href on single source (#968)
- fix sources named "files" or "share" issue (#949, #574)
- focus input field on popups (#976)
- hopeful fix for size calculation (#982)
- edit button is not working on .md files (#983)
## v0.7.14-beta
**Notes**:
- Updated translations https://github.com/gtsteffaniak/filebrowser/issues/957
- enabled more doc types for onlyoffice https://github.com/gtsteffaniak/filebrowser/discussions/945
**BugFixes**:
- noauth user issue https://github.com/gtsteffaniak/filebrowser/issues/955
- error 403 on source name with special characters https://github.com/gtsteffaniak/filebrowser/issues/952
- delete pictures in previewer issue https://github.com/gtsteffaniak/filebrowser/issues/456
- trailing slash source name issue https://github.com/gtsteffaniak/filebrowser/issues/920
- image lazy loading issue causing all items to get previews at one time, not just whats in view.
## v0.7.13-beta
**New Features**:
- copy and Move files between sources https://github.com/gtsteffaniak/filebrowser/issues/689
- new enhanced upload prompt
- uses chunked uploads https://github.com/gtsteffaniak/filebrowser/issues/770
- all or individual uploads can be paused/resumed
- individual uploads can be retried
- individual file upload progress https://github.com/gtsteffaniak/filebrowser/issues/871
- keeps screen on https://github.com/gtsteffaniak/filebrowser/issues/900
**Notes**:
- lots of UI improvements
- reworked a lot of the frontend path/source logic to be more consistent.
- updated sort behavior to be natural sort https://github.com/gtsteffaniak/filebrowser/issues/551
- optional quick save icon https://github.com/gtsteffaniak/filebrowser/issues/918
- improved language support: zh-tw chinese traditional (tawain)
**BugFixes**:
- more accurate disk used calculation -- accounting for hard links and sparse files. https://github.com/gtsteffaniak/filebrowser/issues/921
- fix api key revoking mechanism
- fixed shift-select https://github.com/gtsteffaniak/filebrowser/issues/929
- video preview images on safari https://github.com/gtsteffaniak/filebrowser/issues/932
- sticky mode isn't sticky https://github.com/gtsteffaniak/filebrowser/issues/916
## v0.7.12-beta
Happy 4th of July!
The most noteworthy change is that no sources will be automatically enabled for any user. In order for a user to use a source, it needs to be added for that user. Or to keep a source available for all users, you can specify `defaultEnabled` in the source config to maintain the same behavior. See the wiki
**New Features**:
- setting added `deleteWithoutConfirming`, useful for quickly deleting files -- does not apply to folders.
- more options for minimal UI https://github.com/gtsteffaniak/filebrowser/issues/745
- dedicated section for sidebar customization in profile settings https://github.com/gtsteffaniak/filebrowser/issues/437
**Notes**:
- Filebrowser no longer requires a default source, users can be created without any sources.
- Disables changing login type fallback behavior https://github.com/gtsteffaniak/filebrowser/issues/620
- Uses calculated index size as "used" and total partition size as "total" https://github.com/gtsteffaniak/filebrowser/issues/875
- Select multiple won't show up in context menu when using a desktop browser (with keyboard), opting for keyboard shortcuts
- Updated translations that were not complete, such as simplified chinese https://github.com/gtsteffaniak/filebrowser/issues/895
- larger min drop target size https://github.com/gtsteffaniak/filebrowser/issues/902
- refresh page after file actions https://github.com/gtsteffaniak/filebrowser/issues/894
- improved user PUT handler for easier user modification via API https://github.com/gtsteffaniak/filebrowser/issues/897
- optional sidebar actions for upload/create https://github.com/gtsteffaniak/filebrowser/issues/885
**BugFixes**:
- fix delete in preview when moving between pictures. https://github.com/gtsteffaniak/filebrowser/issues/456
- getting file info issue when indexing is disabled.
- fixed initial sort order https://github.com/gtsteffaniak/filebrowser/issues/551
- incorrect filename Drag and Drop fixes https://github.com/gtsteffaniak/filebrowser/issues/880
- fix share duration always showing just now https://github.com/gtsteffaniak/filebrowser/issues/896
## v0.7.11-beta
**Breaking Changes**:
- `auth.resetAdminOnStart` has been removed. Instead, if you have `auth.adminPassword` set it will always be reset on startup. If you want to change your default admin password afterwards, make sure to unset `auth.adminPassword` so it doesn't get reset on startup.
- renamed include/exclude rules see [updated example wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration-And-Examples#example-advanced-source-config)!
**New Features**:
- more comprehensive exclude/include rules (see example wiki above).
- include/exclude parts of folder names as well https://github.com/gtsteffaniak/filebrowser/issues/854
- include/exclude file or folder names globally.
- `source.config.neverWatchPaths` is now functional -- a list of paths that get indexed initially, but skips re-indexing. Useful for directories you don't expect to change ever, still show up in search but get don't contribute to indexing time after initial indexing.
**Notes**:
- updated swagger docs https://github.com/gtsteffaniak/filebrowser/issues/849
**BugFixes**:
- fix version update notification for binary https://github.com/gtsteffaniak/filebrowser/issues/836
- ctrl-click cache issue https://github.com/gtsteffaniak/filebrowser/issues/735
- fix admin user reset OIDC user https://github.com/gtsteffaniak/filebrowser/issues/811 https://github.com/gtsteffaniak/filebrowser/issues/851
- fix windows and binary muPdf issue https://github.com/gtsteffaniak/filebrowser/issues/744
- fix logout oidc issue https://github.com/gtsteffaniak/filebrowser/issues/829 https://github.com/gtsteffaniak/filebrowser/issues/662
- file name upload bug https://github.com/gtsteffaniak/filebrowser/issues/662
- could not create share with absolute timestamps enabled https://github.com/gtsteffaniak/filebrowser/issues/764
- context menu off screen issue https://github.com/gtsteffaniak/filebrowser/issues/828
## v0.7.10-beta
**OIDC change**: if you specify `oidc.userIdentifier: "username"`, originally this would map to `preferred_username` but now it maps to `username` explicitly. To maintain the same behavior update your config to `userIdentifier: "preferred_username"`. This was updated to allow for `username` to work as [some might need](https://github.com/gtsteffaniak/filebrowser/pull/789).
**New Features**:
- Added settings option to stop sidebar from automatically hiding on editor and previews. https://github.com/gtsteffaniak/filebrowser/issues/744
- Added more secrets loadable from environment variables. https://github.com/gtsteffaniak/filebrowser/issues/790
- Include/exclude files are checked for existence to assist with configuration, will show as warning if something is configured but doesn't exist.
- Added open in new tab link for preview items to view the raw picture, pdf, etc. Especially helpful for safari viewing PDF documents. https://github.com/gtsteffaniak/filebrowser/issues/734
- Added autoplay media toggle in user profile, to automatically play videos and audio.
**Notes**:
- Allowed to delete default admin user https://github.com/gtsteffaniak/filebrowser/issues/811 https://github.com/gtsteffaniak/filebrowser/issues/762
- Better try/catch error handling for user feedback for shares https://github.com/gtsteffaniak/filebrowser/issues/732
**BugFixes**:
- Fix share scope creation issue https://github.com/gtsteffaniak/filebrowser/issues/809
- Fix oidc token logout issue https://github.com/gtsteffaniak/filebrowser/issues/791
- Non-admin users OTP issue https://github.com/gtsteffaniak/filebrowser/issues/815
- Linewrap issue for a few cases https://github.com/gtsteffaniak/filebrowser/issues/810
- BaseUrl redirect issue with proxies https://github.com/gtsteffaniak/filebrowser/issues/796
- Fix exclude still shows up in ui issue https://github.com/gtsteffaniak/filebrowser/issues/797
- Copy/move functions are async https://github.com/gtsteffaniak/filebrowser/issues/812
- fix subtitle fetch issue https://github.com/gtsteffaniak/filebrowser/issues/766
- fix location memory issue for url encoded file names
## v0.7.9-beta
**New Features**:
- Admin users will get a small notification banner for available update in sidebar with link to new release.
**Notes**:
- docker now defaults to ./data/databse.db as the database path allowing a simplified initial docker-compose.yaml. Existing configurations do not need updating.
- oidc groups header updates admin permission of existing user (either add/remove if role exists)'
- builds amd64 binary with musl for compatibility (glic error) https://github.com/gtsteffaniak/filebrowser/issues/755
- renamed `server.sources.config.disabled` to `server.sources.config.disableIndexing`
- better support for running with disabled index.
- small indexing behavior tweaks.
- markdown viewer hides sidebar https://github.com/gtsteffaniak/filebrowser/issues/744
- quick download only applies to files
**BugFixes**:
- subtitles filename issue https://github.com/gtsteffaniak/filebrowser/issues/678
- search result links not working with custom baseUrl https://github.com/gtsteffaniak/filebrowser/issues/746
- preview error for office native preview https://github.com/gtsteffaniak/filebrowser/issues/744
- more source name safety for special characters.
- shares with special character errors https://github.com/gtsteffaniak/filebrowser/issues/753
- backspace navigates back a page when typing https://github.com/gtsteffaniak/filebrowser/issues/663
- markdown viewer scrolling https://github.com/gtsteffaniak/filebrowser/issues/767
- fix user permissions updated when modifying api key permissions
- fix language change issue https://github.com/gtsteffaniak/filebrowser/issues/768 https://github.com/gtsteffaniak/filebrowser/issues/487
## v0.7.8-beta
Note: if using oidc, please update from 0.7.7 to resolve invalid_grant issue. Also - oidc no longer creates users automatically by default -- must be enabled.
**New Features**:
- More oidc user creation options https://github.com/gtsteffaniak/filebrowser/issues/685
- `auth.methods.oidc.createUser` must be true to automatically create user, defaults to false.
- `auth.methods.oidc.adminGroup` allows using oidc provider group name to enable admin user creation.
**BugFixes**:
- fix save editor info sometimes saves wrong file. https://github.com/gtsteffaniak/filebrowser/issues/701
- make ctrl select work on mac or windows. https://github.com/gtsteffaniak/filebrowser/issues/739
- oidc login failures introduced in 0.7.6 https://github.com/gtsteffaniak/filebrowser/issues/731
- oidc respects non-default baseURL
## v0.7.7-beta
This release cleans up some of the native preview (image preview) feature logic. And adds simple docx and epub viewers as well. Going through all of this, I think I know how I can add full-fledge google doc and microsoft office viewer support (no edit). But, for now "onlyOffice" remains the most comprehensive solution with most compatibility and ability to fully edit. One day, I think I will be able to integrate a minimal license-free server into the docker image. But that's something for another time.
Native preview (image preview) support is also available for linux arm64 and amd64 binaries, and windows exe.
**New Features**:
- since theres a wider kind of document preview types, a new disableOfficePreviewExt option has been added.
- native (and simple) docx and epub viewers.
- Other documents like xlsx get full size image preview when opened and no onlyoffice support.
**Notes**:
- all text mimetype files have preview support.
- high-quality preview image sizes bumped from 512x512 to 640x640 to help make text previews readable.
- no config is allowed and defaults to on source at current directory.
**BugFixes**:
- fix otp clearing on user save https://github.com/gtsteffaniak/filebrowser/issues/699
- admin special characters and general login improvements https://github.com/gtsteffaniak/filebrowser/issues/594
- updated editor caching behavior https://github.com/gtsteffaniak/filebrowser/issues/701
- move/copy file path issue and overwrite https://github.com/gtsteffaniak/filebrowser/issues/687
- fix popup preview loading on safari
- `preview.highQuality` only affects gallery view mode. popop preview is always high quality, and icons are always low quality.
## v0.7.6-beta
**New Features**:
- native document preview generation enabled for certain document types on the regular docker image (no office integration needed)
- supported native document preview types:
- ".pdf", // PDF
- ".xps", // XPS
- ".epub", // EPUB
- ".mobi", // MOBI
- ".fb2", // FB2
- ".cbz", // CBZ
- ".svg", // SVG
- ".txt", // TXT
- ".docx", // DOCX
- ".ppt", // PPT
- ".pptx", // PPTX
- ".xlsx", // exel XLSX
- ".hwp", // HWP
- ".hwp", // HWPX
- proxy logout redirectUrl support via `auth.methods.proxy.logoutRedirectUrl` https://github.com/gtsteffaniak/filebrowser/issues/684
**Notes**:
- image loading placeholders added and remain if image can't be loaded.
- no more arm32 support on main image -- use a `slim` tagged image.
**BugFixes**:
- onlyoffice and other cache issues https://github.com/gtsteffaniak/filebrowser/issues/686
- gallery size indicator centering https://github.com/gtsteffaniak/filebrowser/issues/652
## v0.7.5-beta
**New Features**
- new `./filebrowser.exe setup` command for creating a config.yaml on first run. https://github.com/gtsteffaniak/filebrowser/issues/675
- new 2FA/OTP support for password-based users.
- `auth.password.enforcedOtp` option to enforce 2FA usage for password users.
**Notes**:
- logging uses localtime, optional UTC config added https://github.com/gtsteffaniak/filebrowser/issues/665
- generated config example now includes defaults https://github.com/gtsteffaniak/filebrowser/issues/590
- `server.debugMedia` config option added to help debug ffmpeg issues in the future (don't enable unless debugging an issue)
- more translations additions from english settings https://github.com/gtsteffaniak/filebrowser/issues/653
- visual tweaks https://github.com/gtsteffaniak/filebrowser/issues/652
- enhanced markdown viewer with code view spec
**BugFixes**:
- long video names ffmpeg issue fixed https://github.com/gtsteffaniak/filebrowser/issues/669
- certain files not passing content https://github.com/gtsteffaniak/filebrowser/issues/657
- https://github.com/gtsteffaniak/filebrowser/issues/668
- allow edit markdown files
- rename button doesn't close prompt https://github.com/gtsteffaniak/filebrowser/issues/664
- webm video preview issue https://github.com/gtsteffaniak/filebrowser/issues/673
- fix signup issue https://github.com/gtsteffaniak/filebrowser/issues/648
- fix default source bug
- https://github.com/gtsteffaniak/filebrowser/issues/666
- fix 500 error for subtitle videos https://github.com/gtsteffaniak/filebrowser/issues/678
- spaces and special characters in source name issue https://github.com/gtsteffaniak/filebrowser/issues/679
![image](https://github.com/user-attachments/assets/28e4e67e-31a1-4107-9294-0e715e87b558)
## v0.7.4-beta
**Notes**:
- Updated German translation. https://github.com/gtsteffaniak/filebrowser/pull/644
**BugFixes**:
- windows control click https://github.com/gtsteffaniak/filebrowser/issues/642
- create user issue https://github.com/gtsteffaniak/filebrowser/issues/647
## v0.7.3-beta
Note: OIDC changes require config update.
**New Features**
- Added code highlights to text editor and enabled text editor for all asci files under 25MB
- Motion previews for videos -- cycles screenshots of vidoes. https://github.com/gtsteffaniak/filebrowser/issues/588
- Optionally reset default admin username/password on startup, to guarentee a username/password on startup if needed. Use by setting `auth.resetAdminOnStart` true https://github.com/gtsteffaniak/filebrowser/issues/625
**Notes**:
- Updated translations everywhere. https://github.com/gtsteffaniak/filebrowser/issues/627
- Office viewer is now full-screen with floating close button. https://github.com/gtsteffaniak/filebrowser/issues/542
- OIDC config additions
- `issuerUrl` required now to get relevant oidc configurations.
- `disableVerifyTLS` optionally, disable verifying HTTPS provider endpoints.
- `logoutRedirectUrl` optionally, redirect the user to this URL on logout.
- other URL config parameters are no longer accepted -- replace with issuerUrl.
- Aadmins allowed to change user login methods in user settings when creating or updating users.
- https://github.com/gtsteffaniak/filebrowser/issues/618
- https://github.com/gtsteffaniak/filebrowser/issues/617
- Hide header when showing only office https://github.com/gtsteffaniak/filebrowser/issues/542
**BugFixes**:
- Editor save shows notification
- Preview settings resetting on startup
- Not all languages show correctly https://github.com/gtsteffaniak/filebrowser/issues/623
- scopes sometimes reset on startup https://github.com/gtsteffaniak/filebrowser/issues/636
- Update save password option
- https://github.com/gtsteffaniak/filebrowser/issues/587
- https://github.com/gtsteffaniak/filebrowser/issues/619
- https://github.com/gtsteffaniak/filebrowser/issues/615
## v0.7.2-beta
The `media` tags introduced in 0.7.0 have been removed -- all docker images have media enabled now.
**Notes**:
- Reverts enforced user login methods types -- until suitable methods to alter are available.
- When updating a user, updating scope always sets to the exact scope specified on updated.
- Redirect api messages are INFO instead of WARN
- Settings has close button instead of back https://github.com/gtsteffaniak/filebrowser/issues/583
**Bug Fixes**:
- Hover bug when exact timestamp setting enabled https://github.com/gtsteffaniak/filebrowser/issues/585
## v0.7.1-beta
The `media` tags introduced in 0.7.0 have been removed -- all docker images have media enabled now.
+208
View File
@@ -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 18.0.0+** with npm 7.0.0+ (see `frontend/package.json`)
- **Docker** (optional, for containerized development)
- **Git**
### Required Tools
- **ffmpeg**: Required for video thumbnail generation
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
- **mupdf-tools**: Required 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`
+61 -47
View File
@@ -6,7 +6,7 @@
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/donate/?business=W5XKNXHJM2WPE&no_recurring=0&currency_code=USD)
[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](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">
<h3>FileBrowser Quantum</h3>
@@ -16,13 +16,27 @@
</div>
> [!WARNING]
> There is no stable version -- planned for 2025.
> There is no stable version -- :construction: planned for 2025. ([Read more](https://github.com/gtsteffaniak/filebrowser/discussions/628))
[FYI: how much RAM does it require?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
## About
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has 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 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 Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/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
2. ✅ Login support for OIDC, password + 2FA, and proxy.
3. ✅ Beautiful, Responsive, and Customizable user interface.
4. ✅ Simplified configuration via `config.yaml` config file.
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
- Real-time search results as you type.
@@ -33,29 +47,27 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
- Instantly switches view modes and sort order without reloading data.
- Folder sizes are displayed.
- Navigating remembers the last scroll position.
7. ✅ Developer API support
7. ✅ Highly configurable and customizable sharing options, incuding:
- 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.
8. ✅ 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).
- :construction: jobs are not supported yet.
- ❌ shell commands are completely removed and will not be returned.
## About
> [!WARNING]
> Every file and directory in the source gets indexed (by default). This enables powerful features such as instant search, but large source filesystems can increase your system requirements. [See indexing wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.
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:
@@ -74,7 +86,9 @@ a pop-up menu.
## Install and Configuration
See the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration)
Check out the [Getting Started Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Getting-Started)
For help configuring your filebrowser see [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration-And-Examples)
## Command Line Usage
@@ -84,35 +98,35 @@ See the [CLI Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
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.
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)
## Other Questions
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
## 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
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | :construction: | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
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 |
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 256 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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -120,32 +134,32 @@ Upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
Custom job support | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
Custom job support | :construction: | ✅ | ❌ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
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 | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
activity log | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
trash 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 | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
+16 -30
View File
@@ -1,16 +1,19 @@
FROM golang:1.24-alpine AS base
FROM gtstef/ffmpeg:8.0-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 upx
ENV CGO_ENABLED=1
ENV GOEXPERIMENT=greenteagc
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 .
RUN upx filebrowser
FROM node:slim AS nbuild
FROM node:lts-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
@@ -18,40 +21,23 @@ COPY ./frontend/ ./
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 PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap
RUN apk --no-cache add ca-certificates mailcap tzdata
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", "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/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
# exposing default port for auto discovery.
EXPOSE 80
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps firefox
+11 -1
View File
@@ -1,8 +1,18 @@
FROM gtstef/playwright-base
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*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+9 -1
View File
@@ -1,8 +1,16 @@
FROM gtstef/playwright-base
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/noauth/", "./" ]
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 tests
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+13
View File
@@ -0,0 +1,13 @@
FROM gtstef/playwright-base
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
RUN node mock-oidc-server.js & sleep 2 && cd ../backend/ && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+4 -1
View File
@@ -2,10 +2,13 @@ FROM gtstef/playwright-base
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
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+17
View File
@@ -0,0 +1,17 @@
FROM gtstef/playwright-base
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
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+1
View File
@@ -0,0 +1 @@
FROM gtstef/playwright-base
+37
View File
@@ -0,0 +1,37 @@
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
ENV GOEXPERIMENT=greenteagc
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 .
RUN upx filebrowser
FROM node:lts-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata
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/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
ENTRYPOINT [ "./filebrowser" ]
+2 -1
View File
@@ -6,6 +6,7 @@ services:
- "80:80"
volumes:
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
filebrowser:
hostname: filebrowser
volumes:
@@ -13,4 +14,4 @@ services:
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile
dockerfile: ./_docker/Dockerfile.slim
+5
View File
@@ -5,8 +5,13 @@ server:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
- path: "."
name: "docker"
config:
defaultEnabled: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: 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. */
+6
View File
@@ -5,6 +5,12 @@ server:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
exclude:
folderPaths:
- "excluded"
auth:
methods:
noauth: 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. */
+38
View File
@@ -0,0 +1,38 @@
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"
auth:
methods:
password:
enabled: false
oidc:
enabled: true
createUser: true
clientId: "vyBrzOmcrU3OokiWEZoObdGBscw0eYPNJauBtOJr"
clientSecret: "gIMKbkEcckUhlGrnE2Vxi8eZhEcfxQqfwFgM0ATzk4DciHGrW2YRtQfqcpRTsmzrxEETkKvZ8yeK3sfoXnUV1IQTzqK7wxw3lEMtmeXi5clj0h7nH1rVGFq2Q3HL8zUe"
issuerUrl: "http://localhost:8080"
adminGroup: "Admins"
userDefaults:
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: false
api: false
@@ -0,0 +1,60 @@
import { OAuth2Server } from 'oauth2-mock-server';
// This is a list of pre-defined users. You can add more users here.
// The filebrowser application will use this data when a user logs in.
const users = {
// Default user
johndoe: {
sub: 'johndoe',
preferred_username: 'johndoe',
email: 'johndoe@example.com',
groups: ['Admins', 'Users'],
},
// Another example user
testuser: {
sub: 'testuser',
preferred_username: 'testuser',
email: 'testuser@example.com',
groups: ['Users'],
},
};
const server = new OAuth2Server();
// Generate a new RSA key for the server.
await server.issuer.keys.generate('RS256');
/**
* This hook is called before the ID token is signed.
* We're using it to add claims to the ID token.
* This is the primary way the application will get user information.
*/
server.service.on('beforeIdTokenSigning', (token, req) => {
// By default, the mock server uses 'johndoe' as the subject.
const user = users[token.payload.sub] || users.johndoe;
console.log(`[ID Token] Customizing token for user: ${user.sub}`);
Object.assign(token.payload, user);
});
/**
* This hook is called before the userinfo response is sent.
* We're using it to provide user claims when the application calls the /userinfo endpoint.
*/
server.service.on('beforeUserinfo', (userInfoResponse, req) => {
const user = users.johndoe;
console.log(`[UserInfo] Customizing userinfo for user: ${user.sub}`);
userInfoResponse.body = user;
});
const port = 8080;
const host = '0.0.0.0'; // Bind to 0.0.0.0 to be accessible within Docker
await server.start(port, host);
console.log(`OAuth 2 Mock Server listening on http://${host}:${port}`);
console.log(`Issuer URL: ${server.issuer.url}`);
console.log('---');
console.log('Pre-defined users:');
console.log(JSON.stringify(users, null, 2));
console.log('---');
@@ -0,0 +1,48 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/oidc-setup",
timeout: 5000,
testDir: "./tests/playwright/oidc",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
storageState: "loginAuth.json",
actionTimeout: 5000,
//storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});
+1
View File
@@ -0,0 +1 @@
demo-127.0.0.1:$apr1$j5FRA4Bc$RYdNFuM7YAAo9Qtie8w4b/
+3 -2
View File
@@ -1,6 +1,6 @@
server:
port: 8080
baseURL: "/"
baseURL: "/subpath"
logging:
- levels: "info|error|debug"
sources:
@@ -23,6 +23,7 @@ auth:
enabled: true
header: "X-Username"
createUser: true
logoutRedirectUrl: "https://localhost/api/auth/logout2"
userDefaults:
darkMode: true
@@ -31,5 +32,5 @@ userDefaults:
permissions:
admin: false
modify: true
share: false
share: true
api: false
+14 -2
View File
@@ -7,13 +7,25 @@ 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;
}
# 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,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
//globalSetup: "./global-setup",
globalSetup: "./tests/playwright/proxy-setup.ts",
timeout: 3000,
testDir: "./tests-playwright/proxy",
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. */
@@ -25,8 +25,8 @@ export default defineConfig({
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",
+22
View File
@@ -0,0 +1,22 @@
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"
@@ -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"] },
},
],
});
+17
View File
@@ -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/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=testing'\" ."
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"]
log = "air.log"
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = true
+5
View File
@@ -0,0 +1,5 @@
tmp/
air.log
filebrowser
generated.yaml
test_config.yaml
+145 -231
View File
@@ -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$
+55 -15
View File
@@ -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:
@@ -67,4 +108,3 @@ archives:
checksum:
disable: true
@@ -31,10 +31,6 @@ 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)
}
return &FileCache{dir: dir}, nil
}
+223 -110
View File
@@ -9,79 +9,72 @@ import (
"fmt"
"hash"
"io"
"unicode"
"unicode/utf8"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"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/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error) {
response := iteminfo.ExtendedFileInfo{}
if opts.Source == "" {
opts.Source = settings.Config.Server.DefaultSource.Name
}
index := indexing.GetIndex(opts.Source)
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
if opts.Access != nil && !opts.Access.Permitted(index.Path, opts.Path, opts.Username) {
return response, errors.ErrPermissionDenied
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
return response, err
}
opts.IsDir = isDir
// TODO: whats the best way to save trips to disk here?
// disabled using cache because its not clear if this is helping or hurting
// check if the file exists in the index
//info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
//if exists {
// err := RefreshFileInfo(opts)
// if err != nil {
// return info, err
// }
// if opts.Content {
// content := ""
// content, err = getContent(opts.Path)
// if err != nil {
// return info, err
// }
// info.Content = content
// }
// return info, nil
//}
var info *iteminfo.FileInfo
var exists bool
err = index.RefreshFileInfo(opts)
if err != nil {
return response, err
}
info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
if opts.Content && strings.HasPrefix(info.Type, "text") {
// Check file size
if info.Size > 50*1024*1024 { // 50 megabytes in bytes
logger.Debug(fmt.Sprintf("Reading large text file contents: "+info.Path, info.Name))
if err == errors.ErrNotIndexed && index.Config.DisableIndexing {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
} else if err == errors.ErrNotIndexed {
return response, fmt.Errorf("could not refresh file info: %v", err)
}
content, err := getContent(realPath)
if err != nil {
return response, err
} else {
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
}
if opts.Content {
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(realPath)
if err != nil {
logger.Debugf("could not get content for file: "+info.Path, info.Name, err)
return response, err
}
response.Content = content
} else {
logger.Debug("skipping large text file contents (20MB limit): "+info.Path, info.Name)
}
response.Content = content
}
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
response.Source = index.Name
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
@@ -95,11 +88,11 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
}
func generateOfficeId(realPath string) string {
key, ok := cache.OnlyOffice.Get(realPath).(string)
key, ok := utils.OnlyOfficeCache.Get(realPath).(string)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
cache.OnlyOffice.Set(realPath, documentKey)
utils.OnlyOfficeCache.Set(realPath, documentKey)
return documentKey
}
return key
@@ -144,6 +137,9 @@ func DeleteFiles(source, absPath string, absDirPath string) error {
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
if index.Config.DisableIndexing {
return nil
}
refreshConfig := iteminfo.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
@@ -152,71 +148,88 @@ func DeleteFiles(source, absPath string, absDirPath string) error {
return nil
}
func MoveResource(sourceIndex, destIndex, realsrc, realdst string) error {
func RefreshIndex(source string, path string, isDir bool) error {
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", source)
}
if idx.Config.DisableIndexing {
return nil
}
path = idx.MakeIndexPath(path)
return idx.RefreshFileInfo(iteminfo.FileOptions{Path: path, IsDir: isDir})
}
// validateMoveDestination checks if a move/rename operation is valid
// It prevents moving a directory into itself or its subdirectories
func validateMoveDestination(src, dst string, isSrcDir bool) error {
// Clean and normalize paths
src = filepath.Clean(src)
dst = filepath.Clean(dst)
// If source is a directory, check if destination is within source
if isSrcDir {
// Get the parent directory of the destination
dstParent := filepath.Dir(dst)
// Check if destination parent is the source directory or a subdirectory of it
if strings.HasPrefix(dstParent+string(filepath.Separator), src+string(filepath.Separator)) || dstParent == src {
return fmt.Errorf("cannot move directory '%s' to a location within itself: '%s'", src, dst)
}
}
// Check if destination parent directory exists
dstParent := filepath.Dir(dst)
if dstParent != "." && dstParent != "/" {
if _, err := os.Stat(dstParent); os.IsNotExist(err) {
return fmt.Errorf("destination directory does not exist: '%s'", dstParent)
}
}
return nil
}
func MoveResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage) error {
// Validate the move operation before executing
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
return err
}
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := indexing.GetIndex(sourceIndex)
if idxSrc == nil {
go RefreshIndex(sourceIndex, realsrc, isSrcDir) //nolint:errcheck
go RefreshIndex(destIndex, realdst, isDestDir) //nolint:errcheck
// update shares
idx := indexing.GetIndex(sourceIndex)
if idx == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := indexing.GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
// refresh info for source and dest
err = idxSrc.RefreshFileInfo(iteminfo.FileOptions{
Path: refreshSourceDir,
IsDir: true,
})
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
if refreshSourceDir == refreshDestDir {
return nil
}
refreshConfig := iteminfo.FileOptions{Path: refreshDestDir, IsDir: true}
err = idxDst.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for dest: %v", err)
idx2 := indexing.GetIndex(destIndex)
if idx2 == nil {
return fmt.Errorf("could not get index: %v ", destIndex)
}
// Use backend source paths to match how shares are stored
go s.UpdateShares(idx.Path, idx.MakeIndexPath(realsrc), idx2.Path, idx2.MakeIndexPath(realdst)) //nolint:errcheck
return nil
}
func CopyResource(sourceIndex, destIndex, realsrc, realdst string) error {
func CopyResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, realdst string) error {
// Validate the copy operation before executing
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
return err
}
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := indexing.GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := indexing.GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
index := indexing.GetIndex(sourceIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshConfig := iteminfo.FileOptions{Path: refreshSourceDir, IsDir: true}
// refresh info for source and dest
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
refreshConfig.Path = refreshDestDir
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
}
go RefreshIndex(sourceIndex, realsrc, isSrcDir) //nolint:errcheck
go RefreshIndex(destIndex, realdst, isDestDir) //nolint:errcheck
// update shares
return nil
}
@@ -227,15 +240,11 @@ func WriteDirectory(opts iteminfo.FileOptions) error {
}
realPath, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(realPath, 0775)
err := os.MkdirAll(realPath, fileutils.PermDir)
if err != nil {
return err
}
err = idx.RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
return nil
return RefreshIndex(idx.Name, opts.Path, true)
}
func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
@@ -244,15 +253,13 @@ func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
dst, _, _ := idx.GetRealPath(opts.Path)
parentDir := filepath.Dir(dst)
// Create the directory and all necessary parents
err := os.MkdirAll(parentDir, 0775)
// Ensure the parent directories exist
err := os.MkdirAll(filepath.Dir(dst), fileutils.PermDir)
if err != nil {
return err
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
file, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
file, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
@@ -263,24 +270,130 @@ func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
if err != nil {
return err
}
opts.Path = idx.MakeIndexPath(parentDir)
opts.IsDir = true
return idx.RefreshFileInfo(opts)
return RefreshIndex(opts.Source, opts.Path, false)
}
// addContent reads and sets content based on the file type.
// getContent reads and returns the file content if it's considered an editable text file.
func getContent(realPath string) (string, error) {
const headerSize = 4096
// Thresholds for detecting binary-like content (these can be tuned)
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
// Open file
f, err := os.Open(realPath)
if err != nil {
return "", err
}
defer f.Close()
// Read header
headerBytes := make([]byte, headerSize)
n, err := f.Read(headerBytes)
if err != nil && err != io.EOF {
return "", err
}
actualHeader := headerBytes[:n]
// --- Start of new heuristic checks ---
if n > 0 {
// 1. Basic Check: Is the header valid UTF-8?
// If not, it's unlikely an editable UTF-8 text file.
if !utf8.Valid(actualHeader) {
return "", nil // Not an error, just not the text file we want
}
// 2. Check for excessive null bytes in the header
nullCountInHeader := 0
for _, b := range actualHeader {
if b == 0x00 {
nullCountInHeader++
}
}
// Reject if too many nulls absolutely or relatively in the header
if nullCountInHeader > 0 { // Only perform check if there are any nulls
if nullCountInHeader > maxNullBytesInHeaderAbs ||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
return "", nil // Too many nulls in header
}
}
// 3. Check for other non-text ASCII control characters in the header
// (C0 controls excluding \t, \n, \r)
for _, b := range actualHeader {
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
return "", nil // Found problematic control character
}
// C1 control characters (0x80-0x9F) would be caught by utf8.Valid if part of invalid sequences,
// or by the non-printable rune check later if they form valid (but undesirable) codepoints.
}
// Optional: Use http.DetectContentType for an additional check on the header
// contentType := http.DetectContentType(actualHeader)
// if !strings.HasPrefix(contentType, "text/") && contentType != "application/octet-stream" {
// // If it's clearly a non-text MIME type (e.g., "image/jpeg"), reject it.
// // "application/octet-stream" is ambiguous, so we rely on other heuristics.
// return "", nil
// }
}
// --- End of new heuristic checks for header ---
// Now read the full file (original logic)
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
// Handle empty file (original logic - returns specific string)
if len(content) == 0 {
return "empty-file-x6OlSil", nil
}
stringContent := string(content)
// 4. Final UTF-8 validation for the entire file
// (This is crucial as the header might be fine, but the rest of the file isn't)
if !utf8.ValidString(stringContent) {
return "", nil
}
if stringContent == "" {
return "empty-file-x6OlSil", nil
// 5. Check for excessive null bytes in the entire file content
if len(content) > 0 { // Check only for non-empty files
totalNullCount := 0
for _, b := range content {
if b == 0x00 {
totalNullCount++
}
}
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
return "", nil // Too many nulls in the entire file
}
}
// 6. Check for excessive non-printable runes in the entire file content
// (Excluding tab, newline, carriage return, which are common in text files)
if len(stringContent) > 0 { // Check only for non-empty strings
nonPrintableRuneCount := 0
totalRuneCount := 0
for _, r := range stringContent {
totalRuneCount++
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
// It excludes control characters. We explicitly allow \t, \n, \r.
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
nonPrintableRuneCount++
}
}
if totalRuneCount > 0 { // Avoid division by zero
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
return "", nil // Too many non-printable runes
}
}
}
// The file has passed all checks and is considered editable text.
return stringContent, nil
}
+10 -10
View File
@@ -17,35 +17,35 @@ func MakeUserDir(fullPath string) error {
return nil
}
func MakeUserDirs(u *users.User) error {
func MakeUserDirs(u *users.User, disableScopeChange 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 {
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)
return fmt.Errorf("MakeUserDirs: scope path does not exist: %s", scope.Scope)
}
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)
return fmt.Errorf("MakeUserDirs: 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)
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
}
err := MakeUserDir(fullPath)
if err != nil {
@@ -53,9 +53,9 @@ func MakeUserDirs(u *users.User) error {
}
} else {
// just assigning scope to path provided, so just check that it exists
parentDir := filepath.Join(source.Path, scope.Scope)
if !Exists(parentDir) {
return fmt.Errorf("create user: parent folder does not exist: %s", parentDir)
path := filepath.Join(source.Path, scope.Scope)
if !Exists(path) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
}
}
u.Scopes[i] = scope
+11 -9
View File
@@ -1,16 +1,18 @@
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"
)
const PermFile = 0644
const PermDir = 0755
// 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 +25,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 %v %v ", src, dst, 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 %v ", src, err)
}
}()
@@ -64,13 +66,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
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, PermFile)
if err != nil {
return err
}
@@ -98,7 +100,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
}
@@ -181,14 +183,14 @@ func ClearCacheDir() {
cacheDir := settings.Config.Server.CacheDir
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)
}
}
+7 -3
View File
@@ -8,10 +8,14 @@ import (
)
var (
revokedApiKeyList map[string]bool
revokedApiKeyList map[string]struct{}
revokeMu sync.Mutex
)
func init() {
revokedApiKeyList = make(map[string]struct{})
}
// Auther is the authentication interface.
type Auther interface {
// Auth is called to authenticate a request.
@@ -27,6 +31,6 @@ func IsRevokedApiKey(key string) bool {
func RevokeAPIKey(key string) {
revokeMu.Lock()
delete(revokedApiKeyList, key)
revokeMu.Unlock()
defer revokeMu.Unlock()
revokedApiKeyList[key] = struct{}{}
}
-277
View File
@@ -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
}
+28 -24
View File
@@ -8,38 +8,25 @@ import (
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"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)
if err != nil {
logger.Error("decode body error")
return nil, os.ErrPermission
}
func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
password := r.Header.Get("X-Password")
totpCode := r.Header.Get("X-Secret")
// 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,16 +36,33 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
return nil, os.ErrPermission
}
}
u, err := userStore.Get(cred.Username)
user, err := userStore.Get(username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
err = users.CheckPwd(cred.Password, u.Password)
err = users.CheckPwd(password, user.Password)
if err != nil {
return nil, err
}
return u, nil
// 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
}
}
if user.LoginMethod != users.LoginMethodPassword {
return nil, errors.ErrWrongLoginMethod
}
return user, nil
}
// LoginPage tells that json auth doesn't require a login page.
+21 -4
View File
@@ -1,7 +1,14 @@
package auth
import (
"encoding/base64"
"log"
"crypto/sha256"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"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 == 25) {
// Use the user-provided key if it's valid
encryptionKey = key
} else {
// Otherwise, derive the key from the provided secret string
log.Println("[WARN] 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
}
+203
View File
@@ -0,0 +1,203 @@
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(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 := ""
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is provided, encrypt the secret.
secretText, 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, secretText+"||"+nonce)
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).(string)
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: %v", err)
}
if !valid {
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" {
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"); 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
}
+70 -53
View File
@@ -2,65 +2,82 @@
== Running benchmark ==
/usr/local/go/bin/go
? github.com/gtsteffaniak/filebrowser/backend [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.004s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 0.006s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.003s
? 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/go-cache/cache [no test files]
? 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
? github.com/gtsteffaniak/filebrowser/backend/common/logger [no test files]
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 5.094s
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.006s
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
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.003s
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
? 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]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.002s
? github.com/gtsteffaniak/filebrowser/backend/events [no test files]
2025/05/11 20:42:56 [DEBUG] Saving user [admin] changepass: true
2025/05/11 20:42:56 [DEBUG] Saving user [non-admin] changepass: true
PASS
ok github.com/gtsteffaniak/filebrowser/backend/http 0.197s
2025/05/11 20:42:57 initializing index: [test]
2025/05/11 20:42:57 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:57 [DEBUG] Starting full scan for [test]
2025/05/11 20:42:57 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:42:57 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:42:57 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:57 [DEBUG] Next scan in 1m0s
2025/05/11 20:42:58 initializing index: [test]
2025/05/11 20:42:58 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:58 [DEBUG] Starting full scan for [test]
2025/05/11 20:42:58 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:42:58 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:42:58 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:58 [DEBUG] Next scan in 1m0s
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/indexing
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 2025/05/11 20:42:59 initializing index: [test]
2025/05/11 20:42:59 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:59 [DEBUG] Starting full scan for [test]
2025/05/11 20:42:59 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:42:59 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:42:59 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:59 [DEBUG] Next scan in 1m0s
10 3548759 ns/op 38032 B/op 452 allocs/op
BenchmarkCheckIndexExclude-8 10 172.6 ns/op 0 B/op 0 allocs/op
BenchmarkCheckIndexConditionsInclude-8 10 118.2 ns/op 0 B/op 0 allocs/op
2025/05/11 20:43:01 initializing index: [test]
2025/05/11 20:43:01 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:43:01 [DEBUG] Starting full scan for [test]
2025/05/11 20:43:01 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:43:01 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:43:01 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:43:01 [DEBUG] Next scan in 1m0s
BenchmarkSearchAllIndexes-8 2025/05/11 20:43:02 initializing index: [test]
2025/05/11 20:43:02 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:43:02 [DEBUG] Starting full scan for [test]
2025/05/11 20:43:02 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:43:02 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:43:02 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:43:02 [DEBUG] Next scan in 1m0s
10 781119 ns/op 38515 B/op 900 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/indexing 5.097s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.005s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.128s
? 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]
? github.com/gtsteffaniak/filebrowser/backend/tools [no test files]
+264 -22
View File
@@ -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,17 +58,19 @@ 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
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")
// 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 {
@@ -70,14 +85,12 @@ func runCLI() bool {
}
username := userInfo[0]
password := userInfo[1]
ok := getStore(dbConfig)
if !ok {
logger.Fatal("could not load db info")
}
_ = getStore(dbConfig) // ignore bool check
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
Username: username,
Username: username,
LoginMethod: users.LoginMethodPassword,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
@@ -93,35 +106,264 @@ func runCLI() bool {
// Create the user logic
if asAdmin {
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
logger.Infof("Creating user as admin: %s\n", username)
} else {
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
logger.Infof("Creating non-admin user: %s\n", username)
}
err = storage.CreateUser(newUser, asAdmin)
newUser.Permissions = settings.Config.UserDefaults.Permissions
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser)
if err != nil {
logger.Error(fmt.Sprintf("could not create user: %v", err))
logger.Errorf("could not create user: %v", err)
}
return false
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Fatalf("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
}
err = store.Users.Save(user, true)
err = store.Users.Save(user, true, false)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
logger.Errorf("could not update user: %v", err)
}
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
}
// 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 databse 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() {
if os.Getenv("FILEBROWSER_GENERATE_CONFIG") != "" {
logger.Info("Generating config.yaml")
settings.GenerateYaml()
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
})
}
+1 -1
View File
@@ -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
+24 -13
View File
@@ -7,27 +7,30 @@ import (
"os/signal"
"syscall"
_ "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"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"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 +54,13 @@ func StartFilebrowser() {
if !keepGoing {
return
}
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()
@@ -69,11 +79,12 @@ func StartFilebrowser() {
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)
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL
@@ -90,7 +101,7 @@ func StartFilebrowser() {
// 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
}()
@@ -109,7 +120,7 @@ func StartFilebrowser() {
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")
}
@@ -117,9 +128,9 @@ func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings
numWorkers := settings.Config.Server.NumImageProcessors
ffpmpegPath := settings.Config.Integrations.Media.FfmpegPath
// setup disk cache
err := preview.Start(numWorkers, ffpmpegPath, cacheDir)
err := preview.StartPreviewGenerator(numWorkers, ffpmpegPath, cacheDir)
if err != nil {
logger.Fatal(fmt.Sprintf("Error starting preview service: %v", err))
logger.Fatalf("Error starting preview service: %v", err)
}
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
+47 -80
View File
@@ -2,12 +2,12 @@ package cmd
import (
"fmt"
"strings"
"reflect"
"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{}
@@ -16,14 +16,11 @@ func validateUserInfo() {
// 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,106 +33,76 @@ func validateUserInfo() {
if updateLoginType(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 {
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 {
realsource, ok := settings.Config.Server.NameToSource[src.Name]
if !ok {
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, "/")
existingScope, ok := existing[realsource.Path]
if ok {
// If scope is empty and there's a default, apply default
if existingScope.Scope == "" {
existingScope.Scope = src.Config.DefaultUserScope
}
} else if realsource.Config.DefaultEnabled {
existingScope.Scope = realsource.Config.DefaultUserScope
} else {
continue
}
// Add new scope
finalScopes = append(finalScopes, users.SourceScope{
Scope: scopePath,
Name: source.Path,
newScopes = append(newScopes, users.SourceScope{
Name: realsource.Path,
Scope: existingScope.Scope,
})
seenNames[source.Path] = true
updateUser = true
seen[realsource.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,
}
finalScopes = append(finalScopes, defaultScope)
updateUser = true
}
// Step 4: Assign only if different
if !scopesEqual(user.Scopes, finalScopes) {
user.Scopes = finalScopes
updateUser = true
}
return updateUser
}
// scopesEqual does a deep comparison to avoid unnecessary writes
func scopesEqual(a, b []users.SourceScope) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Scope != b[i].Scope || a[i].Name != b[i].Name {
return false
// 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)
}
}
return true
changed := !reflect.DeepEqual(user.Scopes, newScopes)
user.Scopes = newScopes
return changed
}
// func to convert legacy user with perm key to permissions
+182
View File
@@ -0,0 +1,182 @@
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",
CreateUserDir: true,
}}
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)
})
}
}
-88
View File
@@ -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()
}
}
+5 -1
View File
@@ -17,5 +17,9 @@ var (
ErrPermissionDenied = errors.New("permission 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")
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
)
-141
View File
@@ -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
})
}
-146
View File
@@ -1,146 +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) {
if statusCode >= 300 && 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)
}
+78 -16
View File
@@ -1,11 +1,23 @@
package settings
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gtsteffaniak/go-logger/logger"
)
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"` // the number of hours until the token expires. 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".
TotpSecret string `json:"totpSecret"` // secret used to encrypt TOTP secrets
AuthMethods []string `json:"-"`
}
@@ -17,16 +29,18 @@ type LoginMethods struct {
}
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.
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.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
}
type Recaptcha struct {
@@ -37,13 +51,61 @@ 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
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
ClientID string `json:"clientId"` // client id of the OIDC application
ClientSecret string `json:"clientSecret"` // 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
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
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"` // create user if not exists
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges.
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
Provider *oidc.Provider `json:"-"` // OIDC provider
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
}
// 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()
// If disableVerifyTLS is true, create a custom HTTP client
// and set it in the context for the OIDC provider.
if oidcCfg.DisableVerifyTLS {
// Create a custom transport with InsecureSkipVerify set to true
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
customClient := &http.Client{
Transport: transport,
}
// Use oidc.ClientContext to pass the custom client to the OIDC library
ctx = oidc.ClientContext(ctx, customClient)
// Log that TLS verification is disabled (important for security awareness)
logger.Warning("OIDC TLS verification is disabled.")
}
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
}
+202 -65
View File
@@ -5,41 +5,49 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
var Config Settings
const (
generatorPath = "/relative/or/absolute/path"
)
func Initialize(configFile string) {
err := loadConfigWithDefaults(configFile)
err := loadConfigWithDefaults(configFile, false)
if err != nil {
logger.Error("unable to load config, waiting 5 seconds before exiting...")
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
logger.Fatal(err.Error())
}
err = ValidateConfig(Config)
if err != nil {
errmsg := "the provided config file failed validation. "
errmsg += "If you are seeing this on a config that worked previeously, "
errmsg += "this is because v0.6.8 requires a fully validated config to run. "
errmsg += "Please review your config for typos and invalid keys which are no longer supported. "
errmsg := "The provided config file failed validation. "
errmsg += "If you are seeing this on a config that worked before, "
errmsg += "then check the latest releases for breaking changes. "
errmsg += "visit https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example for more information."
logger.Error(errmsg)
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
logger.Fatal(err.Error())
}
setupLogging()
setupAuth()
setupSources()
setupAuth(false)
setupSources(false)
setupUrls()
setupFrontend()
setupFrontend(false)
}
func setupFrontend() {
func setupFrontend(generate bool) {
if !Config.Frontend.DisableDefaultLinks {
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: fmt.Sprintf("(%v)", version.Version),
@@ -51,31 +59,64 @@ func setupFrontend() {
Url: "https://github.com/gtsteffaniak/filebrowser/wiki",
})
}
Config.Frontend.Styling.LightBackground = FallbackColor(Config.Frontend.Styling.LightBackground, "#f5f5f5")
Config.Frontend.Styling.DarkBackground = FallbackColor(Config.Frontend.Styling.DarkBackground, "#141D24")
Config.Frontend.Styling.CustomCSS = readCustomCSS(Config.Frontend.Styling.CustomCSS)
Config.Frontend.Styling.CustomThemeOptions = map[string]CustomTheme{}
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
for name, theme := range Config.Frontend.Styling.CustomThemes {
addCustomTheme(name, theme.Description, theme.CSS)
}
noThemes := len(Config.Frontend.Styling.CustomThemes) == 0
if noThemes {
addCustomTheme("default", "The default theme", "")
// check if file exists
if _, err := os.Stat("reduce-rounded-corners.css"); err == nil {
addCustomTheme("alternative", "Reduce rounded corners", "reduce-rounded-corners.css")
if generate {
Config.Frontend.Styling.CustomThemes["alternative"] = CustomTheme{
Description: "Reduce rounded corners",
CSS: "reduce-rounded-corners.css",
}
}
}
}
_, ok := Config.Frontend.Styling.CustomThemes["default"]
if !ok {
addCustomTheme("default", "The default theme", "")
}
}
func getRealPath(path string) string {
realPath, err := filepath.Abs(path)
if err != nil {
logger.Fatal(fmt.Sprintf("could not find configured source path: %v", err))
logger.Fatalf("could not find configured source path: %v", err)
}
// check path exists
if _, err = os.Stat(realPath); os.IsNotExist(err) {
logger.Fatal(fmt.Sprintf("configured source path does not exist: %v", realPath))
logger.Fatalf("configured source path does not exist: %v", realPath)
}
return realPath
}
func setupSources() {
func setupSources(generate bool) {
if len(Config.Server.Sources) == 0 {
logger.Fatal("There are no `server.sources` configured. If you have `server.root` configured, please update the config and add at least one `server.sources` with a `path` configured.")
} else {
for k, source := range Config.Server.Sources {
if source.Config.Disabled {
continue
}
realPath := getRealPath(source.Path)
name := filepath.Base(realPath)
if name == "\\" {
name = strings.Split(realPath, ":")[0]
}
source.Path = realPath // use absolute path
if generate {
source.Path = generatorPath // use placeholder path
} else {
source.Path = realPath // use absolute path
}
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
if ok {
@@ -83,10 +124,9 @@ func setupSources() {
} else {
source.Name = name
}
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = source
}
}
modifyExcludeInclude(&source)
if source.Config.DefaultUserScope == "" {
source.Config.DefaultUserScope = "/"
}
@@ -98,21 +138,15 @@ func setupSources() {
sourceList := []Source{}
defaultScopes := []users.SourceScope{}
allSourceNames := []string{}
first := true
potentialDefaultSource := Config.Server.DefaultSource
for _, sourcePathOnly := range Config.Server.Sources {
realPath := getRealPath(sourcePathOnly.Path)
if generate {
realPath = generatorPath // use placeholder path
}
source, ok := Config.Server.SourceMap[realPath]
if ok && !slices.Contains(allSourceNames, source.Name) {
if first {
potentialDefaultSource = source
}
first = false
sourceList = append(sourceList, source)
if source.Config.DefaultEnabled {
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = source
}
defaultScopes = append(defaultScopes, users.SourceScope{
Name: source.Path,
Scope: source.Config.DefaultUserScope,
@@ -120,12 +154,9 @@ func setupSources() {
}
allSourceNames = append(allSourceNames, source.Name)
} else {
logger.Warning(fmt.Sprintf("source %v is not configured correctly, skipping", sourcePathOnly.Path))
logger.Warningf("source %v is not configured correctly, skipping", sourcePathOnly.Path)
}
}
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = potentialDefaultSource
}
Config.UserDefaults.DefaultScopes = defaultScopes
Config.Server.Sources = sourceList
}
@@ -137,31 +168,52 @@ func setupUrls() {
} else {
Config.Server.BaseURL = "/" + baseurl + "/"
}
Config.Server.InternalUrl = strings.Trim(Config.Server.InternalUrl, "/")
Config.Server.ExternalUrl = strings.Trim(Config.Server.ExternalUrl, "/")
if Config.Server.BaseURL != "/" {
if Config.Server.ExternalUrl != "" {
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, "/") + "/"
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, Config.Server.BaseURL)
}
if Config.Server.InternalUrl != "" {
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, "/") + "/"
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, Config.Server.BaseURL)
}
}
Config.Integrations.OnlyOffice.Url = strings.Trim(Config.Integrations.OnlyOffice.Url, "/")
Config.Integrations.OnlyOffice.InternalUrl = strings.Trim(Config.Integrations.OnlyOffice.InternalUrl, "/")
}
func setupAuth() {
func setupAuth(generate bool) {
if generate {
Config.Auth.AdminPassword = "admin"
}
if Config.Auth.Methods.PasswordAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "Password")
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
if Config.Auth.Methods.ProxyAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "Proxy")
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
}
if Config.Auth.Methods.OidcAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "OIDC")
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
}
if Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
Config.Auth.AuthMethods = []string{"Disabled"}
Config.Auth.AuthMethods = []string{"disabled"}
}
if Config.Auth.Methods.OidcAuth.Enabled || generate {
err := validateOidcAuth()
if err != nil && !generate {
logger.Fatalf("Error validating OIDC auth: %v", err)
}
logger.Info("OIDC Auth configured successfully")
}
// use password auth as default if no auth methods are set
if len(Config.Auth.AuthMethods) == 0 {
Config.Auth.Methods.PasswordAuth.Enabled = true
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "Password")
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
Config.UserDefaults.LoginMethod = Config.Auth.AuthMethods[0]
}
func setupLogging() {
@@ -173,24 +225,42 @@ func setupLogging() {
}
}
for _, logConfig := range Config.Server.Logging {
err := logger.SetupLogger(
logConfig.Output,
logConfig.Levels,
logConfig.ApiLevels,
logConfig.NoColors,
)
// Enable debug logging automatically in dev mode
levels := logConfig.Levels
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
levels = "info|warning|error|debug"
}
logConfig := logger.JsonConfig{
Levels: levels,
ApiLevels: logConfig.ApiLevels,
Output: logConfig.Output,
Utc: logConfig.Utc,
NoColors: logConfig.NoColors,
Json: logConfig.Json,
}
err := logger.SetupLogger(logConfig)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
}
}
func loadConfigWithDefaults(configFile string) error {
Config = setDefaults()
func loadConfigWithDefaults(configFile string, generate bool) error {
Config = setDefaults(generate)
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
return err
if configFile != "" {
logger.Errorf("could not open config file '%v', using default settings.", configFile)
}
Config.Server.Sources = []Source{
{
Path: ".",
},
}
loadEnvConfig()
return nil
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
@@ -203,7 +273,7 @@ func loadConfigWithDefaults(configFile string) error {
return fmt.Errorf("could not load specified config file: %v", err.Error())
}
if err != nil {
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
logger.Warningf("Could not load config file '%v', using default settings: %v", configFile, err)
}
err = yaml.NewDecoder(strings.NewReader(string(yamlData)), yaml.DisallowUnknownField()).Decode(&Config)
if err != nil {
@@ -224,7 +294,6 @@ func ValidateConfig(config Settings) error {
}
func loadEnvConfig() {
adminPassword, ok := os.LookupEnv("FILEBROWSER_ADMIN_PASSWORD")
if ok {
logger.Info("Using admin password from FILEBROWSER_ADMIN_PASSWORD environment variable")
@@ -241,15 +310,55 @@ func loadEnvConfig() {
Config.Integrations.Media.FfmpegPath = ffmpegPath
}
oidcClientId := os.Getenv("FILEBROWSER_OIDC_CLIENT_ID")
if oidcClientId != "" {
Config.Auth.Methods.OidcAuth.ClientID = oidcClientId
logger.Info("Using OIDC Client ID from FILEBROWSER_OIDC_CLIENT_ID environment variable")
}
oidcClientSecret := os.Getenv("FILEBROWSER_OIDC_CLIENT_SECRET")
if oidcClientSecret != "" {
Config.Auth.Methods.OidcAuth.ClientSecret = oidcClientSecret
logger.Info("Using OIDC Client Secret from FILEBROWSER_OIDC_CLIENT_SECRET environment variable")
}
jwtTokenSecret := os.Getenv("FILEBROWSER_JWT_TOKEN_SECRET")
if jwtTokenSecret != "" {
Config.Auth.Key = jwtTokenSecret
logger.Info("Using JWT Token Secret from FILEBROWSER_JWT_TOKEN_SECRET environment variable")
}
totpSecret := os.Getenv("FILEBROWSER_TOTP_SECRET")
if totpSecret != "" {
Config.Auth.TotpSecret = totpSecret
logger.Info("Using TOTP Secret from FILEBROWSER_TOTP_SECRET environment variable")
}
recaptchaSecret := os.Getenv("FILEBROWSER_RECAPTCHA_SECRET")
if recaptchaSecret != "" {
Config.Auth.Methods.PasswordAuth.Recaptcha.Secret = recaptchaSecret
logger.Info("Using ReCaptcha Secret from FILEBROWSER_RECAPTCHA_SECRET environment variable")
}
}
func setDefaults() Settings {
func setDefaults(generate bool) Settings {
// get number of CPUs available
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
cpus := runtime.NumCPU()
if cpus > 0 && !generate {
numCpus = cpus
}
database := os.Getenv("FILEBROWSER_DATABASE")
if database == "" {
database = "database.db"
}
return Settings{
Server: Server{
Port: 80,
NumImageProcessors: 4,
NumImageProcessors: numCpus,
BaseURL: "",
Database: "database.db",
Database: database,
SourceMap: map[string]Source{},
NameToSource: map[string]Source{},
MaxArchiveSizeGB: 50,
@@ -257,7 +366,6 @@ func setDefaults() Settings {
},
Auth: Auth{
AdminUsername: "admin",
AdminPassword: "admin",
TokenExpirationHours: 2,
Methods: LoginMethods{
PasswordAuth: PasswordAuthConfig{
@@ -270,23 +378,27 @@ func setDefaults() Settings {
Frontend: Frontend{
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
DisableOnlyOfficeExt: ".txt .csv .html .pdf",
StickySidebar: true,
LockPassword: false,
ShowHidden: false,
DarkMode: true,
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
StickySidebar: true,
LockPassword: false,
ShowHidden: false,
DarkMode: true,
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: users.Permissions{
Modify: false,
Share: false,
Admin: false,
Api: false,
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
ChunkSize: 10, // 10MB
},
},
}
}
@@ -366,7 +478,7 @@ func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
source, ok := Config.Server.NameToSource[sourceName]
if !ok {
logger.Debug(fmt.Sprint("Could not get scope from source name: ", sourceName))
logger.Debug("Could not get scope from source name: ", sourceName)
return "", fmt.Errorf("source with name not found %v", sourceName)
}
for _, scope := range scopes {
@@ -374,7 +486,7 @@ func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (stri
return scope.Scope, nil
}
}
logger.Debug(fmt.Sprintf("scope not found for source %v", sourceName))
logger.Debugf("scope not found for source %v", sourceName)
return "", fmt.Errorf("scope not found for source %v", sourceName)
}
@@ -398,3 +510,28 @@ func GetSources(u *users.User) []string {
}
return sources
}
func modifyExcludeInclude(config *Source) {
normalize := func(s []string, checkExists bool) {
for i, v := range s {
s[i] = "/" + strings.Trim(v, "/")
// check if file/folder exists
if checkExists {
realPath, err := filepath.Abs(config.Path + s[i])
if err != nil {
logger.Warningf("could not get absolute path for %v: %v", s[i], err)
continue
}
if _, err := os.Stat(realPath); os.IsNotExist(err) {
logger.Warningf("configured exclude/include path %v does not exist", realPath)
}
}
}
}
normalize(config.Config.Exclude.FolderPaths, true)
normalize(config.Config.Exclude.FilePaths, true)
normalize(config.Config.Include.RootFolders, true)
normalize(config.Config.Include.RootFiles, true)
normalize(config.Config.NeverWatchPaths, true)
}
+8 -8
View File
@@ -34,7 +34,7 @@ func Test_setDefaults(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setDefaults(); !reflect.DeepEqual(got, tt.want) {
if got := setDefaults(true); !reflect.DeepEqual(got, tt.want) {
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
}
})
@@ -42,8 +42,8 @@ func Test_setDefaults(t *testing.T) {
}
func TestConfigLoadChanged(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./validConfig.yaml")
defaultConfig := setDefaults(true)
err := loadConfigWithDefaults("./validConfig.yaml", true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
@@ -54,11 +54,11 @@ func TestConfigLoadChanged(t *testing.T) {
}
func TestConfigLoadEnvVars(t *testing.T) {
defaultConfig := setDefaults()
defaultConfig := setDefaults(true)
expectedKey := "MYKEY"
// mock environment variables
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
err := loadConfigWithDefaults("./validConfig.yaml")
err := loadConfigWithDefaults("./validConfig.yaml", true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
@@ -72,8 +72,8 @@ func TestConfigLoadEnvVars(t *testing.T) {
}
func TestConfigLoadSpecificValues(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./validConfig.yaml")
defaultConfig := setDefaults(true)
err := loadConfigWithDefaults("./validConfig.yaml", true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
@@ -94,7 +94,7 @@ func TestConfigLoadSpecificValues(t *testing.T) {
func TestInvalidConfig(t *testing.T) {
configFile := "./invalidConfig.yaml"
err := loadConfigWithDefaults(configFile)
err := loadConfigWithDefaults(configFile, true)
if err == nil {
t.Fatalf("expected error loading config file %s, got nil", configFile)
}
@@ -1,9 +1,7 @@
// cmd/yamlgen/main.go
package main
package settings
import (
"bytes"
"flag"
"fmt"
"go/ast"
"go/parser"
@@ -13,7 +11,6 @@ import (
"reflect"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"gopkg.in/yaml.v3"
)
@@ -82,6 +79,13 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
}
switch v.Kind() {
case reflect.String:
return &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: v.String(),
Style: yaml.DoubleQuotedStyle,
}, nil
case reflect.Struct:
rt := v.Type()
typeName := rt.Name()
@@ -116,12 +120,13 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
// attach validate and comments inline
var parts []string
if vt := sf.Tag.Get("validate"); vt != "" {
parts = append(parts, fmt.Sprintf("validate:%s", vt))
}
if cm := comm[typeName][sf.Name]; cm != "" {
parts = append(parts, fmt.Sprintf("comments:\"%s\"", cm))
parts = append(parts, cm)
}
if vt := sf.Tag.Get("validate"); vt != "" {
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
}
if len(parts) > 0 {
keyNode.LineComment = strings.Join(parts, " ")
}
@@ -168,19 +173,29 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
}
}
func main() {
input := flag.String("input", "settings/settings.go", "path to Go source file or directory containing structs")
output := flag.String("output", "settings/config.generated.yaml", "path to write generated YAML")
flag.Parse()
func GenerateYaml() {
_ = loadConfigWithDefaults("", true)
Config.Server.Sources = []Source{
{
Path: ".",
},
}
comm, err := collectComments(*input)
setupLogging()
setupAuth(true)
setupSources(true)
setupUrls()
setupFrontend(true)
input := "common/settings/settings.go" // "path to Go source file or directory containing structs"
output := "generated.yaml" // "output YAML file"
comm, err := collectComments(input)
if err != nil {
fmt.Fprintf(os.Stderr, "error parsing comments: %v\n", err)
os.Exit(1)
}
cfg := &settings.Settings{}
node, err := buildNode(reflect.ValueOf(cfg), comm)
node, err := buildNode(reflect.ValueOf(Config), comm)
if err != nil {
fmt.Fprintf(os.Stderr, "error building YAML node: %v\n", err)
os.Exit(1)
@@ -199,51 +214,57 @@ func main() {
aligned := alignComments(rawBuf.String())
if err := os.WriteFile(*output, []byte(aligned), 0644); err != nil {
if err := os.WriteFile(output, []byte(aligned), 0644); err != nil {
fmt.Fprintf(os.Stderr, "error writing YAML: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generated YAML with comments: %s\n", *output)
fmt.Printf("Generated YAML with comments: %s\n", output)
}
// alignComments scans YAML text and aligns all inline '#' within each indentation block
func alignComments(input string) string {
lines := strings.Split(input, "\n")
var out []string
blockStart := 0
maxPos := 0
for i, line := range lines {
trim := strings.TrimLeft(line, " ")
// new block when indentation decreases
if i > 0 {
prevIndent := len(lines[i-1]) - len(strings.TrimLeft(lines[i-1], " "))
curIndent := len(line) - len(trim)
if curIndent < prevIndent {
for j := blockStart; j < i; j++ {
out = append(out, padLine(lines[j], maxPos))
// formatLine applies padding to a single line so its comment starts at a target column.
func formatLine(line string) string {
const targetCol = 42
const defaultPadding = 1
// Find the first # character that is NOT inside double or single quotes.
inSingle, inDouble := false, false
for i := 0; i < len(line); i++ {
c := line[i]
switch c {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case '#':
if !inSingle && !inDouble {
// found a comment
contentPart := line[:i]
commentPart := line[i:]
trimmedContent := strings.TrimRight(contentPart, " ")
if len(trimmedContent) >= targetCol {
return trimmedContent + strings.Repeat(" ", defaultPadding) + commentPart
}
blockStart = i
maxPos = 0
}
}
if idx := strings.Index(line, "#"); idx >= 0 {
if idx > maxPos {
maxPos = idx
}
}
if i == len(lines)-1 {
for j := blockStart; j <= i; j++ {
out = append(out, padLine(lines[j], maxPos))
paddingNeeded := targetCol - len(trimmedContent)
return trimmedContent + strings.Repeat(" ", paddingNeeded) + commentPart
}
}
}
return strings.Join(out, "\n")
}
// padLine inserts spaces before '#' so it lands at column maxPos
func padLine(line string, maxPos int) string {
if idx := strings.Index(line, "#"); idx >= 0 && idx < maxPos {
return line[:idx] + strings.Repeat(" ", maxPos-idx) + line[idx:]
}
// No comment found
return line
}
// alignComments formats each line of the YAML string independently.
func alignComments(input string) string {
lines := strings.Split(input, "\n")
outputLines := make([]string, len(lines))
for i, line := range lines {
outputLines[i] = formatLine(line)
}
return strings.Join(outputLines, "\n")
}
+2 -1
View File
@@ -46,9 +46,10 @@ func ApplyUserDefaults(u *users.User) {
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
u.Permissions = Config.UserDefaults.Permissions
u.Preview = Config.UserDefaults.Preview
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
u.ThemeColor = Config.UserDefaults.ThemeColor
u.GallerySize = Config.UserDefaults.GallerySize
u.QuickDownload = Config.UserDefaults.QuickDownload
+79 -38
View File
@@ -21,7 +21,7 @@ type Settings struct {
}
type Server struct {
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
@@ -31,16 +31,17 @@ type Server struct {
Port int `json:"port"` // port to listen on
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging"`
Database string `json:"database"` // path to the database file
DebugMedia bool `json:"debugMedia"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
Database string `json:"database"` // path to the database file
Sources []Source `json:"sources" validate:"required,dive"`
ExternalUrl string `json:"externalUrl"` // used by share links if set
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the url that an integration service will use to communicate with filebrowser
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
// not exposed to config
SourceMap map[string]Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]Source `json:"-" validate:"omitempty"` // uses name as key
DefaultSource Source `json:"-" validate:"omitempty"`
SourceMap map[string]Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]Source `json:"-" validate:"omitempty"` // uses name as key
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
}
type Integrations struct {
@@ -66,6 +67,7 @@ type LogConfig struct {
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Json bool `json:"json"` // output in json format, currently not supported
Utc bool `json:"utc"` // use UTC time in the output instead of local time
}
type Source struct {
@@ -75,23 +77,34 @@ type Source struct {
}
type SourceConfig struct {
IndexingInterval uint32 `json:"indexingInterval"` // optional manual overide interval in seconds to re-index the source
Disabled bool `json:"disabled"` // disable the indexing of this source
MaxWatchers int `json:"maxWatchers"` // number of concurrent watchers to use for this source, currently not supported
NeverWatch []string `json:"neverWatchPaths"` // paths to never watch, relative to the source path (eg. "/folder/file.txt")
IgnoreHidden bool `json:"ignoreHidden"` // ignore hidden files and folders.
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
Exclude IndexFilter `json:"exclude"` // exclude files and folders from indexing, if include is not set
Include IndexFilter `json:"include"` // include files and folders from indexing, if exclude is not set
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user
DenyByDefault bool `json:"denyByDefault"` // deny access unless an "allow" access rule was specifically created.
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
Disabled bool `json:"disabled"` // disable the source, this is useful so you don't need to remove it from the config file
IndexingInterval uint32 `json:"indexingIntervalMinutes"` // optional manual overide interval in minutes to re-index the source
DisableIndexing bool `json:"disableIndexing"` // disable the indexing of this source
MaxWatchers int `json:"maxWatchers"` // number of concurrent watchers to use for this source, currently not supported
NeverWatchPaths []string `json:"neverWatchPaths"` // paths that get initially once. Useful for folders that rarely change contents (without source path prefix)
Exclude ExcludeIndexFilter `json:"exclude"` // exclude files and folders from indexing, if include is not set
Include IncludeIndexFilter `json:"include"` // include files and folders from indexing, if exclude is not set
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user
}
type IndexFilter struct {
Files []string `json:"files"` // array of file names to include/exclude
Folders []string `json:"folders"` // array of folder names to include/exclude
FileEndsWith []string `json:"fileEndsWith"` // array of file names to include/exclude (eg "a.jpg")
type IncludeIndexFilter struct {
RootFolders []string `json:"rootFolders"` // list of root folders to include, relative to the source path (eg. "folder1")
RootFiles []string `json:"rootFiles"` // list of root files to include, relative to the source path (eg. "file1.txt")
}
type ExcludeIndexFilter struct {
Hidden bool `json:"hidden"` // exclude hidden files and folders.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
FilePaths []string `json:"filePaths"` // list of filepaths Eg. "folder1" or "file1.txt" or "folder1/file1.txt" (without source path prefix)
FolderPaths []string `json:"folderPaths"` // (filepath) list of folder names to include/exclude. Eg. "folder1" or "folder1/subfolder" (do not include source path, just the subpaths from the source path)
FileNames []string `json:"fileNames"` // (global) list of file names to include/exclude. Eg. "a.jpg"
FolderNames []string `json:"folderNames"` // (global) list of folder names to include/exclude. Eg. "@eadir" or ".thumbnails"
FileEndsWith []string `json:"fileEndsWith"` // (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
FolderEndsWith []string `json:"folderEndsWith"` // (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
}
type Frontend struct {
@@ -99,6 +112,21 @@ type Frontend struct {
DisableDefaultLinks bool `json:"disableDefaultLinks"` // disable default links in the sidebar
DisableUsedPercentage bool `json:"disableUsedPercentage"` // disable used percentage for the sources in the sidebar
ExternalLinks []ExternalLink `json:"externalLinks"`
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
Styling StylingConfig `json:"styling"`
}
type StylingConfig struct {
CustomCSS string `json:"customCSS"` // if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
LightBackground string `json:"lightBackground"` // specify a valid CSS color property value to use as the background color in light mode
DarkBackground string `json:"darkBackground"` // Specify a valid CSS color property value to use as the background color in dark mode
CustomThemes map[string]CustomTheme `json:"customThemes"` // A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
CustomThemeOptions map[string]CustomTheme `json:"-"` // not exposed
}
type CustomTheme struct {
Description string `json:"description"` // The description of the theme to display in the UI.
CSS string `json:"css,omitempty"` // The css file path and filename to use for the theme.
}
type ExternalLink struct {
@@ -110,20 +138,33 @@ type ExternalLink struct {
// UserDefaults is a type that holds the default values
// for some fields on User.
type UserDefaults struct {
PreviewOptions users.PreviewOptions `json:"preview"`
StickySidebar bool `json:"stickySidebar"`
DarkMode bool `json:"darkMode"`
LockPassword bool `json:"lockPassword"`
DisableSettings bool `json:"disableSettings,omitempty"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
GallerySize int `json:"gallerySize"`
SingleClick bool `json:"singleClick"`
Permissions users.Permissions `json:"permissions"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
DefaultScopes []users.SourceScope `json:"-"`
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisablePreviewExt string `json:"disablePreviewExt"` // comma separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // comma separated list of file extensions to disable viewing for
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
Preview users.Preview `json:"preview"`
DefaultScopes []users.SourceScope `json:"-"`
Permissions users.Permissions `json:"permissions"`
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
}
+109
View File
@@ -0,0 +1,109 @@
package settings
import (
"os"
"regexp"
"strings"
"github.com/gtsteffaniak/go-logger/logger"
)
// Strict hex color regex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
var hexColorRE = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
// All valid CSS named colors (W3C spec, lower-case)
var cssNamedColors = map[string]struct{}{
"aliceblue": {}, "antiquewhite": {}, "aqua": {}, "aquamarine": {}, "azure": {},
"beige": {}, "bisque": {}, "black": {}, "blanchedalmond": {}, "blue": {}, "blueviolet": {},
"brown": {}, "burlywood": {}, "cadetblue": {}, "chartreuse": {}, "chocolate": {},
"coral": {}, "cornflowerblue": {}, "cornsilk": {}, "crimson": {}, "cyan": {},
"darkblue": {}, "darkcyan": {}, "darkgoldenrod": {}, "darkgray": {}, "darkgreen": {},
"darkgrey": {}, "darkkhaki": {}, "darkmagenta": {}, "darkolivegreen": {}, "darkorange": {},
"darkorchid": {}, "darkred": {}, "darksalmon": {}, "darkseagreen": {}, "darkslateblue": {},
"darkslategray": {}, "darkslategrey": {}, "darkturquoise": {}, "darkviolet": {}, "deeppink": {},
"deepskyblue": {}, "dimgray": {}, "dimgrey": {}, "dodgerblue": {}, "firebrick": {},
"floralwhite": {}, "forestgreen": {}, "fuchsia": {}, "gainsboro": {}, "ghostwhite": {},
"gold": {}, "goldenrod": {}, "gray": {}, "green": {}, "greenyellow": {}, "grey": {},
"honeydew": {}, "hotpink": {}, "indianred": {}, "indigo": {}, "ivory": {},
"khaki": {}, "lavender": {}, "lavenderblush": {}, "lawngreen": {}, "lemonchiffon": {},
"lightblue": {}, "lightcoral": {}, "lightcyan": {}, "lightgoldenrodyellow": {}, "lightgray": {},
"lightgreen": {}, "lightgrey": {}, "lightpink": {}, "lightsalmon": {}, "lightseagreen": {},
"lightskyblue": {}, "lightslategray": {}, "lightslategrey": {}, "lightsteelblue": {}, "lightyellow": {},
"lime": {}, "limegreen": {}, "linen": {}, "magenta": {}, "maroon": {}, "mediumaquamarine": {},
"mediumblue": {}, "mediumorchid": {}, "mediumpurple": {}, "mediumseagreen": {}, "mediumslateblue": {},
"mediumspringgreen": {}, "mediumturquoise": {}, "mediumvioletred": {}, "midnightblue": {}, "mintcream": {},
"mistyrose": {}, "moccasin": {}, "navajowhite": {}, "navy": {}, "oldlace": {},
"olive": {}, "olivedrab": {}, "orange": {}, "orangered": {}, "orchid": {},
"palegoldenrod": {}, "palegreen": {}, "paleturquoise": {}, "palevioletred": {}, "papayawhip": {},
"peachpuff": {}, "peru": {}, "pink": {}, "plum": {}, "powderblue": {},
"purple": {}, "rebeccapurple": {}, "red": {}, "rosybrown": {}, "royalblue": {},
"saddlebrown": {}, "salmon": {}, "sandybrown": {}, "seagreen": {}, "seashell": {},
"sienna": {}, "silver": {}, "skyblue": {}, "slateblue": {}, "slategray": {},
"slategrey": {}, "snow": {}, "springgreen": {}, "steelblue": {}, "tan": {},
"teal": {}, "thistle": {}, "tomato": {}, "turquoise": {}, "violet": {},
"wheat": {}, "white": {}, "whitesmoke": {}, "yellow": {}, "yellowgreen": {},
// And the special values:
"transparent": {}, "currentcolor": {},
}
func FallbackColor(val, defaultColor string) string {
val = strings.TrimSpace(val)
if val == "" {
return defaultColor
}
if hexColorRE.MatchString(val) {
return val
}
lc := strings.ToLower(val)
// CSS color functions
if strings.HasPrefix(lc, "rgb(") || strings.HasPrefix(lc, "rgba(") ||
strings.HasPrefix(lc, "hsl(") || strings.HasPrefix(lc, "hsla(") {
return val
}
// CSS variable
if strings.HasPrefix(lc, "var(") {
return val
}
// Named colors and special values
if _, ok := cssNamedColors[lc]; ok {
return val
}
// Log a warning if the color is invalid
logger.Warningf("Invalid CSS color value provided: '%s'. Falling back to default: '%s'", val, defaultColor)
return defaultColor
}
func addCustomTheme(name, description, cssFilePath string) {
cssContent := ""
if cssFilePath != "" {
cssContent = readCustomCSS(cssFilePath)
}
Config.Frontend.Styling.CustomThemes[name] = CustomTheme{
Description: description,
CSS: cssContent,
}
Config.Frontend.Styling.CustomThemeOptions[name] = CustomTheme{
Description: description,
CSS: cssFilePath,
}
}
func readCustomCSS(path string) string {
if path == "" {
return ""
}
content, err := os.ReadFile(path)
if err != nil {
// Only log if the file exists but cannot be read (e.g., permission error)
if !os.IsNotExist(err) {
logger.Warningf("Could not read custom CSS file %s: %v", path, err)
return ""
}
}
if len(content) > 128*1024 {
logger.Warningf("Custom CSS file %s is too large (%d bytes), ignoring", path, len(content))
return ""
}
logger.Infof("Loaded custom CSS from: %s (%d bytes)", path, len(content))
return string(content)
}
+15
View File
@@ -0,0 +1,15 @@
package utils
import (
"time"
"github.com/gtsteffaniak/go-cache/cache"
)
var (
DiskUsageCache = cache.NewCache(30*time.Second, 24*time.Hour)
RealPathCache = cache.NewCache(48*time.Hour, 72*time.Hour)
SearchResultsCache = cache.NewCache(15*time.Second, 1*time.Hour)
OnlyOfficeCache = cache.NewCache(48*time.Hour, 1*time.Hour)
JwtCache = cache.NewCache(1*time.Hour, 72*time.Hour)
)
+102
View File
@@ -0,0 +1,102 @@
package utils
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/mod/semver"
)
var updateAvailableUrl = ""
type Tag struct {
Name string `json:"name"`
}
type updateInfo struct {
LatestVersion string
CurrentVersion string
ReleaseNotes string
ReleaseUrl string
}
func CheckForUpdates() (updateInfo, error) {
// --- Configuration ---
repoOwner := "gtsteffaniak"
repoName := "filebrowser"
currentVersion := version.Version
splitVersion := strings.Split(currentVersion, "-")
versionCategory := "stable"
if len(splitVersion) > 1 {
versionCategory = splitVersion[1]
}
githubApiUrl := fmt.Sprintf("https://api.github.com/repos/%s/%s/tags", repoOwner, repoName)
// Make the GET request
resp, err := http.Get(githubApiUrl)
if err != nil {
return updateInfo{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return updateInfo{}, fmt.Errorf("failed to fetch tags from GitHub API")
}
// Decode the JSON response
var tags []Tag
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
return updateInfo{}, err
}
// Find the latest beta version greater than the current one
var NewVersion string
for _, tag := range tags {
// Check if the version is valid, is a pre-release, and contains "beta"
if semver.IsValid(tag.Name) && strings.Contains(semver.Prerelease(tag.Name), versionCategory) {
// Check if this tag is greater than the current version
// and also greater than any other beta version we've found so far
if semver.Compare(tag.Name, currentVersion) > 0 {
if NewVersion == "" || semver.Compare(tag.Name, NewVersion) > 0 {
NewVersion = tag.Name
}
}
}
}
if NewVersion == "" {
// No newer version found, return empty updateInfo
return updateInfo{}, fmt.Errorf("no newer version found for %s", currentVersion)
}
updateAvailableUrl = fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", repoOwner, repoName, NewVersion)
return updateInfo{
LatestVersion: NewVersion,
CurrentVersion: currentVersion,
ReleaseNotes: updateAvailableUrl,
ReleaseUrl: updateAvailableUrl,
}, nil
}
func GetUpdateAvailableUrl() string {
return updateAvailableUrl
}
// starts a background process to check for updates periodically.
func StartCheckForUpdates() {
// Create a ticker that fires every 24 hours.
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
// Start a loop that waits for the ticker to fire.
for range ticker.C {
_, err := CheckForUpdates()
if err != nil {
// In a real application, you might want more sophisticated logging.
logger.Debug("update check failed:", err)
continue // Don't stop the loop, just wait for the next tick.
}
// Update the global variable with the result of the check.
// This will be an empty string if no newer version was found.
}
}
+7 -30
View File
@@ -6,20 +6,18 @@ import (
"encoding/hex"
"fmt"
math "math/rand"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
func CheckErr(source string, err error) {
if err != nil {
logger.Fatal(fmt.Sprintf("%s: %v", source, err))
logger.Fatalf("%s: %v", source, err)
}
}
@@ -73,7 +71,7 @@ func PrintStructFields(v interface{}) {
fieldValue = fieldValue[:100] + "..."
}
logger.Debug(fmt.Sprintf("Field: %s, %s\n", fieldType.Name, fieldValue))
logger.Debugf("Field: %s, %s\n", fieldType.Name, fieldValue)
}
}
@@ -117,30 +115,9 @@ func JoinPathAsUnix(parts ...string) string {
return joinedPath
}
// ResolveSymlinks resolves symlinks in the given path and returns
// the final resolved path, whether it's a directory (considering bundle logic), and any error.
func ResolveSymlinks(path string) (string, bool, error) {
for {
// Get the file info using os.Lstat to handle symlinks
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
// Check if the path is a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the symlink's target relative to its directory
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, check with bundle-aware directory logic
isDir := iteminfo.IsDirectory(info)
return path, isDir, nil
}
func NonNilSlice[T any](in []T) []T {
if in == nil {
return []T{}
}
return in
}
-27
View File
@@ -4,35 +4,8 @@ import (
"time"
"math/rand"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func CreateMockData(numDirs, numFilesPerDir int) iteminfo.FileInfo {
dir := iteminfo.FileInfo{}
dir.Path = "/here/is/your/mock/dir"
for i := 0; i < numDirs; i++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + GetRandomTerm() + GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Folders = append(dir.Folders, newFile)
}
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + GetRandomTerm() + GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Files = append(dir.Files, newFile)
}
return dir
}
func GenerateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
+990
View File
@@ -0,0 +1,990 @@
package access
import (
"encoding/json"
"fmt"
"maps"
"slices"
"sort"
"sync"
"time"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var accessCache = cache.NewCache(1 * time.Minute)
const accessRulesBucket = "access_rules"
const accessRulesKey = "rules"
const accessChangedKey = "newRule:"
type RuleMap map[string]*AccessRule
type SourceRuleMap map[string]RuleMap
type StringSet map[string]struct{}
type dbStorage struct {
AllRules SourceRuleMap `json:"all_rules"`
Groups GroupMap `json:"groups"`
}
// RuleSet groups users and groups for allow/deny lists.
type RuleSet struct {
Users StringSet
Groups StringSet
}
// AccessRule defines allow/deny lists for a path.
type AccessRule struct {
DenyAll bool `json:"denyAll,omitempty"`
Deny RuleSet
Allow RuleSet
}
type FrontendRuleSet struct {
Users []string `json:"users"`
Groups []string `json:"groups"`
}
type FrontendAccessRule struct {
DenyAll bool `json:"denyAll,omitempty"`
Deny FrontendRuleSet `json:"deny"`
Allow FrontendRuleSet `json:"allow"`
SourceDenyDefault bool `json:"sourceDenyDefault"`
}
// GroupMap maps group names to a set of usernames.
type GroupMap map[string]StringSet
// Storage manages access rules and group membership.
type Storage struct {
mux sync.RWMutex
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
DB *storm.DB // Optional: DB for persistence
Users *users.Storage // Reference to users storage
}
// SaveToDB persists all rules to the DB if DB is set.
func (s *Storage) SaveToDB() error {
if s.DB == nil {
return nil
}
data, err := json.Marshal(&dbStorage{
AllRules: s.AllRules,
Groups: s.Groups,
})
if err != nil {
return err
}
return s.DB.Set(accessRulesBucket, accessRulesKey, data)
}
// Flush persists the current in-memory state to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
return s.SaveToDB()
}
// LoadFromDB loads all rules from the DB if DB is set.
func (s *Storage) LoadFromDB() error {
if s.DB == nil {
return nil
}
var data []byte
err := s.DB.Get(accessRulesBucket, accessRulesKey, &data)
if err != nil {
return err
}
var storage dbStorage
if err := json.Unmarshal(data, &storage); err != nil {
return err
}
s.mux.Lock()
s.AllRules = storage.AllRules
if s.AllRules == nil {
s.AllRules = make(SourceRuleMap)
}
s.Groups = storage.Groups
if s.Groups == nil {
s.Groups = make(GroupMap)
}
s.mux.Unlock()
return nil
}
// NewStorage creates a new Storage instance. Optionally pass a DB for persistence and users storage.
// After creating Storage with a DB, call LoadFromDB() to load rules from the database on startup.
// Example:
//
// store := NewStorage(db, usersStore)
// err := store.LoadFromDB()
// if err != nil { /* handle error */ }
func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
var s = &Storage{
AllRules: make(SourceRuleMap),
Groups: make(GroupMap),
DB: db,
Users: usersStore,
}
return s
}
// ClearCache clears the access cache (useful for testing)
func ClearCache() {
// Recreate the cache to clear it
accessCache = cache.NewCache(1 * time.Minute)
}
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
// The caller must hold the lock.
func (s *Storage) getOrCreateRuleNL(sourcePath, indexPath string) *AccessRule {
if _, ok := s.AllRules[sourcePath]; !ok {
s.AllRules[sourcePath] = make(RuleMap)
}
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
rule = &AccessRule{
Deny: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
Allow: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
}
s.AllRules[sourcePath][indexPath] = rule
}
logger.Debugf("Created rule for source: %s and index: %s", sourcePath, indexPath)
return rule
}
// DenyUser adds a user to the deny list for a given source and index path.
func (s *Storage) DenyUser(sourcePath, indexPath, username string) error {
if s.Users != nil {
_, err := s.Users.Get(username)
if err != nil {
return fmt.Errorf("user '%s' does not exist: %w", username, err)
}
}
s.mux.Lock()
defer s.mux.Unlock()
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Users[username]; ok {
return errors.ErrExist
}
rule.Deny.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// AllowUser adds a user to the allow list for a given source and index path.
func (s *Storage) AllowUser(sourcePath, indexPath, username string) error {
if s.Users != nil {
_, err := s.Users.Get(username)
if err != nil {
return fmt.Errorf("user '%s' does not exist: %w", username, err)
}
}
s.mux.Lock()
defer s.mux.Unlock()
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Users[username]; ok {
return errors.ErrExist
}
rule.Allow.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// DenyGroup adds a group to the deny list for a given source and index path.
func (s *Storage) DenyGroup(sourcePath, indexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
_, ok := s.Groups[groupname]
if !ok {
return fmt.Errorf("group '%s' does not exist", groupname)
}
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Groups[groupname]; ok {
return errors.ErrExist
}
rule.Deny.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// AllowGroup adds a group to the allow list for a given source and index path.
func (s *Storage) AllowGroup(sourcePath, indexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
_, ok := s.Groups[groupname]
if !ok {
return fmt.Errorf("group '%s' does not exist", groupname)
}
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Groups[groupname]; ok {
return errors.ErrExist
}
rule.Allow.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// DenyAll sets a rule to deny all access for a given source and index path.
func (s *Storage) DenyAll(sourcePath, indexPath string) error {
s.mux.Lock()
defer s.mux.Unlock()
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if rule.DenyAll {
return errors.ErrExist
}
rule.DenyAll = true
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
// Get current version for the sourcePath
versionKey := "version:" + sourcePath
version := 0
if v, ok := accessCache.Get(versionKey).(int); ok {
version = v
}
// Check cache with versioned key
permKey := fmt.Sprintf("perm:%s:%d:%s:%s", sourcePath, version, indexPath, username)
if p, ok := accessCache.Get(permKey).(bool); ok {
return p
}
// Not in cache, compute, then cache it.
result := s.computePermitted(sourcePath, indexPath, username)
accessCache.Set(permKey, result)
return result
}
func (s *Storage) computePermitted(sourcePath, indexPath, username string) bool {
for {
permitted, found := s.permittedAtExactPath(sourcePath, indexPath, username)
if found {
return permitted
}
indexPath = utils.GetParentDirectoryPath(indexPath)
if indexPath == "" {
break
}
}
// No rules found anywhere in the path hierarchy
// Check if the source has DenyByDefault configured (acts like a root-level denyAll rule)
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if !ok {
logger.Errorf("source %s not found in config during access check", sourcePath)
return false
}
if sourceInfo.Config.DenyByDefault {
return false
}
return true
}
// permittedAtExactPath checks if a rule exists at the given path and evaluates it if so.
func (s *Storage) permittedAtExactPath(sourcePath, indexPath, username string) (bool, bool) {
s.mux.RLock()
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
s.mux.RUnlock()
return true, false
}
rule, ok := rulesBySource[indexPath]
s.mux.RUnlock()
if !ok {
return true, false
}
return s.permittedRule(rule, username, sourcePath), true
}
// permittedRule contains the old Permitted logic, but operates on a rule and username only.
func (s *Storage) permittedRule(rule *AccessRule, username string, sourcePath string) bool {
// Check user deny first - this always takes precedence
if _, found := rule.Deny.Users[username]; found {
return false
}
// Check group deny
for group := range rule.Deny.Groups {
if s.isUserInGroup(username, group) {
return false
}
}
// Check allow rules - these can override DenyAll
hasUserAllow := len(rule.Allow.Users) > 0
hasGroupAllow := len(rule.Allow.Groups) > 0
if hasUserAllow || hasGroupAllow {
// If user is in any allow list, permit them (overrides DenyAll)
if hasUserAllow {
if _, found := rule.Allow.Users[username]; found {
return true
}
}
if hasGroupAllow {
for group := range rule.Allow.Groups {
if s.isUserInGroup(username, group) {
return true
}
}
}
// User is not in any allow list
// Check DenyByDefault to determine behavior
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if ok && sourceInfo.Config.DenyByDefault {
// DenyByDefault=true: allow lists are exclusive, deny users not on the list
return false
} else {
// DenyByDefault=false: allow lists are additive, allow users not on the list
return true
}
}
// Check DenyAll - only applies if no allow rules exist
if rule.DenyAll {
return false
}
// No specific rules apply - check source DenyByDefault setting
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if ok && sourceInfo.Config.DenyByDefault {
return false
}
return true
}
// isUserInGroup checks if a username is in a group.
func (s *Storage) isUserInGroup(username, group string) bool {
s.mux.RLock()
defer s.mux.RUnlock()
users, ok := s.Groups[group]
if !ok {
return false
}
_, found := users[username]
return found
}
// GetRule retrieves a rule for a sourcePath and indexPath.
func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccessRule, bool) {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
frontendRules := FrontendAccessRule{
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: make([]string, 0),
Groups: make([]string, 0),
},
Allow: FrontendRuleSet{
Users: make([]string, 0),
Groups: make([]string, 0),
},
}
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return frontendRules, false
}
rule, ok := rulesBySource[indexPath]
if !ok || rule == nil {
return frontendRules, false
}
// Convert AccessRule to FrontendAccessRule
frontendRules.DenyAll = rule.DenyAll
frontendRules.Deny.Users = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users)))
frontendRules.Deny.Groups = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups)))
frontendRules.Allow.Users = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users)))
frontendRules.Allow.Groups = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups)))
logger.Debugf("Returning frontend rules for source: %s and index: %s", sourcePath, indexPath)
return frontendRules, ok
}
// GetAllRules returns all access rules as a map.
func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule, error) {
value, ok := accessCache.Get(accessChangedKey + sourcePath).(map[string]FrontendAccessRule)
if ok {
return value, nil
}
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
// Return a copy to avoid external mutation
frontendRules := make(map[string]FrontendAccessRule, len(s.AllRules))
rules, ok := s.AllRules[sourcePath]
if !ok {
return frontendRules, nil
}
for indexPath, rule := range rules {
// Convert AccessRule to FrontendAccessRule
frontendRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
}
// cache responses
accessCache.Set(accessChangedKey+sourcePath, frontendRules)
return frontendRules, nil
}
// AddUserToGroup adds a username to a group.
func (s *Storage) AddUserToGroup(group, username string) error {
s.mux.Lock()
defer s.mux.Unlock()
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
}
if _, ok := s.Groups[group][username]; ok {
return nil
}
s.Groups[group][username] = struct{}{}
return s.SaveToDB()
}
// GetAllGroups returns all group names.
func (s *Storage) GetAllGroups() []string {
s.mux.RLock()
defer s.mux.RUnlock()
groups := make([]string, 0, len(s.Groups))
for group := range s.Groups {
groups = append(groups, group)
}
sort.Strings(groups)
return groups
}
// GetUserGroups returns all groups for a specific user.
func (s *Storage) GetUserGroups(username string) []string {
s.mux.RLock()
defer s.mux.RUnlock()
var groups []string
for group, users := range s.Groups {
if _, ok := users[username]; ok {
groups = append(groups, group)
}
}
return utils.NonNilSlice(groups)
}
// SyncUserGroups updates a user's group memberships.
// It removes the user from groups not in the new list and adds them to new ones.
func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
// Create a set of new groups for efficient lookup
newGroupsSet := make(StringSet, len(newGroups))
for _, g := range newGroups {
newGroupsSet[g] = struct{}{}
}
// Iterate over all existing groups to find the user's current memberships
for group, users := range s.Groups {
_, userIsInGroup := users[username]
_, groupIsInNewSet := newGroupsSet[group]
// If user is in a group that is not in their new set of groups, remove them.
if userIsInGroup && !groupIsInNewSet {
delete(s.Groups[group], username)
changed = true
}
}
// Add user to new groups
for group := range newGroupsSet {
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
}
if _, ok := s.Groups[group][username]; !ok {
s.Groups[group][username] = struct{}{}
changed = true
}
}
if changed {
return s.SaveToDB()
}
return nil
}
// RemoveUserFromGroup removes a username from a group.
func (s *Storage) RemoveUserFromGroup(group, username string) error {
s.mux.Lock()
defer s.mux.Unlock()
users, ok := s.Groups[group]
if !ok {
return nil
}
if _, ok := users[username]; !ok {
return nil
}
delete(users, username)
if len(s.Groups[group]) == 0 {
delete(s.Groups, group)
}
return s.SaveToDB()
}
// RemoveAllowUser removes a user from the allow list for a given source and index path.
func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Allow.Users[username]
if exists {
delete(rule.Allow.Users, username)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return false, nil
}
// RemoveAllowGroup removes a group from the allow list for a given source and index path.
func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Allow.Groups[groupname]
if exists {
delete(rule.Allow.Groups, groupname)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return exists, nil
}
// RemoveDenyUser removes a user from the deny list for a given source and index path.
func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Deny.Users[username]
if exists {
delete(rule.Deny.Users, username)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return false, nil
}
// RemoveDenyGroup removes a group from the deny list for a given source and index path.
func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Deny.Groups[groupname]
if exists {
delete(rule.Deny.Groups, groupname)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return exists, nil
}
// RemoveDenyAll removes the deny all rule for a given source and index path.
func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
removed := false
if rule.DenyAll {
rule.DenyAll = false
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return true, s.SaveToDB()
}
return false, nil
}
// RemoveAllRulesForUser removes a user from all allow and deny lists.
func (s *Storage) RemoveAllRulesForUser(username string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
changedSourcePaths := make(map[string]struct{})
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
if _, exists := rule.Allow.Users[username]; exists {
delete(rule.Allow.Users, username)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if _, exists := rule.Deny.Users[username]; exists {
delete(rule.Deny.Users, username)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
}
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, false)
}
if changed {
return s.SaveToDB()
}
return nil
}
// RemoveAllRulesForGroup removes a group from all allow and deny lists.
func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
changedSourcePaths := make(map[string]struct{})
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
if _, exists := rule.Allow.Groups[groupname]; exists {
delete(rule.Allow.Groups, groupname)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if _, exists := rule.Deny.Groups[groupname]; exists {
delete(rule.Deny.Groups, groupname)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
}
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, false)
}
if changed {
return s.SaveToDB()
}
return nil
}
// GetRulesForUser returns all rules for a specific user for a given sourcePath.
func (s *Storage) GetRulesForUser(sourcePath, username string) map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
userRules := make(map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return userRules
}
for indexPath, rule := range rulesBySource {
userHasRule := false
if _, ok := rule.Allow.Users[username]; ok {
userHasRule = true
}
if !userHasRule {
if _, ok := rule.Deny.Users[username]; ok {
userHasRule = true
}
}
if userHasRule {
userRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
}
}
return userRules
}
// GetRulesForGroup returns all rules for a specific group for a given sourcePath.
func (s *Storage) GetRulesForGroup(sourcePath, groupname string) map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
groupRules := make(map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return groupRules
}
for indexPath, rule := range rulesBySource {
groupHasRule := false
if _, ok := rule.Allow.Groups[groupname]; ok {
groupHasRule = true
}
if !groupHasRule {
if _, ok := rule.Deny.Groups[groupname]; ok {
groupHasRule = true
}
}
if groupHasRule {
groupRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
}
}
return groupRules
}
// GetAllRulesByUsers returns a map of usernames to their rules for a given sourcePath.
func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
allUserRules := make(map[string]map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return allUserRules
}
for indexPath, rule := range rulesBySource {
hasAllowUsers := len(rule.Allow.Users) > 0
hasDenyUsers := len(rule.Deny.Users) > 0
if !hasAllowUsers && !hasDenyUsers {
continue
}
frontendRule := FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
for user := range rule.Allow.Users {
if _, ok := allUserRules[user]; !ok {
allUserRules[user] = make(map[string]FrontendAccessRule)
}
allUserRules[user][indexPath] = frontendRule
}
for user := range rule.Deny.Users {
if _, ok := allUserRules[user]; !ok {
allUserRules[user] = make(map[string]FrontendAccessRule)
}
allUserRules[user][indexPath] = frontendRule
}
}
return allUserRules
}
// GetAllRulesByGroups returns a map of groupnames to their rules for a given sourcePath.
func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
allGroupRules := make(map[string]map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return allGroupRules
}
for indexPath, rule := range rulesBySource {
hasAllowGroups := len(rule.Allow.Groups) > 0
hasDenyGroups := len(rule.Deny.Groups) > 0
if !hasAllowGroups && !hasDenyGroups {
continue
}
frontendRule := FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
for group := range rule.Allow.Groups {
if _, ok := allGroupRules[group]; !ok {
allGroupRules[group] = make(map[string]FrontendAccessRule)
}
allGroupRules[group][indexPath] = frontendRule
}
for group := range rule.Deny.Groups {
if _, ok := allGroupRules[group]; !ok {
allGroupRules[group] = make(map[string]FrontendAccessRule)
}
allGroupRules[group][indexPath] = frontendRule
}
}
return allGroupRules
}
// incrementSourceVersion increments the version of a sourcePath to invalidate caches.
// The caller MUST hold the mutex lock.
func (s *Storage) incrementSourceVersion(sourcePath string) {
key := "version:" + sourcePath
version := 0
if v, ok := accessCache.Get(key).(int); ok {
version = v
}
accessCache.Set(key, version+1)
}
+407
View File
@@ -0,0 +1,407 @@
package access_test
import (
"path/filepath"
"testing"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
boltusers "github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := storm.Open(dbPath)
if err != nil {
t.Fatalf("failed to open storm db: %v", err)
}
userStore := users.NewStorage(boltusers.NewUsersBackend(db))
return access.NewStorage(db, userStore), userStore
}
func createTestUser(t *testing.T, userStore *users.Storage, username string) {
u := &users.User{NonAdminEditable: users.NonAdminEditable{Password: "test"}, Username: username}
err := userStore.Save(u, false, false)
if err != nil {
t.Fatalf("failed to create user %s: %v", username, err)
}
}
func setupTestSources() {
// Setup default test sources with allow-by-default behavior
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
"mnt/open": {
Path: "mnt/open",
Name: "open",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
}
}
func TestPermitted_UserBlacklist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if err := s.DenyUser("mnt/storage", "/secret", "alice"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if s.Permitted("mnt/storage", "/secret", "alice") {
t.Error("alice should not be permitted (denied)")
}
if !s.Permitted("mnt/storage", "/secret", "bob") {
t.Error("bob should be permitted (not denied)")
}
}
func TestPermitted_UserWhitelist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if err := s.AllowUser("mnt/storage", "/vip", "bob"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should be permitted (allowed)")
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (default allow behavior when DenyByDefault=false)")
}
}
func TestPermitted_GroupBlacklist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
_ = s.AddUserToGroup("admins", "alice")
if err := s.DenyGroup("mnt/storage", "/admin", "admins"); err != nil {
t.Errorf("DenyGroup failed: %v", err)
}
if s.Permitted("mnt/storage", "/admin", "bob") == false {
t.Error("bob should be permitted (not in denied group)")
}
if s.Permitted("mnt/storage", "/admin", "alice") {
t.Error("alice should not be permitted (in denied group)")
}
}
func TestPermitted_GroupWhitelist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
_ = s.AddUserToGroup("vip", "bob")
if err := s.AllowGroup("mnt/storage", "/vip", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should be permitted (in allowed group)")
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (default allow behavior when DenyByDefault=false)")
}
}
func TestPermitted_NoRule(t *testing.T) {
setupTestSources()
s, _ := createTestStorage(t)
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if !s.Permitted("mnt/storage", "/public", "anyone") {
t.Error("anyone should be permitted if no rule exists")
}
}
func TestPermitted_CombinedRules(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
createTestUser(t, userStore, "carol")
createTestUser(t, userStore, "eve")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
err = s.AddUserToGroup("vip", "bob")
if err != nil {
t.Errorf("AddUserToGroup failed: %v", err)
}
err = s.AddUserToGroup("admins", "alice")
if err != nil {
t.Errorf("AddUserToGroup failed: %v", err)
}
if err := s.DenyUser("mnt/storage", "/combo", "eve"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if err := s.AllowUser("mnt/storage", "/combo", "carol"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if err := s.DenyGroup("mnt/storage", "/combo", "admins"); err != nil {
t.Errorf("DenyGroup failed: %v", err)
}
if err := s.AllowGroup("mnt/storage", "/combo", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if s.Permitted("mnt/storage", "/combo", "eve") {
t.Error("eve should not be permitted (user denied)")
}
if !s.Permitted("mnt/storage", "/combo", "carol") {
t.Error("carol should be permitted (user allowed)")
}
if s.Permitted("mnt/storage", "/combo", "alice") {
t.Error("alice should not be permitted (in group denied)")
}
if !s.Permitted("mnt/storage", "/combo", "bob") {
t.Error("bob should be permitted (in group allowed)")
}
}
func TestPermitted_DenyAll(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Test DenyAll
if err = s.DenyAll("mnt/storage", "/private"); err != nil {
t.Errorf("DenyAll failed: %v", err)
}
if s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should not be permitted (deny all)")
}
if s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should not be permitted (deny all)")
}
// Test that Allow rule overrides DenyAll
if err = s.AllowUser("mnt/storage", "/private", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should be permitted (allow overrides deny all)")
}
// Test removing DenyAll
removed, err := s.RemoveDenyAll("mnt/storage", "/private")
if err != nil {
t.Errorf("RemoveDenyAll failed: %v", err)
}
if !removed {
t.Error("RemoveDenyAll should have removed the rule")
}
// After removing DenyAll, alice should be permitted due to the Allow rule
if !s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should be permitted after removing deny all")
}
// Bob should be permitted because DenyByDefault=false makes allow lists additive (not exclusive)
if !s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should be permitted after removing deny all (DenyByDefault=false makes allow lists additive)")
}
}
func TestPermitted_DenyByDefault(t *testing.T) {
// Clear access cache to prevent test pollution
access.ClearCache()
// Create isolated storage for this test
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test configuration with DenyByDefault enabled for one source
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: true,
},
},
"mnt/open": {
Path: "mnt/open",
Name: "open",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
}
// Test DenyByDefault: When no rules exist, users should be denied
if s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should not be permitted (DenyByDefault is true and no rules exist)")
}
if s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true and no rules exist)")
}
// Test that explicit allow rules override DenyByDefault
if err := s.AllowUser("mnt/storage", "/allowed", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/allowed", "alice") {
t.Error("alice should be permitted (explicit allow rule overrides DenyByDefault)")
}
if s.Permitted("mnt/storage", "/allowed", "bob") {
t.Error("bob should not be permitted (not in allow list and DenyByDefault is true)")
}
// Test that explicit deny rules work with DenyByDefault
if err := s.DenyUser("mnt/storage", "/denied", "alice"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if s.Permitted("mnt/storage", "/denied", "alice") {
t.Error("alice should not be permitted (explicit deny rule)")
}
if s.Permitted("mnt/storage", "/denied", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true)")
}
// Test that DenyByDefault doesn't affect sources where it's disabled
if !s.Permitted("mnt/open", "/public", "alice") {
t.Error("alice should be permitted in open source (DenyByDefault is false)")
}
if !s.Permitted("mnt/open", "/public", "bob") {
t.Error("bob should be permitted in open source (DenyByDefault is false)")
}
// Test recursive path checking with DenyByDefault
// Allow alice at parent path, should work for child paths too
if err := s.AllowUser("mnt/storage", "/parent", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/parent/child", "alice") {
t.Error("alice should be permitted for child path (inherits from parent allow rule)")
}
if s.Permitted("mnt/storage", "/parent/child", "bob") {
t.Error("bob should not be permitted for child path (DenyByDefault and not in allow list)")
}
// Test that group rules work with DenyByDefault
_ = s.AddUserToGroup("vip", "alice")
if err := s.AllowGroup("mnt/storage", "/vip", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (in allowed group)")
}
if s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should not be permitted (not in allowed group and DenyByDefault is true)")
}
}
func TestPermitted_DenyByDefaultWithDenyAll(t *testing.T) {
// Clear access cache to prevent test pollution
access.ClearCache()
// Create isolated storage for this test
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test configuration with DenyByDefault enabled
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: true,
},
},
}
// Test that explicit DenyAll rule also works when DenyByDefault is enabled
if err = s.DenyAll("mnt/storage", "/restricted"); err != nil {
t.Errorf("DenyAll failed: %v", err)
}
if s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should not be permitted (DenyAll rule)")
}
if s.Permitted("mnt/storage", "/restricted", "bob") {
t.Error("bob should not be permitted (DenyAll rule)")
}
// Test that Allow rule overrides DenyAll even with DenyByDefault
if err = s.AllowUser("mnt/storage", "/restricted", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should be permitted (allow overrides DenyAll)")
}
// Remove DenyAll and test that DenyByDefault takes effect
removed, err := s.RemoveDenyAll("mnt/storage", "/restricted")
if err != nil {
t.Errorf("RemoveDenyAll failed: %v", err)
}
if !removed {
t.Error("RemoveDenyAll should have removed the rule")
}
// After removing DenyAll, alice should be permitted due to Allow rule
if !s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should be permitted after removing DenyAll (has allow rule)")
}
// Bob should not be permitted due to DenyByDefault
if s.Permitted("mnt/storage", "/restricted", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true and no allow rule)")
}
}
+34
View File
@@ -0,0 +1,34 @@
package crud
// CrudBackend defines the basic CRUD operations for a type T.
type CrudBackend[T any] interface {
GetByID(id any) (*T, error)
GetAll() ([]*T, error)
Save(obj *T) error
DeleteByID(id any) error
}
// Storage provides generic CRUD operations for any type T.
type Storage[T any] struct {
backend CrudBackend[T]
}
func NewStorage[T any](backend CrudBackend[T]) *Storage[T] {
return &Storage[T]{backend: backend}
}
func (s *Storage[T]) Get(id any) (*T, error) {
return s.backend.GetByID(id)
}
func (s *Storage[T]) GetAll() ([]*T, error) {
return s.backend.GetAll()
}
func (s *Storage[T]) Save(obj *T) error {
return s.backend.Save(obj)
}
func (s *Storage[T]) Delete(id any) error {
return s.backend.DeleteByID(id)
}
+36 -6
View File
@@ -1,6 +1,35 @@
package share
import "sync"
type CommonShare struct {
//AllowEdit bool `json:"allowEdit,omitempty"`
AllowUpload bool `json:"allowUpload,omitempty"`
DisableFileViewer bool `json:"disableFileViewer,omitempty"`
DownloadsLimit int `json:"downloadsLimit,omitempty"`
ShareTheme string `json:"shareTheme,omitempty"`
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
MaxBandwidth int `json:"maxBandwidth,omitempty"`
DisableThumbnails bool `json:"disableThumbnails,omitempty"`
KeepAfterExpiration bool `json:"keepAfterExpiration,omitempty"`
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
ThemeColor string `json:"themeColor,omitempty"`
Banner string `json:"banner,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Favicon string `json:"favicon,omitempty"`
QuickDownload bool `json:"quickDownload,omitempty"`
HideNavButtons bool `json:"hideNavButtons,omitempty"`
DisableSidebar bool `json:"disableSidebar"`
ViewMode string `json:"viewMode,omitempty"`
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
Path string `json:"path,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
DisableShareCard bool `json:"disableShareCard,omitempty"`
}
type CreateBody struct {
CommonShare
Hash string `json:"hash,omitempty"`
Password string `json:"password"`
Expires string `json:"expires"`
Unit string `json:"unit"`
@@ -8,12 +37,13 @@ type CreateBody struct {
// Link is the information needed to build a shareable link.
type Link struct {
Hash string `json:"hash" storm:"id,index"`
Path string `json:"path" storm:"index"`
Source string `json:"source" storm:"index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
CommonShare
Mu sync.Mutex `json:"-"`
Downloads int `json:"downloads"`
Hash string `json:"hash" storm:"id,index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
// Token is a random value that will only be set when PasswordHash is set. It is
// URL-Safe and is used to download links in password-protected shares via a
// query arg.
+247 -47
View File
@@ -1,9 +1,14 @@
package share
import (
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// StorageBackend is the interface to implement for a share storage.
@@ -12,109 +17,304 @@ type StorageBackend interface {
FindByUserID(id uint) ([]*Link, error)
GetByHash(hash string) (*Link, error)
GetPermanent(path, source string, id uint) (*Link, error)
GetBySourcePath(path, source string) ([]*Link, error)
Gets(path, source string, id uint) ([]*Link, error)
Save(s *Link) error
Delete(hash string) error
}
// Storage is a storage.
type Storage struct {
// crudBackend implements crud.CrudBackend[Link] for share storage.
type crudBackend struct {
back StorageBackend
}
// NewStorage creates a share links storage from a backend.
func NewStorage(back StorageBackend) *Storage {
return &Storage{back: back}
func (c *crudBackend) GetByID(id any) (*Link, error) {
hash, ok := id.(string)
if !ok {
return nil, errors.ErrInvalidDataType
}
return c.back.GetByHash(hash)
}
// All wraps a StorageBackend.All.
func (c *crudBackend) GetAll() ([]*Link, error) {
return c.back.All()
}
func (c *crudBackend) Save(obj *Link) error {
return c.back.Save(obj)
}
func (c *crudBackend) DeleteByID(id any) error {
hash, ok := id.(string)
if !ok {
return errors.ErrInvalidDataType
}
return c.back.Delete(hash)
}
// Storage is a share storage using generics.
type Storage struct {
Generic *crud.Storage[Link]
back StorageBackend
shareCache map[string]*Link
mu sync.RWMutex
users *users.Storage
}
// NewStorage creates a share links storage from a backend.
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
return &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareCache: make(map[string]*Link),
users: usersStore,
}
}
// All wraps StorageBackend.All and handles expiry.
func (s *Storage) All() ([]*Link, error) {
links, err := s.back.All()
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
return links, nil
s.mu.Unlock()
return filtered, nil
}
// FindByUserID wraps a StorageBackend.FindByUserID.
// FindByUserID wraps StorageBackend.FindByUserID and handles expiry.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
links, err := s.back.FindByUserID(id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
return links, nil
s.mu.Unlock()
return filtered, nil
}
// GetByHash wraps a StorageBackend.GetByHash.
// GetByHash wraps StorageBackend.GetByHash and handles expiry.
func (s *Storage) GetByHash(hash string) (*Link, error) {
// return stable in-memory pointer if available
s.mu.RLock()
if link, ok := s.shareCache[hash]; ok && link != nil {
s.mu.RUnlock()
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
return nil, errors.ErrNotExist
}
return link, nil
}
s.mu.RUnlock()
link, err := s.back.GetByHash(hash)
if err != nil {
return nil, err
}
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
_ = s.back.Delete(hash)
return nil, errors.ErrNotExist
}
s.mu.Lock()
s.shareCache[hash] = link
s.mu.Unlock()
return link, nil
}
// GetPermanent wraps a StorageBackend.GetPermanent
// GetPermanent wraps StorageBackend.GetPermanent
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
return s.back.GetPermanent(path, source, id)
l, err := s.back.GetPermanent(path, source, id)
if err == nil && l != nil {
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
}
return l, err
}
// Gets wraps a StorageBackend.Gets
// Gets wraps StorageBackend.Gets and handles expiry.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
return links, nil
s.mu.Unlock()
return filtered, nil
}
// Save wraps a StorageBackend.Save
// GetBySourcePath wraps StorageBackend.GetBySourcePath and handles expiry.
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
links, err := s.back.GetBySourcePath(path, source)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
s.mu.Unlock()
return filtered, nil
}
// UpdateShares updates all shares that exactly match oldSource and oldPath
// to point to newSource and newPath. Returns the number of updated shares.
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
links, err := s.back.All()
if err != nil && err != errors.ErrNotExist {
logger.Error("failed to list shares", "error", err)
return 0, err
}
updated := 0
for _, l := range links {
if l == nil {
continue
}
if l.Source != oldSource {
continue
}
pos := strings.Index(l.Path, oldPath)
if pos < 0 {
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = oldFullPath[:pos] + newPath + oldFullPath[pos+len(oldPath):]
if err := s.back.Save(l); err != nil {
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
return updated, err
}
s.mu.Lock()
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
// update existing cached pointer fields
existing.Source = l.Source
existing.Path = l.Path
} else {
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
// Save wraps StorageBackend.Save
func (s *Storage) Save(l *Link) error {
return s.back.Save(l)
if err := s.back.Save(l); err != nil {
return err
}
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
return nil
}
// Delete wraps a StorageBackend.Delete
// Delete wraps StorageBackend.Delete
func (s *Storage) Delete(hash string) error {
return s.back.Delete(hash)
if err := s.back.Delete(hash); err != nil {
return err
}
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
return nil
}
// Flush persists the current in-memory state of all shares to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
s.mu.RLock()
links := make([]*Link, 0, len(s.shareCache))
for _, l := range s.shareCache {
if l != nil {
links = append(links, l)
}
}
s.mu.RUnlock()
for _, l := range links {
if err := s.back.Save(l); err != nil {
return err
}
}
return nil
}
// filterExpired removes expired links and deletes them from storage.
func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
var filtered []*Link
for _, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
s.mu.Lock()
delete(s.shareCache, link.Hash)
s.mu.Unlock()
continue
}
filtered = append(filtered, link)
}
return filtered, nil
}
-2
View File
@@ -17,8 +17,6 @@ func (s authBackend) Get(t string) (auth.Auther, error) {
auther = &auth.JSONAuth{}
case "proxy":
auther = &auth.ProxyAuth{}
case "hook":
auther = &auth.HookAuth{}
case "noauth":
auther = &auth.NoAuth{}
default:
+20 -5
View File
@@ -5,18 +5,33 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// Storage is a storage powered by a Backend which makes the necessary
// verifications when fetching and saving data to ensure consistency.
type BoltStore struct {
Users *users.Storage
Share *share.Storage
Auth *auth.Storage
Settings *settings.Storage
Access *access.Storage
}
// NewStorage creates a storage.Storage based on Bolt DB.
func NewStorage(db *storm.DB) (*auth.Storage, *users.Storage, *share.Storage, *settings.Storage, error) {
func NewStorage(db *storm.DB) (*BoltStore, error) {
userStore := users.NewStorage(usersBackend{db: db})
shareStore := share.NewStorage(shareBackend{db: db})
settingsStore := settings.NewStorage(settingsBackend{db: db})
authStore, err := auth.NewStorage(authBackend{db: db}, userStore)
if err != nil {
return nil, nil, nil, nil, err
return nil, err
}
return authStore, userStore, shareStore, settingsStore, nil
return &BoltStore{
Users: userStore,
Share: share.NewStorage(shareBackend{db: db}, userStore),
Auth: authStore,
Settings: settings.NewStorage(settingsBackend{db: db}),
Access: access.NewStorage(db, userStore),
}, nil
}
+13 -4
View File
@@ -1,15 +1,14 @@
package bolt
import (
"fmt"
"time"
storm "github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/go-logger/logger"
)
type shareBackend struct {
@@ -53,6 +52,16 @@ func (s shareBackend) GetPermanent(path, source string, id uint) (*share.Link, e
return &v, nil
}
// GetBySourcePath returns all shares that exactly match Path and Source across users.
func (s shareBackend) GetBySourcePath(path, source string) ([]*share.Link, error) {
var v []*share.Link
err := s.db.Select(q.Eq("Path", path), q.Eq("Source", source)).Find(&v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return v, err
}
func (s shareBackend) Gets(path, sourcePath string, id uint) ([]*share.Link, error) {
var v []*share.Link
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", sourcePath), q.Eq("UserID", id)).Find(&v)
@@ -61,9 +70,9 @@ func (s shareBackend) Gets(path, sourcePath string, id uint) ([]*share.Link, err
// through and filter out expired share
for i := range v {
if v[i].Expire < time.Now().Unix() && v[i].Expire != 0 {
err = s.Delete(v[i].PasswordHash)
err = s.Delete(v[i].Hash)
if err != nil {
logger.Error(fmt.Sprintf("expired share could not be deleted: %v", err.Error()))
logger.Errorf("expired share could not be deleted: %v", err.Error())
}
} else {
filteredList = append(filteredList, v[i])
+34 -9
View File
@@ -11,16 +11,21 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"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/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
type usersBackend struct {
db *storm.DB
}
// NewUsersBackend returns a users.StorageBackend backed by storm DB.
func NewUsersBackend(db *storm.DB) users.StorageBackend {
return &usersBackend{db: db}
}
func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
user = &users.User{}
@@ -32,6 +37,7 @@ func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
arg = "ID"
case int:
val = uint(i)
arg = "ID"
case string:
arg = "Username"
val = i
@@ -68,7 +74,11 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
if err != nil {
return err
}
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !user.OtpEnabled {
return errors.ErrNoTotpConfigured
}
fields, err = parseFields(user, fields, actorIsAdmin)
if err != nil {
return err
@@ -100,7 +110,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user)
err = files.MakeUserDirs(user, true)
if err != nil {
return err
}
@@ -121,6 +131,14 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
// Get the value to be stored
val := fieldValue.Interface()
if field == "OtpEnabled" {
otpEnabled, _ := val.(bool)
if otpEnabled {
continue
}
field = "TOTPSecret" // if otp is disabled, we also want to clear the TOTPSecret
val = "" // clear the TOTPSecret
}
// Update the database
if err := st.db.UpdateField(existingUser, field, val); err != nil {
return fmt.Errorf("failed to update user field: %s, error: %v", field, err)
@@ -136,11 +154,14 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return nil
}
func (st usersBackend) Save(user *users.User, changePass bool) error {
func (st usersBackend) Save(user *users.User, changePass, disableScopeChange bool) error {
if user.LoginMethod == "" {
user.LoginMethod = users.LoginMethodPassword
}
logger.Debug(fmt.Sprintf("Saving user [%s] changepass: %v", user.Username, changePass))
if user.Username == "anonymous" {
return fmt.Errorf("username cannot be 'anonymous'")
}
logger.Debugf("Saving user [%s] changepass: %v", user.Username, changePass)
if user.LoginMethod == users.LoginMethodPassword && changePass {
err := checkPassword(user.Password)
if err != nil {
@@ -159,13 +180,13 @@ func (st usersBackend) Save(user *users.User, changePass bool) error {
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user)
err = files.MakeUserDirs(user, disableScopeChange)
if err != nil {
logger.Error(err.Error())
}
err = st.db.Save(user)
if err == storm.ErrAlreadyExists {
return errors.ErrExist
return fmt.Errorf("user with provided username already exists")
}
return err
}
@@ -220,9 +241,13 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// which=all can't update password
if strings.ToLower(field.Name) != "password" && strings.ToLower(field.Name) != "id" && strings.ToLower(field.Name) != "username" {
fields = append(fields, field.Name)
switch strings.ToLower(field.Name) {
case "id", "username", "password", "apikeys", "totpenabled", "totpsecret", "totpnonce":
// Skip these fields
continue
}
fields = append(fields, field.Name)
}
}
newfields := []string{}
+13
View File
@@ -18,3 +18,16 @@ func get(db *storm.DB, name string, to interface{}) error {
func Save(db *storm.DB, name string, from interface{}) error {
return db.Set("config", name, from)
}
func SaveAccessRules(db *storm.DB, name string, from interface{}) error {
return db.Set("access_rules", name, from)
}
func GetAccessRules(db *storm.DB, name string, to interface{}) error {
err := db.Get("access_rules", name, to)
if err == storm.ErrNotFound {
return errors.ErrNotExist
}
return err
}
+55 -51
View File
@@ -4,52 +4,42 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"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/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// Storage is a storage powered by a Backend which makes the necessary
// verifications when fetching and saving data to ensure consistency.
type Storage struct {
Users *users.Storage
Share *share.Storage
Auth *auth.Storage
Settings *settings.Storage
}
var userStore *users.Storage
var store *Storage
func InitializeDb(path string) (*Storage, bool, error) {
func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
exists, err := dbExists(path)
if err != nil {
panic(err)
}
db, err := storm.Open(path)
utils.CheckErr(fmt.Sprintf("storm.Open path %v", path), err)
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
if err != nil {
if strings.Contains(err.Error(), "timeout") {
logger.Fatal("the database is locked, please close all other instances of filebrowser before starting.")
}
logger.Fatalf("could not open database: %v", err)
}
store, err := bolt.NewStorage(db)
if err != nil {
return nil, exists, err
}
// Load access rules from DB on startup
// ignoring errors because
_ = store.Access.LoadFromDB()
userStore = store.Users
err = bolt.Save(db, "version", 2)
if err != nil {
return nil, exists, err
}
store = &Storage{
Auth: authStore,
Users: userStore,
Share: shareStore,
Settings: settingsStore,
}
if !exists {
quickSetup(store)
}
@@ -77,45 +67,59 @@ func dbExists(path string) (bool, error) {
return false, err
}
func quickSetup(store *Storage) {
func quickSetup(store *bolt.BoltStore) {
settings.Config.Auth.Key = utils.GenerateKey()
err := store.Settings.Save(&settings.Config)
utils.CheckErr("store.Settings.Save", err)
err = store.Settings.SaveServer(&settings.Config.Server)
utils.CheckErr("store.Settings.SaveServer", err)
user := &users.User{}
settings.ApplyUserDefaults(user)
user.Username = settings.Config.Auth.AdminUsername
user.Password = settings.Config.Auth.AdminPassword
user.Permissions.Admin = true
user.Scopes = []users.SourceScope{}
for _, val := range settings.Config.Server.Sources {
user.Scopes = append(user.Scopes, users.SourceScope{
Name: val.Path, // backend name is path
Scope: "",
})
passwordAuth := settings.Config.Auth.Methods.PasswordAuth.Enabled
noAuth := settings.Config.Auth.Methods.NoAuth
if passwordAuth || noAuth {
user := &users.User{}
settings.ApplyUserDefaults(user)
user.Username = settings.Config.Auth.AdminUsername
if settings.Config.Auth.AdminPassword == "" {
settings.Config.Auth.AdminPassword = "admin"
}
user.Password = settings.Config.Auth.AdminPassword
user.Permissions.Admin = true
user.Scopes = []users.SourceScope{}
for _, val := range settings.Config.Server.Sources {
user.Scopes = append(user.Scopes, users.SourceScope{
Name: val.Path, // backend name is path
Scope: "",
})
}
user.LockPassword = false
user.Permissions = settings.AdminPerms()
logger.Debugf("Creating user as admin: %v %v", user.Username, user.Password)
err = store.Users.Save(user, true, true)
utils.CheckErr("store.Users.Save", err)
}
user.LockPassword = false
user.Permissions = settings.AdminPerms()
logger.Debug(fmt.Sprintf("Creating user as admin: %v %v", user.Username, user.Password))
err = store.Users.Save(user, true)
utils.CheckErr("store.Users.Save", err)
}
// create new user
func CreateUser(userInfo users.User, asAdmin bool) error {
func CreateUser(userInfo users.User) error {
newUser := &userInfo
// must have username or password to create
if userInfo.Username == "" || userInfo.Password == "" {
return errors.ErrInvalidRequestParams
if userInfo.LoginMethod == "password" {
if userInfo.Password == "" {
return fmt.Errorf("password is required to create a password login user")
}
} else {
hashpass, err := users.HashPwd(userInfo.Username)
if err != nil {
return err
}
userInfo.Password = hashpass
}
logger.Debug(fmt.Sprintf("Creating user: %v %v", userInfo.Username, userInfo.Scopes))
settings.ApplyUserDefaults(newUser)
if asAdmin {
userInfo.Permissions = settings.AdminPerms()
// must have username
if userInfo.Username == "" {
return fmt.Errorf("username is required to create a user")
}
logger.Debugf("Creating user: %v %v", userInfo.Username, userInfo.Scopes)
// create new home directories
err := store.Users.Save(newUser, true)
err := userStore.Save(newUser, true, false)
if err != nil {
return err
}
+42 -15
View File
@@ -5,13 +5,14 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
)
// StorageBackend is the interface to implement for a users storage.
type StorageBackend interface {
GetBy(interface{}) (*User, error)
Gets() ([]*User, error)
Save(u *User, changePass bool) error
Save(u *User, changePass bool, disableScopeChange bool) error
Update(u *User, adminActor bool, fields ...string) error
DeleteByID(uint) error
DeleteByUsername(string) error
@@ -22,15 +23,50 @@ type Store interface {
Get(id interface{}) (user *User, err error)
Gets() ([]*User, error)
Update(user *User, adminActor bool, fields ...string) error
Save(user *User, changePass bool) error
Save(user *User, changePass bool, disableScopeChange bool) error
Delete(id interface{}) error
LastUpdate(id uint) int64
AddApiKey(username uint, name string, key AuthToken) error
DeleteApiKey(username uint, name string) error
}
// Storage is a users storage.
// crudBackend implements crud.CrudBackend[User] for users storage.
type crudBackend struct {
back StorageBackend
}
func (c *crudBackend) GetByID(id any) (*User, error) {
switch v := id.(type) {
case string, uint:
return c.back.GetBy(v)
default:
return nil, errors.ErrInvalidDataType
}
}
func (c *crudBackend) GetAll() ([]*User, error) {
return c.back.Gets()
}
func (c *crudBackend) Save(obj *User) error {
// Use default values for changePass and disableScopeChange
return c.back.Save(obj, false, false)
}
func (c *crudBackend) DeleteByID(id any) error {
switch v := id.(type) {
case string:
return c.back.DeleteByUsername(v)
case uint:
return c.back.DeleteByID(v)
default:
return errors.ErrInvalidDataType
}
}
// Storage is a users storage using generics.
type Storage struct {
Generic *crud.Storage[User]
back StorageBackend
updated map[uint]int64
mux sync.RWMutex
@@ -39,6 +75,7 @@ type Storage struct {
// NewStorage creates a users storage from a backend.
func NewStorage(back StorageBackend) *Storage {
return &Storage{
Generic: crud.NewStorage[User](&crudBackend{back: back}),
back: back,
updated: map[uint]int64{},
}
@@ -114,8 +151,8 @@ func (s *Storage) DeleteApiKey(userID uint, name string) error {
}
// Save saves the user in a storage.
func (s *Storage) Save(user *User, changePass bool) error {
return s.back.Save(user, changePass)
func (s *Storage) Save(user *User, changePass, disableScopeChange bool) error {
return s.back.Save(user, changePass, disableScopeChange)
}
// Delete allows you to delete a user by its name or username. The provided
@@ -124,18 +161,8 @@ func (s *Storage) Save(user *User, changePass bool) error {
func (s *Storage) Delete(id interface{}) error {
switch id := id.(type) {
case string:
user, err := s.back.GetBy(id)
if err != nil {
return err
}
if user.ID == 1 {
return errors.ErrRootUserDeletion
}
return s.back.DeleteByUsername(id)
case uint:
if id == 1 {
return errors.ErrRootUserDeletion
}
return s.back.DeleteByID(id)
default:
return errors.ErrInvalidDataType
+46 -28
View File
@@ -40,12 +40,15 @@ type Sorting struct {
Asc bool `json:"asc"`
}
type PreviewOptions struct {
HighQuality bool `json:"highQuality"`
Image bool `json:"image"`
Video bool `json:"video"`
Office bool `json:"office"`
PopUp bool `json:"popup"`
type Preview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // disable the hide sidebar preview for previews and editors
HighQuality bool `json:"highQuality"` // generate high quality preview images
Image bool `json:"image"` // show real image as icon instead of generic photo icon
Video bool `json:"video"` // show preview image for video files
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in preview when hovering
Office bool `json:"office"` // show preview image for office files
PopUp bool `json:"popup"` // show larger popup preview when hovering
AutoplayMedia bool `json:"autoplayMedia"` // autoplay media files in preview
}
// User describes a user.
@@ -59,7 +62,10 @@ type User struct {
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"`
}
@@ -69,31 +75,43 @@ type SourceScope struct {
Scope string `json:"scope"`
}
// json tags must match variable name with smaller case first letter
type NonAdminEditable struct {
Preview PreviewOptions `json:"preview"`
StickySidebar bool `json:"stickySidebar"`
DarkMode bool `json:"darkMode"`
Password string `json:"password,omitempty"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
GallerySize int `json:"gallerySize"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
Preview Preview `json:"preview"`
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
DisablePreviewExt string `json:"disablePreviewExt"` // comma separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // comma separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
}
var PublicUser = User{
NonAdminEditable: NonAdminEditable{
Password: "publicUser", // temp user not registered
ViewMode: "normal",
},
Username: "publicUser", // temp user not registered
LockPassword: true,
Permissions: Permissions{},
type FileLoading struct {
MaxConcurrent int `json:"maxConcurrentUpload"`
ChunkSize int `json:"uploadChunkSizeMb"`
}
var AnonymousUser = User{
Username: "anonymous", // temp user not registered
}
func CleanUsername(s string) string {
+96 -78
View File
@@ -1,82 +1,103 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.24.0
go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/disintegration/imaging v1.6.2
github.com/coreos/go-oidc/v3 v3.15.0
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/go-playground/validator/v10 v10.26.0
github.com/goccy/go-yaml v1.17.1
github.com/gen2brain/go-fitz v1.24.15
github.com/go-playground/validator/v10 v10.27.0
github.com/goccy/go-yaml v1.18.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063
github.com/gtsteffaniak/go-logger v0.1.2
github.com/kovidgoyal/imaging v1.6.4
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.14.0
github.com/stretchr/testify v1.10.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.38.0
golang.org/x/image v0.27.0
golang.org/x/sys v0.33.0
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.41.0
golang.org/x/image v0.30.0
golang.org/x/mod v0.27.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.35.0
golang.org/x/time v0.12.0
gopkg.in/yaml.v3 v3.0.1
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect
github.com/Antonboom/nilnil v1.0.1 // indirect
github.com/Antonboom/testifylint v1.5.2 // indirect
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/Crocmagnon/fatcontext v0.7.1 // indirect
github.com/Antonboom/errname v1.1.0 // indirect
github.com/Antonboom/nilnil v1.1.0 // indirect
github.com/Antonboom/testifylint v1.6.1 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.62.0 // indirect
github.com/alecthomas/chroma/v2 v2.17.2 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.1.2 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/ashanbrown/forbidigo v1.6.0 // indirect
github.com/ashanbrown/makezero v1.2.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.5.0 // indirect
github.com/breml/bidichk v0.3.2 // indirect
github.com/breml/errchkjson v0.4.0 // indirect
github.com/butuzov/ireturn v0.3.1 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.0 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.8.2 // indirect
github.com/catenacyber/perfsprint v0.9.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/chavacava/garif v0.1.0 // indirect
github.com/ckaznocha/intrange v0.3.0 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.5 // indirect
github.com/daixiang0/gci v0.13.6 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.5 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/ghostiam/protogetter v0.3.9 // indirect
github.com/go-critic/go-critic v0.12.0 // indirect
github.com/ghostiam/protogetter v0.3.15 // indirect
github.com/go-critic/go-critic v0.13.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-openapi/jsonpointer v0.21.2 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
@@ -92,18 +113,19 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/geo v0.0.0-20250505201543-5b58c72585db // indirect
github.com/gohugoio/hugo v0.147.6 // indirect
github.com/golang/geo v0.0.0-20250821133510-ecfc33a939ac // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint v1.64.8 // indirect
github.com/golangci/golangci-lint/v2 v2.1.6 // indirect
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect
github.com/golangci/misspell v0.6.0 // indirect
github.com/golangci/plugin-module-register v0.1.1 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/gordonklaus/ineffassign v0.1.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
@@ -115,35 +137,30 @@ require (
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.7.1 // indirect
github.com/jgautheron/goconst v1.8.1 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.4 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.5.1 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kunwardeep/paralleltest v1.0.10 // indirect
github.com/kunwardeep/paralleltest v1.0.14 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.2 // indirect
github.com/ldez/exptostd v0.4.3 // indirect
github.com/ldez/gomoddirectives v0.6.1 // indirect
github.com/ldez/grignotin v0.9.0 // indirect
github.com/ldez/tagliatelle v0.7.1 // indirect
github.com/ldez/usetesting v0.4.2 // indirect
github.com/ldez/usetesting v0.4.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx v1.2.31 // indirect
github.com/lestrrat-go/jwx/v3 v3.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/macabu/inamedparam v0.1.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/manuelarte/funcorder v0.2.1 // indirect
github.com/maratori/testableexamples v1.0.0 // indirect
github.com/maratori/testpackage v1.1.1 // indirect
github.com/matoous/godox v1.1.0 // indirect
@@ -151,26 +168,25 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.7.0 // indirect
github.com/mgechev/revive v1.9.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.7.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/polyfloyd/go-errorlint v1.8.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect
github.com/quasilyte/go-ruleguard v0.4.4 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
@@ -179,20 +195,18 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/ryancurrah/gomodguard v1.3.5 // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
github.com/securego/gosec/v2 v2.22.2 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/securego/gosec/v2 v2.22.3 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sivchari/tenv v1.12.1 // indirect
github.com/sonatard/noctx v0.1.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cast v1.8.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
@@ -203,45 +217,49 @@ require (
github.com/subosito/gotenv v1.4.1 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect
github.com/tetafro/godot v1.5.0 // indirect
github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect
github.com/timonwong/loggercheck v0.10.1 // indirect
github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect
github.com/tdewolff/parse/v2 v2.8.1 // indirect
github.com/tetafro/godot v1.5.1 // indirect
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/uudashr/gocognit v1.2.0 // indirect
github.com/uudashr/iface v1.3.1 // indirect
github.com/xen0n/gosmopolitan v1.2.2 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.13.0 // indirect
go-simpler.org/sloglint v0.9.0 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
go-simpler.org/musttag v0.13.1 // indirect
go-simpler.org/sloglint v0.11.0 // indirect
go.augendre.info/fatcontext v0.8.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.33.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.org/x/tools/go/expect v0.1.1-deprecated // indirect
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.6.1 // indirect
mvdan.cc/gofumpt v0.7.0 // indirect
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
mvdan.cc/gofumpt v0.8.0 // indirect
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
tool (
github.com/golangci/golangci-lint/cmd/golangci-lint
github.com/air-verse/air
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
github.com/swaggo/swag/cmd/swag
)
+288 -170
View File
@@ -34,23 +34,25 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
github.com/Abirdcfly/dupword v0.1.3/go.mod h1:8VbB2t7e10KRNdwTVoxdBaxla6avbhGzb8sCTygUMhw=
github.com/Antonboom/errname v1.0.0 h1:oJOOWR07vS1kRusl6YRSlat7HFnb3mSfMl6sDMRoTBA=
github.com/Antonboom/errname v1.0.0/go.mod h1:gMOBFzK/vrTiXN9Oh+HFs+e6Ndl0eTFbtsRTSRdXyGI=
github.com/Antonboom/nilnil v1.0.1 h1:C3Tkm0KUxgfO4Duk3PM+ztPncTFlOf0b2qadmS0s4xs=
github.com/Antonboom/nilnil v1.0.1/go.mod h1:CH7pW2JsRNFgEh8B2UaPZTEPhCMuFowP/e8Udp9Nnb0=
github.com/Antonboom/testifylint v1.5.2 h1:4s3Xhuv5AvdIgbd8wOOEeo0uZG7PbDKQyKY5lGoQazk=
github.com/Antonboom/testifylint v1.5.2/go.mod h1:vxy8VJ0bc6NavlYqjZfmp6EfqXMtBgQ4+mhCojwC1P8=
github.com/Antonboom/errname v1.1.0 h1:A+ucvdpMwlo/myWrkHEUEBWc/xuXdud23S8tmTb/oAE=
github.com/Antonboom/errname v1.1.0/go.mod h1:O1NMrzgUcVBGIfi3xlVuvX8Q/VP/73sseCaAppfjqZw=
github.com/Antonboom/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+ng=
github.com/Antonboom/nilnil v1.1.0/go.mod h1:b7sAlogQjFa1wV8jUW3o4PMzDVFLbTux+xnQdvzdcIE=
github.com/Antonboom/testifylint v1.6.1 h1:6ZSytkFWatT8mwZlmRCHkWz1gPi+q6UBSbieji2Gj/o=
github.com/Antonboom/testifylint v1.6.1/go.mod h1:k+nEkathI2NFjKO6HvwmSrbzUcQ6FAnbZV+ZRrnXPLI=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Crocmagnon/fatcontext v0.7.1 h1:SC/VIbRRZQeQWj/TcQBS6JmrXcfA+BU4OGSVUt54PjM=
github.com/Crocmagnon/fatcontext v0.7.1/go.mod h1:1wMvv3NXEBJucFGfwOJBxSVWcoIO6emV215SMkW9MFU=
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM=
@@ -59,14 +61,18 @@ github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnh
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.62.0 h1:6CoXL4MAX9dc4xAzLfjMcDfbBoGmW5VjuuTV/1+bI+M=
github.com/air-verse/air v1.62.0/go.mod h1:EO+jWuetL10tS9raffwg8WEV0t0KUeucRRaf9ii86dA=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.17.2 h1:Rm81SCZ2mPoH+Q8ZCc/9YvzPUN/E7HgPiPJD8SLV6GI=
github.com/alecthomas/chroma/v2 v2.17.2/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
@@ -76,42 +82,75 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexkohler/nakedret/v2 v2.0.5 h1:fP5qLgtwbx9EJE8dGEERT02YwS8En4r9nnZ71RK+EVU=
github.com/alexkohler/nakedret/v2 v2.0.5/go.mod h1:bF5i0zF2Wo2o4X4USt9ntUWve6JbFv02Ff4vlkmS/VU=
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw=
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/alingse/nilnesserr v0.1.2 h1:Yf8Iwm3z2hUUrP4muWfW83DF4nE3r1xZ26fGWUKCZlo=
github.com/alingse/nilnesserr v0.1.2/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY=
github.com/ashanbrown/forbidigo v1.6.0/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU=
github.com/ashanbrown/makezero v1.2.0 h1:/2Lp1bypdmK9wDIq7uWBlDF1iMUpIIS4A+pF6C9IEUU=
github.com/ashanbrown/makezero v1.2.0/go.mod h1:dxlPhHbDMC6N6xICzFBSK+4njQDdK8euNO0qjQMtGY4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bep/gitmap v1.6.0 h1:sDuQMm9HoTL0LtlrfxjbjgAg2wHQd4nkMup2FInYzhA=
github.com/bep/gitmap v1.6.0/go.mod h1:n+3W1f/rot2hynsqEGxGMErPRgT41n9CkGuzPvz9cIw=
github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU=
github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI=
github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5kujw=
github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q=
github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc=
github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k=
github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8=
github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8=
github.com/bep/lazycache v0.8.0/go.mod h1:BQ5WZepss7Ko91CGdWz8GQZi/fFnCcyWupv8gyTeKwk=
github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE=
github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro=
github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w=
github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A=
github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc=
github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs=
github.com/breml/bidichk v0.3.2/go.mod h1:VzFLBxuYtT23z5+iVkamXO386OB+/sVwZOpIj6zXGos=
github.com/breml/errchkjson v0.4.0 h1:gftf6uWZMtIa/Is3XJgibewBm2ksAQSY/kABDNFTAdk=
github.com/breml/errchkjson v0.4.0/go.mod h1:AuBOSTHyLSaaAFlWsRSuRBIroCh3eh7ZHh5YeelDIk8=
github.com/butuzov/ireturn v0.3.1 h1:mFgbEI6m+9W8oP/oDdfA34dLisRFCj2G6o/yiI1yZrY=
github.com/butuzov/ireturn v0.3.1/go.mod h1:ZfRp+E7eJLC0NQmk1Nrm1LOrn/gQlOykv+cVPdiXH5M=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E=
github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70=
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
github.com/catenacyber/perfsprint v0.8.2 h1:+o9zVmCSVa7M4MvabsWvESEhpsMkhfE7k0sHNGL95yw=
github.com/catenacyber/perfsprint v0.8.2/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM=
github.com/catenacyber/perfsprint v0.9.1 h1:5LlTp4RwTooQjJCvGEFV6XksZvWE7wCOUvjD2z0vls0=
github.com/catenacyber/perfsprint v0.9.1/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM=
github.com/ccojocar/zxcvbn-go v1.0.2 h1:na/czXU8RrhXO4EZme6eQJLR4PzcGsahsBOAwU6I3Vg=
github.com/ccojocar/zxcvbn-go v1.0.2/go.mod h1:g1qkXtUSvHP8lhHp5GrSmTz6uWALGRMQdw6Qnz/hi60=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -121,33 +160,51 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iyoHGPf5w4=
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/ckaznocha/intrange v0.3.0 h1:VqnxtK32pxgkhJgYQEeOArVidIPg+ahLP7WBOXZd5ZY=
github.com/ckaznocha/intrange v0.3.0/go.mod h1:+I/o2d2A1FBHgGELbGxzIcyd3/9l9DuwjM8FsbSS3Lo=
github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs=
github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs=
github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
github.com/daixiang0/gci v0.13.5 h1:kThgmH1yBmZSBCh1EJVxQ7JsHpm5Oms0AMed/0LaH4c=
github.com/daixiang0/gci v0.13.5/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk=
github.com/daixiang0/gci v0.13.6 h1:RKuEOSkGpSadkGbvZ6hJ4ddItT3cVZ9Vn9Rybk6xjl8=
github.com/daixiang0/gci v0.13.6/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk=
github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY=
github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc=
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
@@ -165,30 +222,41 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
github.com/evanw/esbuild v0.25.3 h1:4JKyUsm/nHDhpxis4IyWXAi8GiyTwG1WdEp6OhGVE8U=
github.com/evanw/esbuild v0.25.3/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6tUTYIdRA=
github.com/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo=
github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ=
github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=
github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w=
github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w=
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY=
github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=
github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY=
github.com/go-critic/go-critic v0.13.0/go.mod h1:M/YeuJ3vOCQDnP2SU+ZhjgRzwzcBW87JqLpMJLrZDLI=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
@@ -198,6 +266,8 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -206,11 +276,8 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA=
github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
@@ -223,8 +290,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -253,22 +320,38 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs=
github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
github.com/gohugoio/hugo v0.147.6 h1:rL4rnus/5qzj4+FoA+JMzsVvFJ2YZdVIH6pbuCB2P84=
github.com/gohugoio/hugo v0.147.6/go.mod h1:Sb2COQPDPYG+tRSpePtzKytiuVDqkBivEhgIew1QbNo=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.3.0 h1:gj49kTR5Z4Hnm0ZaQrgPVazL3DUkppw+x6XhHCmh+Wk=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.3.0/go.mod h1:IMMj7xiUbLt1YNJ6m7AM4cnsX4cFnnfkleO/lBHGzUg=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250505201543-5b58c72585db h1:juWRoz5Y+/j2ffgMV0yPnZD5sd1EkeJOsrnMMyLIFrM=
github.com/golang/geo v0.0.0-20250505201543-5b58c72585db/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA=
github.com/golang/geo v0.0.0-20250821133510-ecfc33a939ac h1:gBtvd74wZT02fkOyoG651Sx2imv5lKdVvDwpUjKPFnw=
github.com/golang/geo v0.0.0-20250821133510-ecfc33a939ac/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -306,16 +389,18 @@ github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUP
github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint v1.64.8 h1:y5TdeVidMtBGG32zgSC7ZXTFNHrsJkDnpO4ItB3Am+I=
github.com/golangci/golangci-lint v1.64.8/go.mod h1:5cEsUQBSr6zi8XI8OjmcY2Xmliqc4iYL7YoPrL+zLJ4=
github.com/golangci/golangci-lint/v2 v2.1.6 h1:LXqShFfAGM5BDzEOWD2SL1IzJAgUOqES/HRBsfKjI+w=
github.com/golangci/golangci-lint/v2 v2.1.6/go.mod h1:EPj+fgv4TeeBq3TcqaKZb3vkiV5dP4hHHKhXhEhzci8=
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8=
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ=
github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs=
github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo=
github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c=
github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed h1:IURFTjxeTfNFP0hTEi1YKjB/ub8zkpaOqFFMApi2EAs=
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed/go.mod h1:XLXN8bNw4CGRPaqgl3bv/lhz7bsGPh4/xSaMTbo2vkQ=
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -362,6 +447,12 @@ github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW
github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M=
github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8=
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063 h1:CoLbeqTmM7348kulYBXYN82Bowx7Iu9PHe0shqQ2QR8=
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-logger v0.1.2 h1:0g6UxxNwU+Q+7IVRw+Yn7AuYsIK0ukiTgbzjTLm+Uy4=
github.com/gtsteffaniak/go-logger v0.1.2/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
@@ -380,10 +471,12 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/jgautheron/goconst v1.7.1 h1:VpdAG7Ca7yvvJk5n8dMwQhfEZJh95kl/Hl9S1OI5Jkk=
github.com/jgautheron/goconst v1.7.1/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4=
github.com/jgautheron/goconst v1.8.1 h1:PPqCYp3K/xlOj5JmIe6O1Mj6r1DbkdbLtR3AJuZo414=
github.com/jgautheron/goconst v1.8.1/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako=
github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs=
github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c=
github.com/jjti/go-spancheck v0.6.4 h1:Tl7gQpYf4/TMU7AT84MN83/6PutY21Nb9fuQjFTpRRc=
@@ -401,6 +494,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI=
github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
@@ -410,6 +505,8 @@ github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/tt
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kovidgoyal/imaging v1.6.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk=
github.com/kovidgoyal/imaging v1.6.4/go.mod h1:bEIgsaZmXlvFfkv/CUxr9rJook6AQkJnpB5EPosRfRY=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -420,53 +517,44 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs=
github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I=
github.com/kunwardeep/paralleltest v1.0.10 h1:wrodoaKYzS2mdNVnc4/w31YaXFtsc21PCTdvWJ/lDDs=
github.com/kunwardeep/paralleltest v1.0.10/go.mod h1:2C7s65hONVqY7Q5Efj5aLzRCNLjw2h4eMc9EcypGjcY=
github.com/kunwardeep/paralleltest v1.0.14 h1:wAkMoMeGX/kGfhQBPODT/BL8XhK23ol/nuQ3SwFaUw8=
github.com/kunwardeep/paralleltest v1.0.14/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4=
github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI=
github.com/ldez/exptostd v0.4.2 h1:l5pOzHBz8mFOlbcifTxzfyYbgEmoUqjxLFHZkjlbHXs=
github.com/ldez/exptostd v0.4.2/go.mod h1:iZBRYaUmcW5jwCR3KROEZ1KivQQp6PHXbDPk9hqJKCQ=
github.com/ldez/exptostd v0.4.3 h1:Ag1aGiq2epGePuRJhez2mzOpZ8sI9Gimcb4Sb3+pk9Y=
github.com/ldez/exptostd v0.4.3/go.mod h1:iZBRYaUmcW5jwCR3KROEZ1KivQQp6PHXbDPk9hqJKCQ=
github.com/ldez/gomoddirectives v0.6.1 h1:Z+PxGAY+217f/bSGjNZr/b2KTXcyYLgiWI6geMBN2Qc=
github.com/ldez/gomoddirectives v0.6.1/go.mod h1:cVBiu3AHR9V31em9u2kwfMKD43ayN5/XDgr+cdaFaKs=
github.com/ldez/grignotin v0.9.0 h1:MgOEmjZIVNn6p5wPaGp/0OKWyvq42KnzAt/DAb8O4Ow=
github.com/ldez/grignotin v0.9.0/go.mod h1:uaVTr0SoZ1KBii33c47O1M8Jp3OP3YDwhZCmzT9GHEk=
github.com/ldez/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk=
github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I=
github.com/ldez/usetesting v0.4.2 h1:J2WwbrFGk3wx4cZwSMiCQQ00kjGR0+tuuyW0Lqm4lwA=
github.com/ldez/usetesting v0.4.2/go.mod h1:eEs46T3PpQ+9RgN9VjpY6qWdiw2/QmfiDeWmdZdrjIQ=
github.com/ldez/usetesting v0.4.3 h1:pJpN0x3fMupdTf/IapYjnkhiY1nSTN+pox1/GyBRw3k=
github.com/ldez/usetesting v0.4.3/go.mod h1:eEs46T3PpQ+9RgN9VjpY6qWdiw2/QmfiDeWmdZdrjIQ=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A=
github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx v1.2.31 h1:/OM9oNl/fzyldpv5HKZ9m7bTywa7COUfg8gujd9nJ54=
github.com/lestrrat-go/jwx v1.2.31/go.mod h1:eQJKoRwWcLg4PfD5CFA5gIZGxhPgoPYq9pZISdxLf0c=
github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY=
github.com/lestrrat-go/jwx/v3 v3.0.1/go.mod h1:XP2WqxMOSzHSyf3pfibCcfsLqbomxakAnNqiuaH8nwo=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk=
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE=
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
github.com/manuelarte/funcorder v0.2.1 h1:7QJsw3qhljoZ5rH0xapIvjw31EcQeFbF31/7kQ/xS34=
github.com/manuelarte/funcorder v0.2.1/go.mod h1:BQQ0yW57+PF9ZpjpeJDKOffEsQbxDFKW8F8zSMe/Zd0=
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc=
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
@@ -480,35 +568,47 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.7.0 h1:JyeQ4yO5K8aZhIKf5rec56u0376h8AlKNQEmjfkjKlY=
github.com/mgechev/revive v1.7.0/go.mod h1:qZnwcNhoguE58dfi96IJeSTPeZQejNeoMQLUZGi4SW4=
github.com/mgechev/revive v1.9.0 h1:8LaA62XIKrb8lM6VsBSQ92slt/o92z5+hTw3CmrvSrM=
github.com/mgechev/revive v1.9.0/go.mod h1:LAPq3+MgOf7GcL5PlWIkHb0PT7XH4NuC2LdWymhb9Mo=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek=
github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o=
github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nunnatsa/ginkgolinter v0.19.1 h1:mjwbOlDQxZi9Cal+KfbEJTCz327OLNfwNvoZ70NJ+c4=
github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWXQCaS/JFIWcOH2s=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0=
github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
@@ -516,20 +616,24 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polyfloyd/go-errorlint v1.7.1 h1:RyLVXIbosq1gBdk/pChWA8zWYLsq9UEw7a1L5TVMCnA=
github.com/polyfloyd/go-errorlint v1.7.1/go.mod h1:aXjNb1x2TNhoLsk26iv1yl7a+zTnXPhwEMtEXukiLR8=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q=
github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -554,8 +658,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 h1:+Wl/0aFp0hpuHM3H//KMft64WQ1yX9LdJY64Qm/gFCo=
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1/go.mod h1:GJLgqsLeo4qgavUoL8JeGFNS7qcisx3awV/w9eWTmNI=
github.com/quasilyte/go-ruleguard v0.4.4 h1:53DncefIeLX3qEpjzlS1lyUmQoUEeOWPFWqaTJq9eAQ=
github.com/quasilyte/go-ruleguard v0.4.4/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE=
github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo=
@@ -575,8 +679,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryancurrah/gomodguard v1.3.5 h1:cShyguSwUEeC0jS7ylOiG/idnd1TpJ1LfHGpV3oJmPU=
github.com/ryancurrah/gomodguard v1.3.5/go.mod h1:MXlEPQRxgfPQa62O8wzK3Ozbkv9Rkqr+wKjSxTdsNJE=
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU=
github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ=
github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0=
@@ -587,12 +691,10 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxXUzwsMDBkR21cyQ=
github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8=
github.com/securego/gosec/v2 v2.22.2 h1:IXbuI7cJninj0nRpZSLCUlotsj8jGusohfONMrHoF6g=
github.com/securego/gosec/v2 v2.22.2/go.mod h1:UEBGA+dSKb+VqM6TdehR7lnQtIIMorYJ4/9CW1KVQBE=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/securego/gosec/v2 v2.22.3 h1:mRrCNmRF2NgZp4RJ8oJ6yPJ7G4x6OCiAXHd8x4trLRc=
github.com/securego/gosec/v2 v2.22.3/go.mod h1:42M9Xs0v1WseinaB/BmNGO8AVqG8vRfhC2686ACY48k=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
@@ -603,16 +705,14 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sivchari/tenv v1.12.1 h1:+E0QzjktdnExv/wwsnnyk4oqZBUfuh89YMQT1cyuvSY=
github.com/sivchari/tenv v1.12.1/go.mod h1:1LjSOUCc25snIr5n3DtGGrENhX3LuWefcplwVGC24mw=
github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM=
github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c=
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
@@ -636,7 +736,6 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -649,22 +748,30 @@ github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8=
github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8=
github.com/tdewolff/minify/v2 v2.23.5 h1:/P548KcpTkIOUvNg22zN83/GiaYSOIrbqtoue4I7kYM=
github.com/tdewolff/minify/v2 v2.23.5/go.mod h1:2RI9tiIrzJU1Z5EasXEPaI1MqobRyxKHOOgrRkq5oEw=
github.com/tdewolff/parse/v2 v2.8.1 h1:J5GSHru6o3jF1uLlEKVXkDxxcVx6yzOlIVIotK4w2po=
github.com/tdewolff/parse/v2 v2.8.1/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
github.com/tetafro/godot v1.5.0 h1:aNwfVI4I3+gdxjMgYPus9eHmoBeJIbnajOyqZYStzuw=
github.com/tetafro/godot v1.5.0/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio=
github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 h1:y4mJRFlM6fUyPhoXuFg/Yu02fg/nIPFMOY8tOqppoFg=
github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
github.com/timonwong/loggercheck v0.10.1 h1:uVZYClxQFpw55eh+PIoqM7uAOHMrhVcDoWDery9R8Lg=
github.com/timonwong/loggercheck v0.10.1/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
github.com/tomarrell/wrapcheck/v2 v2.10.0 h1:SzRCryzy4IrAH7bVGG4cK40tNUhmVmMDuJujy4XwYDg=
github.com/tomarrell/wrapcheck/v2 v2.10.0/go.mod h1:g9vNIyhb5/9TQgumxQyOEqDHsmGYcGsVMOx/xGkqdMo=
github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4=
github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU=
github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU=
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
@@ -679,8 +786,10 @@ github.com/uudashr/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U=
github.com/uudashr/iface v1.3.1/go.mod h1:4QvspiRd3JLPAEXBQ9AiZpLbJlrWWgRChOKDJEuQTdg=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/xen0n/gosmopolitan v1.2.2 h1:/p2KTnMzwRexIW8GlKawsTWOxn7UHA+jCMF/V8HHtvU=
github.com/xen0n/gosmopolitan v1.2.2/go.mod h1:7XX7Mj61uLYrj0qmeN0zi7XDon9JRAEhYQqAPLVNTeg=
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
@@ -694,19 +803,23 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo=
github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE=
go-simpler.org/musttag v0.13.0/go.mod h1:FTzIGeK6OkKlUDVpj0iQUXZLUO1Js9+mvykDQy9C5yM=
go-simpler.org/sloglint v0.9.0 h1:/40NQtjRx9txvsB/RN022KsUJU+zaaSb/9q9BSefSrE=
go-simpler.org/sloglint v0.9.0/go.mod h1:G/OrAF6uxj48sHahCzrbarVMptL2kjWTaUeC8+fOGww=
go-simpler.org/musttag v0.13.1 h1:lw2sJyu7S1X8lc8zWUAdH42y+afdcCnHhWpnkWvd6vU=
go-simpler.org/musttag v0.13.1/go.mod h1:8r450ehpMLQgvpb6sg+hV5Ur47eH6olp/3yEanfG97k=
go-simpler.org/sloglint v0.11.0 h1:JlR1X4jkbeaffiyjLtymeqmGDKBDO1ikC6rjiuFAOco=
go-simpler.org/sloglint v0.11.0/go.mod h1:CFDO8R1i77dlciGfPEPvYke2ZMx4eyGiEIWkyeW2Pvw=
go.augendre.info/fatcontext v0.8.0 h1:2dfk6CQbDGeu1YocF59Za5Pia7ULeAM6friJ3LP7lmk=
go.augendre.info/fatcontext v0.8.0/go.mod h1:oVJfMgwngMsHO+KB2MdgzcO+RvtNdiCEOlWvSFtax/s=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -731,8 +844,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -743,17 +856,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA=
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4=
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -781,8 +893,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -827,14 +939,16 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -850,8 +964,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -864,7 +978,6 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -886,7 +999,6 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -897,21 +1009,19 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -932,11 +1042,13 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -993,8 +1105,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1076,8 +1192,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -1108,11 +1224,13 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU=
mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo=
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U=
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f/go.mod h1:RSLa7mKKCNeTTMHBw5Hsy2rfJmd6O2ivt9Dw9ZqCQpQ=
mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k=
mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg=
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8=
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
+267
View File
@@ -0,0 +1,267 @@
package http
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-logger/logger"
)
type GroupListResponse struct {
Groups []string `json:"groups"`
}
// accessGetHandler lists all access rules or retrieves a specific rule.
// @Summary List access rules
// @Description Lists access rules. Can be filtered by source, path, user, or group. Can also be grouped by user or group.
// @Tags Access
// @Accept json
// @Produce json
// @Param source query string false "Source name (e.g. 'default')"
// @Param path query string false "Index path (e.g. /secret)"
// @Param user query string false "Username to filter rules for"
// @Param group query string false "Group name to filter rules for"
// @Success 200 {object} object "Varies based on query. Can be access.FrontendAccessRule (when source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access [get]
func accessGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
indexPath := r.URL.Query().Get("path")
user := r.URL.Query().Get("user")
group := r.URL.Query().Get("group")
var sourcePath string
if sourceName != "" {
index := indexing.GetIndex(sourceName)
if index == nil {
return http.StatusBadRequest, fmt.Errorf("source not found: %s", sourceName)
}
sourcePath = index.Path
}
// Return rules based on input parameters
if user != "" {
rules := store.Access.GetRulesForUser(sourcePath, user)
return renderJSON(w, r, rules)
}
if group != "" {
rules := store.Access.GetRulesForGroup(sourcePath, group)
return renderJSON(w, r, rules)
}
if sourceName == "" {
return http.StatusBadRequest, fmt.Errorf("source parameter is required for this query")
}
if indexPath != "" {
rule, _ := store.Access.GetFrontendRules(sourcePath, indexPath)
return renderJSON(w, r, rule)
}
// List all rules for the source by default
all, err := store.Access.GetAllRules(sourcePath)
if err != nil {
logger.Errorf("failed to retrieve access rules: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to retrieve access rules: %w", err)
}
return renderJSON(w, r, all)
}
// accessPostHandler adds or updates an access rule.
// @Summary Add or update access rule
// @Description Add or update an access rule for a sourcePath and indexPath.
// @Tags Access
// @Accept json
// @Produce json
// @Param source query string true "Source path prefix (e.g. mnt/storage)"
// @Param path query string true "Index path (e.g. /secret)"
// @Param body body object{allow=bool,ruleCategory=string,value=string} true "Rule details: allow (true/false), ruleCategory (user/group), value (username or groupname)"
// @Success 200 {object} map[string]string "Rule added or updated"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access [post]
func accessPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
indexPath := r.URL.Query().Get("path")
index := indexing.GetIndex(sourceName)
if index == nil {
return http.StatusBadRequest, fmt.Errorf("source not found: %s", sourceName)
}
var body struct {
Allow bool `json:"allow"`
RuleCategory string `json:"ruleCategory"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err)
}
if indexPath == "" || body.RuleCategory == "" || (body.RuleCategory != "all" && body.Value == "") {
return http.StatusBadRequest, fmt.Errorf("path, ruleCategory, and value are required, unless ruleCategory is 'all'")
}
var err error
if body.Allow {
switch body.RuleCategory {
case "user":
err = store.Access.AllowUser(index.Path, indexPath, body.Value)
case "group":
err = store.Access.AllowGroup(index.Path, indexPath, body.Value)
default:
return http.StatusBadRequest, fmt.Errorf("invalid ruleCategory: must be 'user' or 'group'")
}
} else {
switch body.RuleCategory {
case "user":
err = store.Access.DenyUser(index.Path, indexPath, body.Value)
case "group":
err = store.Access.DenyGroup(index.Path, indexPath, body.Value)
case "all":
err = store.Access.DenyAll(index.Path, indexPath)
default:
return http.StatusBadRequest, fmt.Errorf("invalid ruleCategory: must be 'user', 'group', or 'all'")
}
}
if err != nil {
logger.Errorf("failed to add or update rule: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to add or update rule: %w", err)
}
return renderJSON(w, r, map[string]string{"message": "rule added or updated"})
}
// accessDeleteHandler deletes a single user or group from a rule.
// @Summary Delete access rule entry
// @Description Delete a user or group from an allow or deny list for a sourcePath and indexPath.
// @Tags Access
// @Accept json
// @Produce json
// @Param source query string true "Source path prefix (e.g. mnt/storage)"
// @Param path query string true "Index path (e.g. /secret)"
// @Param ruleType query string true "Rule type (allow or deny)"
// @Param ruleCategory query string true "Rule category (user or group)"
// @Param value query string true "Username or groupname to remove"
// @Success 200 {object} map[string]string "Rule entry deleted"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access [delete]
func accessDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
indexPath := r.URL.Query().Get("path")
index := indexing.GetIndex(sourceName)
if index == nil {
return 500, fmt.Errorf("source not found: %s", sourceName)
}
ruleType := r.URL.Query().Get("ruleType")
ruleCategory := r.URL.Query().Get("ruleCategory")
value := r.URL.Query().Get("value")
allow := ruleType == "allow"
if indexPath == "" || ruleCategory == "" || (ruleCategory != "all" && value == "") {
return http.StatusBadRequest, fmt.Errorf("path, ruleCategory, and value are required, unless ruleCategory is 'all'")
}
var found bool
var err error
if allow {
switch ruleCategory {
case "user":
found, err = store.Access.RemoveAllowUser(index.Path, indexPath, value)
case "group":
found, err = store.Access.RemoveAllowGroup(index.Path, indexPath, value)
}
} else {
switch ruleCategory {
case "user":
found, err = store.Access.RemoveDenyUser(index.Path, indexPath, value)
case "group":
found, err = store.Access.RemoveDenyGroup(index.Path, indexPath, value)
case "all":
found, err = store.Access.RemoveDenyAll(index.Path, indexPath)
}
}
if !found {
if err != nil {
logger.Errorf("failed to remove rule entry: %v", err)
}
return http.StatusNotFound, fmt.Errorf("entry not found in rule")
}
return renderJSON(w, r, map[string]string{"message": "rule entry deleted"})
}
// groupGetHandler retrieves all groups or groups for a specific user.
// @Summary Get all groups or groups for a user
// @Description Returns a list of all groups or the groups for a specific user.
// @Tags Access
// @Accept json
// @Produce json
// @Param user query string false "User name"
// @Success 200 {object} GroupListResponse "Object containing a list of groups"
// @Failure 403 {object} map[string]string "Forbidden"
// @Router /api/access/groups [get]
func groupGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
user := r.URL.Query().Get("user")
if user != "" {
groups := store.Access.GetUserGroups(user)
return renderJSON(w, r, &GroupListResponse{Groups: groups})
}
groups := store.Access.GetAllGroups()
return renderJSON(w, r, &GroupListResponse{Groups: groups})
}
// groupPostHandler adds a user to a group.
// @Summary Add a user to a group
// @Description Adds a user to a group.
// @Tags Access
// @Accept json
// @Produce json
// @Param group query string true "Group name"
// @Param user query string true "User name"
// @Success 200 "User added to group successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/access/group [post]
func groupPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
group := r.URL.Query().Get("group")
user := r.URL.Query().Get("user")
err := store.Access.AddUserToGroup(group, user)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
// groupDeleteHandler removes a user from a group.
// @Summary Remove a user from a group
// @Description Removes a user from a group.
// @Tags Access
// @Accept json
// @Produce json
// @Param group query string true "Group name"
// @Param user query string true "User name"
// @Success 200 "User removed from group successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/access/group [delete]
func groupDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
group := r.URL.Query().Get("group")
user := r.URL.Query().Get("user")
err := store.Access.RemoveUserFromGroup(group, user)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
+4 -4
View File
@@ -20,12 +20,12 @@ import (
// @Param name query string true "Name of the API key"
// @Param days query string true "Duration of the API key in days"
// @Param permissions query string true "Permissions for the API key (comma-separated)"
// @Success 200 {object} HttpResponse "Token created successfully, resoponse contains json object with token key"
// @Success 200 {object} HttpResponse "Token created successfully, response contains json object with token key"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 409 {object} map[string]string "Conflict"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/createApiKey [post]
// @Router /api/auth/token [put]
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
durationStr := r.URL.Query().Get("days")
@@ -85,7 +85,7 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Success 200 {object} HttpResponse "API key deleted successfully"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/deleteApiKey [delete]
// @Router /api/auth/token [delete]
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
if !d.user.Permissions.Api {
@@ -127,7 +127,7 @@ type AuthTokenMin struct {
// @Success 200 {object} AuthTokenMin "List of API keys or specific key details"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/listApiKeys [get]
// @Router /api/auth/tokens [get]
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
key := r.URL.Query().Get("name")
if !d.user.Permissions.Api {
+67 -414
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
libError "errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
@@ -12,17 +11,16 @@ import (
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"github.com/lestrrat-go/jwx/v3/jwk"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"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/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// first checks for cookie
@@ -53,7 +51,6 @@ func extractToken(r *http.Request) (string, error) {
// Check for Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
hasToken = true
// Split the header to get "Bearer {token}"
parts := strings.Split(authHeader, " ")
@@ -79,17 +76,12 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
return nil, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
hashpass, err := users.HashPwd(proxyUser)
if err != nil {
return nil, err
}
err = storage.CreateUser(users.User{
user := users.User{
LoginMethod: users.LoginMethodProxy,
Username: proxyUser,
NonAdminEditable: users.NonAdminEditable{
Password: hashpass, // hashed password that can't actually be used
},
}, false)
}
settings.ApplyUserDefaults(&user)
err = storage.CreateUser(user)
if err != nil {
return nil, err
}
@@ -102,7 +94,7 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
}
}
if data.user.LoginMethod != users.LoginMethodProxy {
return nil, fmt.Errorf("user %s is not allowed to login with proxy authentication", proxyUser)
return nil, errors.ErrWrongLoginMethod
}
return data.user, nil
}
@@ -117,48 +109,54 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/login [post]
func loginHandler(w http.ResponseWriter, r *http.Request) {
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
user, err := setupProxyUser(r, &requestContext{}, proxyUser)
if err != nil {
http.Error(w, err.Error(), 403)
return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
http.Error(w, http.StatusText(status), status)
}
return
func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
passwordUser := d.user.LoginMethod == users.LoginMethodPassword
enforcedOtp := config.Auth.Methods.PasswordAuth.EnforcedOtp
missingOtp := d.user.TOTPSecret == ""
if passwordUser && enforcedOtp && missingOtp {
return http.StatusForbidden, errors.ErrNoTotpConfigured
}
if !config.Auth.Methods.PasswordAuth.Enabled {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
return printToken(w, r, d.user) // Pass the data object
}
// logoutHandler handles user logout
// @Summary User Logout
// @Description Returns a logout URL for the frontend to redirect to.
// @Tags Auth
// @Produce json
// @Param auth query string false "JWT token"
// @Success 200 {object} map[string]string "{"logoutUrl": "http://..."}"
// @Router /api/auth/logout [post]
func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
defer auth.RevokeAPIKey(d.token)
logoutUrl := fmt.Sprintf("%vlogin", config.Server.BaseURL) // Default fallback
if d.user.LoginMethod == users.LoginMethodProxy {
proxyRedirectUrl := config.Auth.Methods.ProxyAuth.LogoutRedirectUrl
if proxyRedirectUrl != "" {
logoutUrl = proxyRedirectUrl
}
} else if d.user.LoginMethod == users.LoginMethodOidc {
oidcRedirectUrl := config.Auth.Methods.OidcAuth.LogoutRedirectUrl
if oidcRedirectUrl != "" {
logoutUrl = oidcRedirectUrl
}
}
// currently only supports user/pass
// Get the authentication method from the settings
auther, err := store.Auth.Get("password")
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
if logoutUrl == "" {
logger.Debug("no logout url found, using default")
logoutUrl = fmt.Sprintf("%vlogin", config.Server.BaseURL)
}
response := map[string]string{
"logoutUrl": logoutUrl,
}
// Authenticate the user based on the request
user, err := auther.Auth(r, store.Users)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(response)
if err != nil {
logger.Debug(err.Error())
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
if user.LoginMethod != users.LoginMethodPassword {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
return http.StatusInternalServerError, err
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
http.Error(w, http.StatusText(status), status)
}
return http.StatusOK, nil
}
type signupBody struct {
@@ -179,41 +177,33 @@ type signupBody struct {
// @Failure 409 {object} map[string]string "Conflict - user already exists"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/signup [post]
func signupHandler(w http.ResponseWriter, r *http.Request) {
func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !settings.Config.Auth.Methods.PasswordAuth.Signup {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
return http.StatusMethodNotAllowed, fmt.Errorf("signup is disabled")
}
if r.Body == nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
return http.StatusBadRequest, fmt.Errorf("no post body provided")
}
info := &signupBody{}
err := json.NewDecoder(r.Body).Decode(info)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
return http.StatusBadRequest, fmt.Errorf("invalid request body: %v", err)
}
if info.Password == "" || info.Username == "" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
user := users.User{
Username: info.Username,
NonAdminEditable: users.NonAdminEditable{
Password: info.Password,
},
LoginMethod: users.LoginMethodPassword,
}
user := &users.User{}
settings.ApplyUserDefaults(user)
user.Username = info.Username
user.Password = info.Password
err = store.Users.Save(user, true)
if err == errors.ErrExist {
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
return
} else if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
user.Permissions = settings.Config.UserDefaults.Permissions
err = storage.CreateUser(user)
if err != nil {
logger.Debug(err.Error())
w.WriteHeader(http.StatusConflict)
return http.StatusConflict, fmt.Errorf("user already exists")
}
return 201, nil
}
// renewHandler refreshes the authentication token for a logged-in user.
@@ -237,11 +227,11 @@ func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int,
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
}
return http.StatusInternalServerError, err
return 401, errors.ErrUnauthorized
}
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte(signed.Key)); err != nil {
return http.StatusInternalServerError, err
return 401, errors.ErrUnauthorized
}
return 0, nil
}
@@ -308,340 +298,3 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
}
return 200, nil
}
// userInfo struct to hold user claims from either UserInfo or ID token
type userInfo struct {
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Sub string `json:"sub"`
Phone string `json:"phone_number"`
}
// getKeySet fetches the JWKS from the given URL, using the provided cache.
func getKeySet(jwksUrl string) (jwk.Set, error) {
// Check cache first
value := cache.JwtCache.Get(jwksUrl)
jwks, ok := value.(jwk.Set)
if ok {
return jwks, nil
}
logger.Debug("JWKS not found in cache for, fetching...")
// Fetch the JWKS from the URL
resp, err := http.Get(jwksUrl)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS from %s: %v", jwksUrl, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to fetch JWKS from %s: received status code %d with body: %s", jwksUrl, resp.StatusCode, string(bodyBytes))
}
// Decode the JWKS using jwk.ParseReader
jwks, err = jwk.ParseReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to decode JWKS: %v", err)
}
// Store in cache. Assuming your cache handles expiration (e.g., 1 hour).
cache.JwtCache.Set(jwksUrl, jwks) // Store the jwk.Set
return jwks, nil // Return the jwk.Set after type assertion
}
// oidcCallbackHandler handles the OIDC callback after the user authenticates with the provider.
// It exchanges the authorization code for tokens, then attempts to verify the ID token using JWKS
// if configured, falling back to the UserInfo endpoint if necessary.
func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
code := r.URL.Query().Get("code")
// state := r.URL.Query().Get("state") // You might want to validate the state parameter for CSRF protection
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
redirectURI := fmt.Sprintf("%s/api/auth/oidc/callback", origin)
// Step 1: Exchange the code for tokens
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("client_id", config.Auth.Methods.OidcAuth.ClientID)
data.Set("client_secret", config.Auth.Methods.OidcAuth.ClientSecret)
data.Set("redirect_uri", redirectURI)
req, _ := http.NewRequest("POST", config.Auth.Methods.OidcAuth.TokenUrl, strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{} // Consider using a client with a timeout
resp, err := client.Do(req)
if err != nil {
logger.Debug(fmt.Sprintf("failed to send token request: %v", err))
return 500, fmt.Errorf("token request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
logger.Debug(fmt.Sprintf("failed to fetch token: %v %v", resp.StatusCode, string(body)))
return 500, fmt.Errorf("failed to fetch token: %v %v", resp.StatusCode, string(body))
}
// Decode the token response, expecting access_token and potentially id_token
var tokenResp struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"` // OIDC providers include id_token here
}
if err = json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
logger.Debug(fmt.Sprintf("failed to parse token response: %v", err))
return 500, fmt.Errorf("failed to parse token response: %v", err)
}
var userdata userInfo // This will hold the user claims, using the original struct
useUserInfoEndpoint := true // Flag to determine if we need to call the UserInfo endpoint
// Step 2: Process ID Token if available and JWKS URL is configured
if tokenResp.IDToken != "" && config.Auth.Methods.OidcAuth.JwksUrl != "" {
logger.Debug(fmt.Sprintf("ID token received `%v` attempting to verify using JWKS URL", tokenResp.IDToken))
// getKeySet now returns jwk.Set (interface)
var jwks jwk.Set
jwks, err = getKeySet(config.Auth.Methods.OidcAuth.JwksUrl)
if err != nil {
// Log the error but don't fail yet, try UserInfo endpoint as a fallback
logger.Warning("Failed to fetch or decode JWKS: %v. Falling back to UserInfo endpoint. Ensure your OidcAuth.JwksUrl is correct.")
} else {
var token *jwt.Token
// Parse the ID token. We need to provide a key function to `jwt.Parse`
// that looks up the key in the JWKS based on the token's `kid`.
token, err = jwt.Parse(tokenResp.IDToken, func(token *jwt.Token) (interface{}, error) {
switch alg := token.Method.Alg(); alg {
case "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA":
// accepted
default:
logger.Debug(fmt.Sprintf("unsupported signing method recieved `%v`", alg))
return nil, fmt.Errorf("unsupported signing method recieved: %v", alg)
}
// Find the key in the JWKS that matches the token's kid (Key ID)
keyID, ok := token.Header["kid"].(string)
if !ok {
logger.Debug("kid header not found in ID token")
return nil, fmt.Errorf("kid header not found in ID token")
}
// Use the jwk.Set interface to find the key
// LookupKeyID returns a slice of jwk.Key and a boolean indicating if any keys were found.
key, found := jwks.LookupKeyID(keyID) // Called on the jwk.Set interface
if !found {
logger.Debug(fmt.Sprintf("key with kid %s not found in JWKS", keyID))
return nil, fmt.Errorf("key with kid %s not found in JWKS", keyID)
}
// Return the public key from the first matching JWK
// In a real scenario, you might need more sophisticated key selection logic.
// The jwk.Key interface has a PublicKey() method to get the public key.
publicKey, publicKeyErr := key.PublicKey()
if publicKeyErr != nil {
logger.Debug(fmt.Sprintf("failed to get public key from JWK: %v", publicKeyErr))
return nil, fmt.Errorf("failed to get public key from JWK: %v", err)
}
return publicKey, nil
})
if err != nil {
// Log the verification error but fall back to UserInfo endpoint
logger.Error(fmt.Sprintf("failed to parse or verify ID token: %v. Falling back to UserInfo endpoint.", err))
} else if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
logger.Debug("ID token verified successfully, claims extracted.")
// --- IMPORTANT OIDC VALIDATION ---
// In a production application, you MUST perform additional validations on the ID token claims:
// - Issuer (iss): Must match the expected issuer URL.
// - Audience (aud): Must contain your client ID.
// - Expiration Time (exp): Token must not be expired (jwt.Parse handles this if clock skew is accounted for).
// - Issued At Time (iat): Token must not be issued in the future.
// - Nonce (nonce): If a nonce was sent in the authorization request, it must match the 'nonce' claim in the ID token.
// - Authorized Party (azp): If 'aud' has multiple values, 'azp' must be present and equal to your client ID.
// - at_hash and c_hash: If the access token or authorization code were issued, validate their hashes if present in the ID token.
// A dedicated OIDC library handles these validations correctly and securely.
// This simplified example only verifies the signature and basic validity.
// --- END IMPORTANT OIDC VALIDATION ---
// Populate the userInfo struct from the ID token claims (map)
if name, ok := claims["name"].(string); ok {
userdata.Name = name
}
if preferredUsername, ok := claims["preferred_username"].(string); ok {
userdata.PreferredUsername = preferredUsername
}
if email, ok := claims["email"].(string); ok {
userdata.Email = email
}
if sub, ok := claims["sub"].(string); ok {
userdata.Sub = sub
}
if phone, ok := claims["phone_number"].(string); ok {
userdata.Phone = phone
}
useUserInfoEndpoint = false // Successfully processed ID token, no need for UserInfo endpoint
} else {
// ID token signature verification failed or token is invalid for other reasons
logger.Error("id token signature verification failed or token is invalid. Falling back to UserInfo endpoint.")
}
}
}
// Step 3: Use the access token to fetch user info from the UserInfo URL if needed
// This step is skipped if the ID token was successfully processed and verified.
if useUserInfoEndpoint {
userInfoURL := config.Auth.Methods.OidcAuth.UserInfoUrl
req, _ = http.NewRequest("GET", userInfoURL, nil)
req.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
resp, err = client.Do(req)
if err != nil {
return 500, fmt.Errorf("failed to fetch user info: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return 500, fmt.Errorf("failed to fetch user info: received status code %d with body: %s", resp.StatusCode, string(bodyBytes))
}
// Decode the UserInfo response directly into the original userdata struct
if err = json.NewDecoder(resp.Body).Decode(&userdata); err != nil {
return 500, fmt.Errorf("failed to parse user info: %v", err)
}
}
// Now userdata contains claims from either the verified ID token or the UserInfo endpoint.
// Proceed with logging in the user based on the extracted claims.
return loginWithOidcUser(w, r, userdata)
}
// oidcLoginHandler redirects the user to the OIDC provider's authorization endpoint.
// This function remains largely the same, but includes the 'fb_redirect' parameter
// to redirect the user back to the original page after successful login.
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Auth.Methods.OidcAuth.Enabled {
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
// Generate a nonce for CSRF protection (optional but recommended)
// You should store this nonce and validate it in the callback handler.
nonce := utils.InsecureRandomIdentifier(16)
// For state parameter, you might include the nonce and the original redirect URL
state := fmt.Sprintf("%s:%s", nonce, r.URL.Query().Get("fb_redirect")) // Example state parameter structure
redirectURI := fmt.Sprintf("%s/api/auth/oidc/callback", origin)
// Construct the authorization URL
authURL := fmt.Sprintf("%s?client_id=%s&response_type=code&scope=%s&redirect_uri=%s&state=%s",
config.Auth.Methods.OidcAuth.AuthorizationUrl,
url.QueryEscape(config.Auth.Methods.OidcAuth.ClientID),
url.QueryEscape(config.Auth.Methods.OidcAuth.Scopes),
url.QueryEscape(redirectURI),
url.QueryEscape(state), // Use the state parameter
)
// Redirect the user to the OIDC provider
http.Redirect(w, r, authURL, http.StatusFound)
return 0, nil // Indicate that the request has been handled by the redirect
}
// OIDC authentication is not enabled
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
}
// loginWithOidcUser extracts the username from the user claims (userInfo)
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, userInfo userInfo) (int, error) {
username := userInfo.PreferredUsername
switch config.Auth.Methods.OidcAuth.UserIdentifier {
case "email":
username = userInfo.Email
case "username":
username = userInfo.PreferredUsername
}
// Retrieve the user from the store and store it in the context
user, err := store.Users.Get(username)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
hashpass := ""
hashpass, err = users.HashPwd(username) // hashed password that can't actually be used, gets double hashed
if err != nil {
return http.StatusInternalServerError, err
}
err = storage.CreateUser(users.User{
LoginMethod: users.LoginMethodOidc,
Username: username,
NonAdminEditable: users.NonAdminEditable{
Password: hashpass, // hashed password that can't actually be used
},
}, false)
if err != nil {
return http.StatusInternalServerError, err
}
user, err = store.Users.Get(username)
if err != nil {
return http.StatusInternalServerError, err
}
}
if user.LoginMethod != users.LoginMethodOidc {
return http.StatusForbidden, fmt.Errorf("user %s is not allowed to login with OIDC", username)
}
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err != nil {
// Handle potential errors during token generation
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, fmt.Errorf("token name conflict: %v", err)
}
return http.StatusInternalServerError, fmt.Errorf("failed to generate authentication token for user %s: %v", username, err)
}
// Set the authentication token as an HTTP cookie
cookie := &http.Cookie{
Name: "auth", // The name of your auth cookie
Value: signed.Key, // The generated token value
Domain: strings.Split(r.Host, ":")[0], // Set domain to the host without port
Path: "/", // Make the cookie available to the whole site
// Secure: true, // Recommended: Set to true in production with HTTPS
// HttpOnly: true, // Recommended: Set to true to prevent client-side script access
Expires: time.Now().Add(time.Hour * time.Duration(config.Auth.TokenExpirationHours)), // Set cookie expiration
}
http.SetCookie(w, cookie)
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
setUserInResponseWriter(w, user)
// Redirect the user to the page they were trying to access before login,
// or to the root ("/") if no specific redirect was requested.
// The 'fb_redirect' parameter is extracted from the 'state' parameter for security.
state := r.URL.Query().Get("state")
fbRedirect := "/" // Default redirect path
if state != "" {
// Assuming state is in the format "nonce:fb_redirect"
parts := strings.SplitN(state, ":", 2)
if len(parts) == 2 {
// TODO: Validate the nonce part against the stored nonce for CSRF protection
// For this example, we'll just extract the redirect part
extractedRedirect := parts[1]
if extractedRedirect != "" {
fbRedirect = extractedRedirect
}
}
}
http.Redirect(w, r, fbRedirect, http.StatusFound)
// Return 0 to indicate that the response has been handled by the redirect
return 0, nil
}
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"io"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/events"
"github.com/gtsteffaniak/go-logger/logger"
)
type messenger struct {
@@ -40,7 +40,7 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
f, ok := w.(http.Flusher)
if !ok {
logger.Debug(fmt.Sprintf("error: ResponseWriter does not support Flusher. User: %s, SessionId: %s", username, sessionId))
logger.Debugf("error: ResponseWriter does not support Flusher. User: %s, SessionId: %s", username, sessionId)
return http.StatusInternalServerError, fmt.Errorf("streaming not supported")
}
@@ -63,7 +63,7 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
return http.StatusOK, nil
case <-clientGone:
logger.Debug(fmt.Sprintf("client disconnected. user: %s, SessionId: %s", username, sessionId))
logger.Debugf("client disconnected. user: %s, SessionId: %s", username, sessionId)
return http.StatusOK, nil
case msg := <-events.BroadcastChan:
+13 -3
View File
@@ -1,21 +1,31 @@
package http
import (
"fmt"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-logger/logger"
)
// getJobsHandler returns job info for the user.
// @Summary Get jobs info
// @Description Returns job info for the user.
// @Tags Jobs
// @Accept json
// @Produce json
// @Param action path string true "Job action"
// @Param target path string true "Job target"
// @Success 200 {object} map[string]indexing.ReducedIndex "Job info"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/jobs/{action}/{target} [get]
func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sources := settings.GetSources(d.user)
reducedIndexes := map[string]indexing.ReducedIndex{}
for _, source := range sources {
reducedIndex, err := indexing.GetIndexInfo(source)
if err != nil {
logger.Debug(fmt.Sprintf("error getting index info: %v", err))
logger.Debugf("error getting index info: %v", err)
continue
}
reducedIndexes[source] = reducedIndex
+155 -37
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
@@ -11,10 +12,10 @@ import (
"text/template"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"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/storage/bolt"
"github.com/gtsteffaniak/go-logger/logger"
// http-swagger middleware
)
@@ -23,9 +24,6 @@ import (
//go:embed embed/*
var assets embed.FS
// Boolean flag to determine whether to use the embedded FS or not
var embeddedFS = os.Getenv("FILEBROWSER_NO_EMBEDED") != "true"
// Custom dirFS to handle both embedded and non-embedded file systems
type dirFS struct {
http.Dir
@@ -37,16 +35,27 @@ func (d dirFS) Open(name string) (fs.File, error) {
}
var (
store *storage.Storage
store *bolt.BoltStore
config *settings.Settings
assetFs fs.FS
)
func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete chan struct{}) {
func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete chan struct{}) {
store = storage
config = &settings.Config
var err error
// Dev mode enables development features like template hot-reloading
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
_, err := os.Stat("http/dist")
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
var embeddedFS bool
if devMode {
embeddedFS = false
} else {
embeddedFS = os.IsNotExist(err)
}
// --- START: ADD THIS DECRYPTION LOGIC ---
if embeddedFS {
// Embedded mode: Serve files from the embedded assets
assetFs, err = fs.Sub(assets, "embed")
@@ -57,13 +66,27 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
assetFs = dirFS{Dir: http.Dir("http/dist")}
}
// In development mode, we want to reload the templates on each request.
// In production (embedded), we parse them once.
templates := template.New("").Funcs(template.FuncMap{
"marshal": func(v interface{}) (string, error) {
a, err := json.Marshal(v)
return string(a), err
},
})
if !devMode {
templates = template.Must(templates.ParseFS(assetFs, "public/index.html"))
}
templateRenderer = &TemplateRenderer{
templates: template.Must(template.ParseFS(assetFs, "public/index.html")),
templates: templates,
devMode: devMode,
}
router := http.NewServeMux()
// API group routing
api := http.NewServeMux()
// Public group routing (new structure)
publicRoutes := http.NewServeMux()
// User routes
api.HandleFunc("GET /users", withUser(userGetHandler))
@@ -72,8 +95,11 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
// Auth routes
api.HandleFunc("POST /auth/login", loginHandler)
api.HandleFunc("GET /auth/signup", signupHandler)
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
api.HandleFunc("POST /auth/logout", withUser(logoutHandler))
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(generateOTPHandler))
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(verifyOTPHandler))
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
api.HandleFunc("PUT /auth/token", withUser(createApiKeyHandler))
api.HandleFunc("GET /auth/token", withUser(createApiKeyHandler))
@@ -94,19 +120,27 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
api.HandleFunc("GET /inspectIndex", inspectIndex)
api.HandleFunc("GET /mockData", mockData)
}
// Share routes
api.HandleFunc("GET /shares", withPermShare(shareListHandler))
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
// Public routes
api.HandleFunc("GET /public/publicUser", publicUserGetHandler)
api.HandleFunc("GET /public/dl", withHashFile(publicRawHandler))
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
api.HandleFunc("GET /public/preview", withHashFile(publicPreviewHandler))
// Access routes
api.HandleFunc("GET /access", withAdmin(accessGetHandler))
api.HandleFunc("POST /access", withAdmin(accessPostHandler))
api.HandleFunc("DELETE /access", withAdmin(accessDeleteHandler))
api.HandleFunc("GET /access/groups", withAdmin(groupGetHandler))
api.HandleFunc("POST /access/group", withAdmin(groupPostHandler))
api.HandleFunc("DELETE /access/group", withAdmin(groupDeleteHandler))
// Create API sub-router for public API endpoints
publicAPI := http.NewServeMux()
// NEW PUBLIC ROUTES - All publicly accessible endpoints
// Share management routes (require permission but are publicly accessible)
publicRoutes.HandleFunc("GET /shares", withPermShare(shareListHandler))
publicRoutes.HandleFunc("GET /share/direct", withPermShare(shareDirectDownloadHandler))
publicRoutes.HandleFunc("GET /share", withPermShare(shareGetHandler))
publicRoutes.HandleFunc("POST /share", withPermShare(sharePostHandler))
publicRoutes.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
// Public API routes (hash-based authentication)
publicAPI.HandleFunc("GET /raw", withHashFile(publicRawHandler))
publicAPI.HandleFunc("GET /preview", withHashFile(publicPreviewHandler))
publicAPI.HandleFunc("GET /resources", withHashFile(publicShareHandler))
publicAPI.HandleFunc("GET /users", withUser(userGetHandler))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
@@ -115,28 +149,98 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
d.ctx = ctx // Pass the parent context to ensure proper shutdown
return sseHandler(w, r, d)
}))
// Job routes
api.HandleFunc("GET /jobs/{action}/{target}", withUser(getJobsHandler))
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
publicAPI.HandleFunc("GET /onlyoffice/config", withHashFile(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
publicAPI.HandleFunc("POST /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
api.HandleFunc("GET /onlyoffice/getToken", withUser(onlyofficeGetTokenHandler))
publicAPI.HandleFunc("GET /onlyoffice/getToken", withHashFile(onlyofficeGetTokenHandler))
api.HandleFunc("GET /search", withUser(searchHandler))
// Share routes (DEPRECATED - maintain for backwards compatibility)
// These will redirect to the new /public/shares endpoints
api.HandleFunc("GET /shares", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/shares"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
})
api.HandleFunc("GET /share", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/share"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
})
api.HandleFunc("POST /share", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Server.BaseURL+"public/share", http.StatusPermanentRedirect)
})
api.HandleFunc("DELETE /share", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/share"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusPermanentRedirect)
})
api.HandleFunc("GET /public/raw", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/api/raw"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
})
api.HandleFunc("GET /public/share", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/api/shared"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
})
api.HandleFunc("GET /public/preview", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/api/preview"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
})
// Mount the public API sub-router
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicAPI))
// Mount the route groups
apiPath := config.Server.BaseURL + "api"
publicPath := config.Server.BaseURL + "public"
router.Handle(apiPath+"/", http.StripPrefix(apiPath, api))
router.Handle(publicPath+"/", http.StripPrefix(publicPath, publicRoutes))
// Frontend share route redirect (DEPRECATED - maintain for backwards compatibility)
router.HandleFunc(fmt.Sprintf("GET %vshare/", config.Server.BaseURL), withOrWithoutUser(redirectToShare))
// New frontend share route handler - handle share page and any subpaths
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
// Static and index file handlers
router.HandleFunc(fmt.Sprintf("GET %vstatic/", config.Server.BaseURL), staticFilesHandler)
router.HandleFunc(config.Server.BaseURL, indexHandler)
staticPrefix := config.Server.BaseURL + "static/"
router.Handle(staticPrefix, http.StripPrefix(staticPrefix, http.HandlerFunc(staticFilesHandler)))
publicRoutes.Handle("GET /static/", http.StripPrefix("/static/", http.HandlerFunc(staticFilesHandler)))
// health
router.HandleFunc(config.Server.BaseURL, withOrWithoutUser(indexHandler))
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
// Swagger
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL), withUser(swaggerHandler))
// redirect to baseUrl if not root
if config.Server.BaseURL != "/" {
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Server.BaseURL, http.StatusMovedPermanently)
})
}
var scheme string
port := ""
srv := &http.Server{
@@ -149,7 +253,7 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
// Load the TLS certificate and key
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
if err != nil {
logger.Fatal(fmt.Sprintf("Could not load certificate: %v", err))
logger.Fatalf("Could not load certificate: %v", err)
}
// Create a custom TLS configuration
@@ -166,15 +270,15 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
logger.Infof("Running at : %s", fullURL)
// Create a TLS listener and serve
listener, err := tls.Listen("tcp", srv.Addr, tlsConfig)
if err != nil {
logger.Fatal(fmt.Sprintf("Could not start TLS server: %v", err))
logger.Fatalf("Could not start TLS server: %v", err)
}
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatal(fmt.Sprintf("Server error: %v", err))
logger.Fatalf("Server error: %v", err)
}
} else {
// Set HTTP scheme and the default port for HTTP
@@ -185,11 +289,11 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
logger.Infof("Running at : %s", fullURL)
// Start HTTP server
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal(fmt.Sprintf("Server error: %v", err))
logger.Fatalf("Server error: %v", err)
}
}
}()
@@ -198,11 +302,25 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
<-ctx.Done()
logger.Info("Shutting down HTTP server...")
// Persist in-memory state before shutting down the HTTP server
if store != nil {
if store.Share != nil {
if err := store.Share.Flush(); err != nil {
logger.Errorf("Failed to flush share storage: %v", err)
}
}
if store.Access != nil {
if err := store.Access.Flush(); err != nil {
logger.Errorf("Failed to flush access storage: %v", err)
}
}
}
// Graceful shutdown with a timeout - 30 seconds, in case downloads are happening
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error(fmt.Sprintf("HTTP server forced to shut down: %v", err))
logger.Errorf("HTTP server forced to shut down: %v", err)
} else {
logger.Info("HTTP server shut down gracefully.")
}
+258 -43
View File
@@ -6,26 +6,30 @@ import (
"encoding/json"
"fmt"
"net/http"
"runtime"
"slices"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"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/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
type requestContext struct {
user *users.User
raw interface{}
fileInfo iteminfo.ExtendedFileInfo
path string
token string
share *share.Link
ctx context.Context
user *users.User
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
shareValid bool
ctx context.Context
MaxBandwidth int
}
type HttpResponse struct {
@@ -41,15 +45,22 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
// Middleware to handle file requests by hash and pass it to the handler
func withHashFileHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
path := r.URL.Query().Get("path")
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
// Retrieve the user (using the public user by default)
data.user = &users.PublicUser
path := r.URL.Query().Get("path")
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share not found")
data.share = &share.Link{}
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
if link.DisableAnonymous && data.user.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("share is not available to anonymous users")
}
if len(link.AllowedUsernames) > 0 {
if !slices.Contains(link.AllowedUsernames, data.user.Username) {
return http.StatusForbidden, fmt.Errorf("share is not available to this user")
}
}
data.share = link
// Authenticate the share request if needed
@@ -60,32 +71,41 @@ func withHashFileHelper(fn handleFunc) handleFunc {
return status, fmt.Errorf("could not authenticate share request")
}
}
data.path = strings.TrimSuffix(link.Path, "/") + "/" + strings.TrimPrefix(path, "/")
if path == "" || path == "/" {
data.path = link.Path
}
source, ok := config.Server.SourceMap[link.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
if source.Config.Private {
return http.StatusForbidden, fmt.Errorf("the target source is private")
}
// Get file information with options
getContent := r.URL.Query().Get("content") == "true"
if link.DisableFileViewer || link.Downloads >= link.DownloadsLimit {
getContent = false
}
file, err := FileInfoFasterFunc(iteminfo.FileOptions{
Path: data.path,
Source: source.Name,
Modify: false,
Expand: true,
Path: utils.JoinPathAsUnix(link.Path, path),
Source: link.Source,
Modify: false,
Expand: true,
Content: getContent,
})
file.Token = link.Token
if err != nil {
logger.Error(fmt.Sprintf("error fetching file info for share. hash=%v path=%v error=%v", hash, data.path, err))
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
}
if getContent && file.Content != "" {
link.Mu.Lock()
link.Downloads++
link.Mu.Unlock()
}
file.Path = "/" + strings.TrimPrefix(strings.TrimPrefix(file.Path, link.Path), "/")
// Set the file info in the `data` object
data.raw = file
data.fileInfo = file
// Call the next handler with the data
return fn(w, r, data)
}
})
}
// Middleware to ensure the user is an admin
@@ -99,6 +119,128 @@ func withAdminHelper(fn handleFunc) handleFunc {
})
}
// withOrWithoutUserHelper is a middleware that tries to authenticate a user.
// If authentication is successful, the user is added to the request context.
// If authentication fails, the request continues without a user.
func withOrWithoutUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
var link *share.Link
var isShareRequest bool
var shareHash string
hash := r.URL.Query().Get("hash")
if hash != "" {
// Get the file link by hash
isShareRequest = true
shareHash = hash
link, _ = store.Share.GetByHash(hash)
} else {
prefix := config.Server.BaseURL + "public/share/"
reconstructed := config.Server.BaseURL + "public" + r.URL.Path
if strings.HasPrefix(reconstructed, prefix) {
remaining := strings.TrimPrefix(reconstructed, prefix)
if remaining != "" {
if idx := strings.IndexByte(remaining, '/'); idx >= 0 {
remaining = remaining[:idx]
}
if remaining != "" {
isShareRequest = true
shareHash = remaining
var err error
link, err = store.Share.GetByHash(remaining)
if err != nil {
logger.Debugf("error getting share by hash: %v", err)
}
}
}
}
}
// If this is a share request, always create a share context (even if invalid)
if isShareRequest {
if link != nil {
data.share = link
data.shareValid = true
} else {
// Create an empty share with just the hash for invalid shares
data.share = &share.Link{Hash: shareHash}
data.shareValid = false
}
}
// Try to authenticate user first
status, err := withUserHelper(nil)(w, r, data)
if err == nil && status < 400 {
if data.share != nil && data.shareValid {
if data.user != nil {
data.user.CustomTheme = data.share.ShareTheme
}
}
return fn(w, r, data)
}
// Only fall back to anonymous if authentication actually failed
if status == http.StatusUnauthorized || status == http.StatusForbidden {
data.user = &users.AnonymousUser
// If user authentication failed, call the handler without user context
// Clear any user data that might have been partially set
data.token = ""
if data.share != nil && data.shareValid {
data.user.CustomTheme = data.share.ShareTheme
}
// Call the handler function without user context
return fn(w, r, data)
}
return status, fmt.Errorf("could not authenticate request")
}
}
func withoutUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// This middleware is used when no user authentication is required
// Call the actual handler function with the updated context
return fn(w, r, data)
}
}
// allow user without OTP to pass
func userWithoutOTPhelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// This middleware is used when no user authentication is required
// Call the actual handler function with the updated context
username := r.URL.Query().Get("username")
password := r.Header.Get("X-Password")
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
user, err := setupProxyUser(r, &requestContext{}, proxyUser)
if err != nil {
return 401, errors.ErrUnauthorized
}
d.user = user
} else if username == "" || password == "" {
return withUserHelper(fn)(w, r, d)
} else {
if !config.Auth.Methods.PasswordAuth.Enabled {
return 401, errors.ErrUnauthorized
}
// Get the authentication method from the settings
auther, err := store.Auth.Get("password")
if err != nil {
return 401, errors.ErrUnauthorized
}
// Authenticate the user based on the request
user, err := auther.Auth(r, store.Users)
if err != nil {
if err == errors.ErrNoTotpProvided {
return 403, err
}
return 401, errors.ErrUnauthorized
}
d.user = user
}
return fn(w, r, d)
}
}
// Middleware to retrieve and authenticate user
func withUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
@@ -107,9 +249,12 @@ func withUserHelper(fn handleFunc) handleFunc {
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(uint(1))
if err != nil {
logger.Error(fmt.Sprintf("no auth: %v", err))
logger.Errorf("no auth: %v", err)
return http.StatusInternalServerError, err
}
if fn == nil {
return http.StatusOK, nil
}
return fn(w, r, data)
}
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
@@ -120,6 +265,9 @@ func withUserHelper(fn handleFunc) handleFunc {
}
data.user = user
setUserInResponseWriter(w, data.user)
if fn == nil {
return http.StatusOK, nil
}
return fn(w, r, data)
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
@@ -138,23 +286,26 @@ func withUserHelper(fn handleFunc) handleFunc {
if !token.Valid {
return http.StatusUnauthorized, fmt.Errorf("invalid token")
}
if auth.IsRevokedApiKey(tk.Key) || tk.Expires < time.Now().Unix() {
if auth.IsRevokedApiKey(data.token) || tk.Expires < time.Now().Unix() {
return http.StatusUnauthorized, fmt.Errorf("token expired or revoked")
}
// Check if the token is about to expire and send a header to renew it
if tk.Expires < time.Now().Add(time.Hour).Unix() {
w.Header().Add("X-Renew-Token", "true")
}
// Retrieve the user from the store and store it in the context
} // Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(tk.BelongsTo)
if err != nil {
logger.Errorf("Failed to get user with ID %v: %v", tk.BelongsTo, err)
return http.StatusInternalServerError, err
}
setUserInResponseWriter(w, data.user)
// Call the handler function, passing in the context
if data.user.Username == "" {
return http.StatusForbidden, errors.ErrUnauthorized
}
// Call the handler function, passing in the context (or return OK if no handler)
if fn == nil {
return http.StatusOK, nil
}
return fn(w, r, data)
}
}
@@ -192,14 +343,14 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
// Marshal the error response to JSON
errorBytes, marshalErr := json.Marshal(response)
if marshalErr != nil {
logger.Error(fmt.Sprintf("Error marshalling error response: %v", marshalErr))
logger.Errorf("Error marshalling error response: %v", marshalErr)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Write the JSON error response
if _, writeErr := w.Write(errorBytes); writeErr != nil {
logger.Debug(fmt.Sprintf("Error writing error response: %v", writeErr))
logger.Debugf("Error writing error response: %v", writeErr)
}
return
}
@@ -237,6 +388,18 @@ func withUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withUserHelper(fn))
}
func withOrWithoutUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withOrWithoutUserHelper(fn))
}
func withoutUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withoutUserHelper(fn))
}
func userWithoutOTP(fn handleFunc) http.HandlerFunc {
return wrapHandler(userWithoutOTPhelper(fn))
}
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
return wrapHandler(withSelfOrAdminHelper(fn))
}
@@ -289,32 +452,84 @@ func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
// LoggingMiddleware logs each request and its status code.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// DEFER RECOVERY FUNCTION
defer func() {
if rcv := recover(); rcv != nil {
// Log detailed information about the panic
// Extract as much context as possible for logging
method := r.Method
url := r.URL.String()
remoteAddr := r.RemoteAddr
username := "unknown" // Default username
// Wrap the ResponseWriter to capture the status code.
// Attempt to get username from ResponseWriterWrapper if it's set
if ww, ok := w.(*ResponseWriterWrapper); ok && ww.User != "" {
username = ww.User
}
// Or try to get it from request context if your other middleware populates it
// This depends on your context setup; example:
// if dataCtx, ok := r.Context().Value("requestData").(*requestContext); ok && dataCtx.user != nil {
// username = dataCtx.user.Username
// }
// Get Go-level stack trace
buf := make([]byte, 16384) // Increased buffer size for potentially long CGo traces
n := runtime.Stack(buf, false) // false for current goroutine only
stackTrace := string(buf[:n])
logger.Errorf("PANIC RECOVERED: %v\nUser: %s\nMethod: %s\nURL: %s\nRemoteAddr: %s\nGo Stack Trace:\n%s",
rcv, username, method, url, remoteAddr, stackTrace)
// Attempt to send a 500 error response to the client
// This is a best-effort; the connection might be broken or process too unstable.
if ww, ok := w.(*ResponseWriterWrapper); ok { // Check if it's our wrapper
if !ww.wroteHeader { // Only write if headers haven't been sent
ww.Header().Set("Content-Type", "application/json; charset=utf-8")
ww.WriteHeader(http.StatusInternalServerError)
}
} else {
_, _ = renderJSON(w, r, &HttpResponse{
Status: 500,
Message: "A critical internal error occurred. Please try again later.",
})
}
// IMPORTANT: After a SIGSEGV from C code, the process might be unstable.
// Even if Go recovers, continuing to run the process is risky.
// Consider a strategy to gracefully shut down or signal an external supervisor
// to restart the process after logging. For now, this will allow other requests
// to proceed if the process doesn't die, but be wary.
}
}() // End of deferred recovery function
start := time.Now()
wrappedWriter := &ResponseWriterWrapper{ResponseWriter: w, StatusCode: http.StatusOK}
// Call the next handler.
// Call the next handler in the chain
next.ServeHTTP(wrappedWriter, r)
// Capture the full URL path including the query parameters.
// Existing logging logic for normal requests
fullURL := r.URL.Path
if r.URL.RawQuery != "" {
fullURL += "?" + r.URL.RawQuery
}
truncUser := wrappedWriter.User
if len(truncUser) > 12 {
if truncUser == "" {
truncUser = "N/A" // Handle case where user might not be set (e.g., if panic occurred before user auth)
} else if len(truncUser) > 12 {
truncUser = truncUser[:10] + ".."
}
duration := time.Since(start)
logger.Api(
// Use the StatusCode from wrappedWriter, which might have been set to 500 by the recover logic
logger.Api(wrappedWriter.StatusCode,
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode, // Captured status code
wrappedWriter.StatusCode,
r.RemoteAddr,
truncUser,
fmt.Sprintf("%vms", duration.Milliseconds()),
fullURL), wrappedWriter.StatusCode)
fullURL))
})
}
+26 -13
View File
@@ -11,7 +11,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -23,16 +22,10 @@ func setupTestEnv(t *testing.T) {
if err != nil {
t.Fatal(err)
}
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
store, err = bolt.NewStorage(db)
if err != nil {
t.Fatal(err)
}
store = &storage.Storage{
Auth: authStore,
Users: userStore,
Share: shareStore,
Settings: settingsStore,
}
config = &settings.Config // mocked
config.Server.SourceMap = map[string]settings.Source{
"/srv": settings.Source{
@@ -77,10 +70,10 @@ func TestWithAdminHelper(t *testing.T) {
Permissions: users.Permissions{Admin: false}, // Non-admin user
}
// Save the users to the mock database
if err := store.Users.Save(adminUser, true); err != nil {
if err := store.Users.Save(adminUser, true, true); err != nil {
t.Fatal("failed to save admin user:", err)
}
if err := store.Users.Save(nonAdminUser, true); err != nil {
if err := store.Users.Save(nonAdminUser, true, true); err != nil {
t.Fatal("failed to save non-admin user:", err)
}
// Test cases for different scenarios
@@ -157,8 +150,26 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
{
name: "Public share, no auth required",
share: &share.Link{
Hash: "public_hash",
Source: "/srv",
Hash: "public_hash",
CommonShare: share.CommonShare{
Source: "/srv",
},
},
expectedStatusCode: 0, // zero means 200 on helpers
},
{
name: "Private share, valid password when token exists",
share: &share.Link{
Hash: "pw_and_token_hash",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "some_random_token",
CommonShare: share.CommonShare{
Source: "/srv",
},
},
extraHeaders: map[string]string{
"X-SHARE-PASSWORD": "password",
},
expectedStatusCode: 0, // zero means 200 on helpers
},
@@ -179,7 +190,9 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
Source: "/srv",
CommonShare: share.CommonShare{
Source: "/srv",
},
},
token: "123",
expectedStatusCode: 0, // zero means 200 on helpers
+391
View File
@@ -0,0 +1,391 @@
package http
import (
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/oauth2"
)
// userInfo struct to hold user claims from either UserInfo or ID token
type userInfo struct {
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Username string `json:"username"`
Email string `json:"email"`
Sub string `json:"sub"`
Phone string `json:"phone_number"`
Groups []string `json:"-"` // Handled manually by userInfoUnmarshaller
}
// userInfoUnmarshaller is a custom unmarshaller that handles configurable groups claim
type userInfoUnmarshaller struct {
userInfo *userInfo
groupsClaim string
}
// UnmarshalJSON implements the json.Unmarshaler interface
func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
// First, unmarshal the basic userInfo fields
if err := json.Unmarshal(data, u.userInfo); err != nil {
return err
}
// Parse the JSON to access the groups claim field
var rawData map[string]interface{}
if err := json.Unmarshal(data, &rawData); err != nil {
return err
}
// Look for the groups claim using the configured field name
if groupsValue, exists := rawData[u.groupsClaim]; exists {
switch v := groupsValue.(type) {
case []interface{}:
// It's already an array, convert to []string
groups := make([]string, len(v))
for i, group := range v {
if str, ok := group.(string); ok {
groups[i] = str
}
}
u.userInfo.Groups = groups
case string:
// It's a string, split by commas
if v != "" {
u.userInfo.Groups = strings.Split(v, ",")
// Trim whitespace from each group
for i, group := range u.userInfo.Groups {
u.userInfo.Groups[i] = strings.TrimSpace(group)
}
}
}
}
return nil
}
// oidcLoginHandler initiates OIDC login.
// @Summary OIDC login
// @Description Initiates OIDC login flow.
// @Tags OIDC
// @Accept json
// @Produce json
// @Success 302 {string} string "Redirect to OIDC provider"
// @Router /api/auth/oidc/login [get]
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
oidcCfg := settings.Config.Auth.Methods.OidcAuth
if !oidcCfg.Enabled {
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
}
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
oauth2Config := &oauth2.Config{
ClientID: oidcCfg.ClientID,
ClientSecret: oidcCfg.ClientSecret,
Endpoint: oidcCfg.Provider.Endpoint(),
RedirectURL: fmt.Sprintf("%s%sapi/auth/oidc/callback", origin, config.Server.BaseURL),
Scopes: strings.Split(oidcCfg.Scopes, " "),
}
nonce := utils.InsecureRandomIdentifier(16)
fbRedirect := r.URL.Query().Get("redirect")
state := fmt.Sprintf("%s:%s", nonce, fbRedirect)
authURL := oauth2Config.AuthCodeURL(state)
http.Redirect(w, r, authURL, http.StatusFound)
return 0, nil
}
// oidcCallbackHandler handles OIDC callback.
// @Summary OIDC callback
// @Description Handles OIDC login callback.
// @Tags OIDC
// @Accept json
// @Produce json
// @Param code query string false "OIDC code"
// @Param state query string false "OIDC state"
// @Success 200 {object} map[string]string "OIDC callback result"
// @Failure 400 {object} map[string]string "Bad request"
// @Router /api/auth/oidc/callback [get]
func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
ctx := r.Context()
oidcCfg := settings.Config.Auth.Methods.OidcAuth
if oidcCfg.Provider == nil || oidcCfg.Verifier == nil {
// Ensure Provider and Verifier are initialized on application startup
// This check is good, keep it.
logger.Error("OIDC provider or verifier not initialized.")
return http.StatusInternalServerError, fmt.Errorf("OIDC provider or verifier not initialized")
}
// If disableVerifyTLS is true, create a custom HTTP client
// and set it in the context for the OIDC provider.
if oidcCfg.DisableVerifyTLS {
// Create a custom transport with InsecureSkipVerify set to true
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
customClient := &http.Client{
Transport: transport,
}
ctx = oidc.ClientContext(ctx, customClient)
}
code := r.URL.Query().Get("code")
// state := r.URL.Query().Get("state") // You might want to validate the state parameter for CSRF protection
// The redirect URI MUST match the one registered with the OIDC provider
// and used in the initial /api/auth/oidc/login handler.
// Using r.Host here might be tricky if running behind a proxy.
// Consider using a fixed redirect URL from settings if possible.
redirectURL := fmt.Sprintf("%s://%s%sapi/auth/oidc/callback", getScheme(r), r.Host, config.Server.BaseURL)
oauth2Config := &oauth2.Config{
ClientID: oidcCfg.ClientID,
ClientSecret: oidcCfg.ClientSecret,
Endpoint: oidcCfg.Provider.Endpoint(), // Use endpoint from discovered provider
RedirectURL: redirectURL, // Use the dynamically determined redirect URL
Scopes: strings.Split(oidcCfg.Scopes, " "),
}
// Exchange the authorization code for tokens
token, err := oauth2Config.Exchange(ctx, code)
if err != nil {
logger.Errorf("failed to exchange token: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to exchange token: %v", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
var userdata userInfo // Declare userdata here to be populated by either source
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
loginUsername := "" // Variable to hold the login username
// Create custom unmarshaller for userInfo
userInfoUnmarshaller := &userInfoUnmarshaller{
userInfo: &userdata,
groupsClaim: oidcCfg.GroupsClaim,
}
// --- Attempt to process ID Token ---
if ok && rawIDToken != "" {
logger.Debug("ID token found in token response, attempting verification.")
// Verify the ID token
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
idToken, err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
if err != nil {
// this might not be necessary for certain providers like authentik
logger.Debugf("failed to verify ID token: %v. This might be expected, falling back to UserInfo endpoint.", err)
// Verification failed, claimsFromIDToken remains false
} else {
// Decode the ID token claims using custom unmarshaller
// This is where the JWE unmarshalling error occurs if the token is encrypted
if err := idToken.Claims(userInfoUnmarshaller); err != nil {
logger.Warningf("failed to decode ID token claims: %v. Falling back to UserInfo endpoint.", err)
// Claims decoding failed, claimsFromIDToken remains false
} else {
// Successfully verified and decoded ID token claims
logger.Debugf("ID Token verified and claims decoded: %+v", userdata)
// Decide if we rely on ID token claims or still need UserInfo
// Even if parsing succeeded, if essential claims are missing, use UserInfo
switch oidcCfg.UserIdentifier {
case "email":
if userdata.Email != "" {
claimsFromIDToken = true
loginUsername = userdata.Email
}
case "username":
if userdata.Username != "" {
claimsFromIDToken = true
loginUsername = userdata.Username
}
case "preferred_username":
if userdata.PreferredUsername != "" {
claimsFromIDToken = true
loginUsername = userdata.PreferredUsername
}
case "phone":
if userdata.Phone != "" {
claimsFromIDToken = true
loginUsername = userdata.Phone
}
}
}
}
} else {
logger.Debug("No ID token found in token response or it was empty. Falling back to UserInfo endpoint.")
// claimsFromIDToken remains false
}
// --- Fallback to UserInfo endpoint if ID token processing did not provide essential claims ---
if !claimsFromIDToken {
// Use the access token obtained from the initial exchange
// oauth2Config.TokenSource creates a token source that uses the provided token.
userInfoResp, err := oidcCfg.Provider.UserInfo(ctx, oauth2Config.TokenSource(ctx, token))
if err != nil {
logger.Errorf("failed to fetch user info from endpoint: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to fetch user info from endpoint: %v", err)
}
// Decode the UserInfo response using custom unmarshaller
// The UserInfo endpoint is expected to return standard JSON
if err := userInfoResp.Claims(userInfoUnmarshaller); err != nil {
logger.Errorf("failed to decode user info from endpoint: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to decode user info from endpoint: %v", err)
}
// Decide if we rely on ID token claims or still need UserInfo
// Even if parsing succeeded, if essential claims are missing, use UserInfo
switch oidcCfg.UserIdentifier {
case "email":
loginUsername = userdata.Email
case "username":
loginUsername = userdata.Username
case "preferred_username":
loginUsername = userdata.PreferredUsername
case "phone":
loginUsername = userdata.Phone
}
}
if loginUsername == "" {
logger.Errorf("No valid username found for identifier '%v' in ID token or UserInfo response.", oidcCfg.UserIdentifier)
return http.StatusInternalServerError, fmt.Errorf("no valid username found in ID token or UserInfo response from claims")
}
// Proceed to log the user in with the OIDC data
// userdata struct now contains info from either verified ID token or UserInfo endpoint
return loginWithOidcUser(w, r, loginUsername, userdata.Groups)
}
// loginWithOidcUser extracts the username from the user claims (userInfo)
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string, groups []string) (int, error) {
isAdmin := false // Default to non-admin user
if config.Auth.Methods.OidcAuth.AdminGroup != "" {
if slices.Contains(groups, config.Auth.Methods.OidcAuth.AdminGroup) {
isAdmin = true // User is in the admin group, grant admin privileges
logger.Debugf("User %s is in admin group %s, granting admin privileges.", username, config.Auth.Methods.OidcAuth.AdminGroup)
}
}
logger.Debugf("Successfully authenticated OIDC username: %s isAdmin: %v", username, isAdmin)
// Retrieve the user from the store and store it in the context
user, err := store.Users.Get(username)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
if config.Auth.Methods.OidcAuth.CreateUser {
if config.Auth.Methods.OidcAuth.AdminGroup == "" {
isAdmin = config.UserDefaults.Permissions.Admin
}
user = &users.User{
Username: username,
LoginMethod: users.LoginMethodOidc,
}
settings.ApplyUserDefaults(user)
if isAdmin {
user.Permissions.Admin = true
}
err = storage.CreateUser(*user)
if err != nil {
return http.StatusInternalServerError, err
}
user, err = store.Users.Get(username)
if err != nil {
return http.StatusInternalServerError, err
}
} else {
return http.StatusForbidden, fmt.Errorf("user %s does not exist and createUser is disabled. Your admin needs to create your user before you can access this application", username)
}
} else {
// update user admin perms
if isAdmin != user.Permissions.Admin && config.Auth.Methods.OidcAuth.AdminGroup != "" {
user.Permissions.Admin = isAdmin
err = store.Users.Update(user, true, "Permissions")
if err != nil {
logger.Warningf("failed to update oidc user %s admin permissions: %v", username, err)
}
}
}
if user.LoginMethod != users.LoginMethodOidc {
return http.StatusForbidden, errors.ErrWrongLoginMethod
}
// Sync OIDC groups with access control system
if err = store.Access.SyncUserGroups(username, groups); err != nil {
logger.Warningf("failed to sync oidc user %s groups: %v", username, err)
}
// Generate a signed token for the user
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err2 != nil {
// Handle potential errors during token generation
if strings.Contains(err2.Error(), "key already exists with same name") {
return http.StatusConflict, fmt.Errorf("token name conflict: %v", err2)
}
return http.StatusInternalServerError, fmt.Errorf("failed to generate authentication token for user %s: %v", username, err)
}
// Set the authentication token as an HTTP cookie
cookie := &http.Cookie{
Name: "auth", // The name of your auth cookie
Value: signed.Key, // The generated token value
Domain: strings.Split(r.Host, ":")[0], // Set domain to the host without port
Path: "/", // Make the cookie available to the whole site
// Secure: true, // Recommended: Set to true in production with HTTPS
// HttpOnly: true, // Recommended: Set to true to prevent client-side script access
Expires: time.Now().Add(time.Hour * time.Duration(config.Auth.TokenExpirationHours)), // Set cookie expiration
}
http.SetCookie(w, cookie)
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
setUserInResponseWriter(w, user)
// Redirect the user to the page they were trying to access before login,
// or to the root ("/") if no specific redirect was requested.
// The 'fb_redirect' parameter is extracted from the 'state' parameter for security.
state := r.URL.Query().Get("state")
fbRedirect := config.Server.BaseURL // Default redirect to the base URL
if state != "" {
parts := strings.SplitN(state, ":", 2)
// 2. Validate the nonce
// receivedNonce := parts[0]
// if receivedNonce != nonceCookie.Value {
// // Handle error: nonce mismatch (possible CSRF attack)
// return http.StatusBadRequest, fmt.Errorf("invalid state nonce")
// }
if len(parts) == 2 && parts[1] != "" {
// 3. Prevent Open Redirect vulnerability
// Ensure the redirect is to a local path.
potentialRedirect, err := url.QueryUnescape(parts[1])
if err == nil && strings.HasPrefix(potentialRedirect, "/") {
fbRedirect = potentialRedirect
} else {
logger.Warningf("Blocked potentially malicious redirect to: %s", parts[1])
}
}
}
// Clean up
http.Redirect(w, r, fbRedirect, http.StatusFound)
// Return 0 to indicate that the response has been handled by the redirect
return 0, nil
}
+86
View File
@@ -0,0 +1,86 @@
package http
import (
"encoding/json"
"reflect"
"testing"
)
func TestUserInfoUnmarshaller(t *testing.T) {
tests := []struct {
name string
jsonData string
groupsClaim string
expected userInfo
}{
{
name: "standard groups array",
jsonData: `{"name":"John","email":"john@example.com","groups":["admin","users"]}`,
groupsClaim: "groups",
expected: userInfo{
Name: "John",
Email: "john@example.com",
Groups: []string{"admin", "users"},
},
},
{
name: "custom groups claim name",
jsonData: `{"name":"Jane","email":"jane@example.com","roles":["admin","users"]}`,
groupsClaim: "roles",
expected: userInfo{
Name: "Jane",
Email: "jane@example.com",
Groups: []string{"admin", "users"},
},
},
{
name: "groups as comma-separated string",
jsonData: `{"name":"Bob","email":"bob@example.com","groups":"admin, users, guests"}`,
groupsClaim: "groups",
expected: userInfo{
Name: "Bob",
Email: "bob@example.com",
Groups: []string{"admin", "users", "guests"},
},
},
{
name: "no groups field",
jsonData: `{"name":"Alice","email":"alice@example.com"}`,
groupsClaim: "groups",
expected: userInfo{
Name: "Alice",
Email: "alice@example.com",
Groups: nil,
},
},
{
name: "empty groups array",
jsonData: `{"name":"Charlie","email":"charlie@example.com","groups":[]}`,
groupsClaim: "groups",
expected: userInfo{
Name: "Charlie",
Email: "charlie@example.com",
Groups: []string{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var userdata userInfo
unmarshaller := &userInfoUnmarshaller{
userInfo: &userdata,
groupsClaim: tt.groupsClaim,
}
err := json.Unmarshal([]byte(tt.jsonData), unmarshaller)
if err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if !reflect.DeepEqual(userdata, tt.expected) {
t.Errorf("Expected %+v, got %+v", tt.expected, userdata)
}
})
}
}
+22 -19
View File
@@ -12,12 +12,11 @@ import (
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"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/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
const (
@@ -41,26 +40,26 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
}
encodedUrl := r.URL.Query().Get("url")
// Decode the URL-encoded path
url, err := url.QueryUnescape(encodedUrl)
givenUrl, err := url.QueryUnescape(encodedUrl)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// get path from url
pathParts := strings.Split(url, "/api/raw?files=")
pathParts := strings.Split(givenUrl, "/api/raw?files=")
origPathParts := strings.Split(encodedUrl, "/api/raw?files=")
encodedPath := origPathParts[len(origPathParts)-1]
sourceFile := pathParts[len(pathParts)-1]
sourceSplit := strings.Split(sourceFile, "::")
if len(sourceSplit) != 2 {
return http.StatusBadRequest, fmt.Errorf("invalid url path %v", url)
return http.StatusBadRequest, fmt.Errorf("invalid url path %v", givenUrl)
}
source := sourceSplit[0]
path := sourceSplit[1]
urlFirst := pathParts[0]
if settings.Config.Server.InternalUrl != "" {
urlFirst = settings.Config.Server.InternalUrl
replacement := strings.Split(url, "/api/raw")[0]
url = strings.Replace(url, replacement, settings.Config.Server.InternalUrl, 1)
urlFirst = settings.Config.Server.InternalUrl + settings.Config.Server.BaseURL
whatToReplace := strings.Split(givenUrl, "/api/raw")[0] + "/"
givenUrl = strings.Replace(givenUrl, whatToReplace, urlFirst, 1)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
@@ -73,10 +72,12 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
Expand: false,
})
if err != nil {
logger.Debugf("onlyofficeClientConfigGetHandler: failed to get file info for file source %v, path %v: %v", source, path, err)
return errToStatus(err), err
}
id, err := getOnlyOfficeId(source, fileInfo.Path)
if err != nil {
logger.Debugf("getOnlyOfficeId failed for file source %v, path %v: %v", source, fileInfo.Path, err)
return http.StatusNotFound, err
}
split := strings.Split(fileInfo.Name, ".")
@@ -85,15 +86,20 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
if d.user.DarkMode {
theme = "dark"
}
callbackURL := fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, encodedPath, d.token)
canEdit := iteminfo.CanEditOnlyOffice(d.user.Permissions.Modify, fileType)
mode := "view"
if canEdit {
mode = "edit"
}
callbackURL := fmt.Sprintf("%v/api/onlyoffice/callback?files=%v&auth=%v", strings.TrimSuffix(urlFirst, "/"), encodedPath, d.token)
clientConfig := map[string]interface{}{
"document": map[string]interface{}{
"fileType": fileType,
"key": id,
"title": fileInfo.Name,
"url": url + "&auth=" + d.token,
"url": givenUrl + "&auth=" + d.token,
"permissions": map[string]interface{}{
"edit": d.user.Permissions.Modify,
"edit": canEdit,
"download": true,
"print": true,
},
@@ -110,7 +116,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
"uiTheme": theme,
},
"lang": d.user.Locale,
"mode": "edit",
"mode": mode,
},
}
if settings.Config.Integrations.OnlyOffice.Secret != "" {
@@ -135,15 +141,12 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
return http.StatusInternalServerError, err
}
encodedPath := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("files")
pathParts := strings.Split(encodedPath, "::")
if len(pathParts) < 2 {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source := pathParts[0]
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(pathParts[1])
if err != nil {
@@ -196,7 +199,7 @@ func getOnlyOfficeId(source, path string) (string, error) {
realpath, _, _ := idx.GetRealPath(path)
// error is intentionally ignored in order treat errors
// the same as a cache-miss
cachedDocumentKey, ok := cache.OnlyOffice.Get(realpath).(string)
cachedDocumentKey, ok := utils.OnlyOfficeCache.Get(realpath).(string)
if ok {
return cachedDocumentKey, nil
}
@@ -206,11 +209,11 @@ func getOnlyOfficeId(source, path string) (string, error) {
func deleteOfficeId(source, path string) {
idx := indexing.GetIndex(source)
if idx == nil {
logger.Error(fmt.Sprintf("deleteOfficeId: failed to find source index for user home dir creation: %s", source))
logger.Errorf("deleteOfficeId: failed to find source index for user home dir creation: %s", source)
return
}
realpath, _, _ := idx.GetRealPath(path)
cache.OnlyOffice.Delete(realpath)
utils.OnlyOfficeCache.Delete(realpath)
}
func onlyofficeGetTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
+44 -20
View File
@@ -5,7 +5,9 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
@@ -42,11 +44,13 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if config.Server.DisablePreviews {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
@@ -56,10 +60,12 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Access: store.Access,
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
})
if err != nil {
return errToStatus(err), err
@@ -89,8 +95,8 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
previewSize := r.URL.Query().Get("size")
if previewSize != "small" {
previewSize = "large"
if !(previewSize == "large" || previewSize == "original") {
previewSize = "small"
}
if d.fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
@@ -106,22 +112,40 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
}
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", d.fileInfo.Type)
}
pathUrl := fmt.Sprintf("/api/raw?files=%s::%s", d.fileInfo.Source, d.fileInfo.Path)
pathUrl = pathUrl + "&auth=" + d.token
if settings.Config.Server.InternalUrl != "" {
pathUrl = config.Server.InternalUrl + pathUrl
} else {
scheme := "http"
if r.TLS != nil {
scheme = "https"
seekPercentage := 0
percentage := r.URL.Query().Get("atPercentage")
if percentage != "" {
var err error
// convert string to int
seekPercentage, err = strconv.Atoi(percentage)
if err != nil {
seekPercentage = 10
}
if seekPercentage < 0 || seekPercentage > 100 {
seekPercentage = 10
}
pathUrl = scheme + "://" + r.Host + pathUrl
}
previewImg, err := preview.GetPreviewForFile(d.fileInfo, previewSize, pathUrl)
officeUrl := ""
if d.fileInfo.OnlyOfficeId != "" {
pathUrl := fmt.Sprintf("/api/raw?files=%s::%s", d.fileInfo.Source, d.fileInfo.Path)
pathUrl = pathUrl + "&auth=" + d.token
if settings.Config.Server.InternalUrl != "" {
officeUrl = config.Server.InternalUrl + pathUrl
} else {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
officeUrl = scheme + "://" + r.Host + pathUrl
}
}
previewImg, err := preview.GetPreviewForFile(d.fileInfo, previewSize, officeUrl, seekPercentage)
if err != nil {
return http.StatusInternalServerError, err
}
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, d.fileInfo.RealPath, d.fileInfo.ModTime, bytes.NewReader(previewImg))
w.Header().Set("Content-Type", "image/jpeg")
http.ServeContent(w, r, d.fileInfo.Name+"-preview.jpg", d.fileInfo.ModTime, bytes.NewReader(previewImg))
return 0, nil
}
+35 -52
View File
@@ -7,12 +7,8 @@ import (
"net/url"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"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/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
)
@@ -23,7 +19,7 @@ import (
// @Tags Resources
// @Accept json
// @Produce json
// @Param files query string true "a list of files in the following format 'filename' and separated by '||' with additional items in the list. (required)"
// @Param files query string false "if specified, only the files in the list will be downloaded. eg. files=/file1||/folder/file2"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
@@ -33,47 +29,56 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/dl [get]
func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.DownloadsLimit > 0 && d.share.Downloads >= d.share.DownloadsLimit {
return http.StatusForbidden, fmt.Errorf("share downloads limit reached")
}
d.share.Mu.Lock()
d.share.Downloads++
d.share.Mu.Unlock()
encodedFiles := r.URL.Query().Get("files")
// Decode the URL-encoded path
f, err := url.QueryUnescape(encodedFiles)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fileInfo, ok := d.raw.(iteminfo.ExtendedFileInfo)
if !ok {
logger.Error("public share handler: failed to assert type files.FileInfo")
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
}
fileList := []string{}
for _, file := range strings.Split(f, "||") {
fileList = append(fileList, fileInfo.Source+"::"+fileInfo.Path+file)
// Check if file already contains source prefix (source::path format)
if strings.Contains(file, "::") {
splitFile := strings.SplitN(file, "::", 2)
if len(splitFile) == 2 {
source := splitFile[0]
path := splitFile[1]
// Join the share path with the requested path
filePath := utils.JoinPathAsUnix(d.share.Path, path)
fileList = append(fileList, source+"::"+filePath)
} else {
// Fallback: treat as plain path
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, d.fileInfo.Source+"::"+filePath)
}
} else {
// Plain path without source prefix
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, d.fileInfo.Source+"::"+filePath)
}
}
var status int
status, err = rawFilesHandler(w, r, d, fileList)
if err != nil {
logger.Error(fmt.Sprintf("public share handler: error processing filelist: %v", err))
logger.Errorf("public share handler: error processing filelist: '%v' with error %v", f, err)
return status, fmt.Errorf("error processing filelist: %v", f)
}
return status, nil
}
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
file, ok := d.raw.(iteminfo.ExtendedFileInfo)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type iteminfo.FileInfo")
}
file.Path = strings.TrimPrefix(file.Path, d.share.Path)
return renderJSON(w, r, file)
}
func publicUserGetHandler(w http.ResponseWriter, r *http.Request) {
// Call the actual handler logic here (e.g., renderJSON, etc.)
// You may need to replace `fn` with the actual handler logic.
status, err := renderJSON(w, r, users.PublicUser)
if err != nil {
http.Error(w, http.StatusText(status), status)
}
// disable onlyoffice for public share
d.fileInfo.OnlyOfficeId = ""
return renderJSON(w, r, d.fileInfo)
}
// health godoc
@@ -109,32 +114,10 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
// @Failure 404 {object} map[string]string "File not found"
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/preview [get]
// @Router /api/public/preview [get]
func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Server.DisablePreviews {
if config.Server.DisablePreviews || d.share.DisableThumbnails {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(d.share.Path, path),
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
})
if err != nil {
logger.Debug(fmt.Sprintf("public preview handler: error getting file info: %v", err))
return 400, fmt.Errorf("file not found")
}
if fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
}
d.fileInfo = fileInfo
return previewHelperFunc(w, r, d)
}
+109 -33
View File
@@ -4,6 +4,7 @@ import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
@@ -13,12 +14,49 @@ import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/time/rate"
)
// throttledReadSeeker is a wrapper around an io.ReadSeeker that throttles the reading speed.
type throttledReadSeeker struct {
rs io.ReadSeeker
limiter *rate.Limiter
ctx context.Context
}
// newThrottledReadSeeker creates a new throttledReadSeeker.
func newThrottledReadSeeker(rs io.ReadSeeker, limit rate.Limit, burst int, ctx context.Context) *throttledReadSeeker {
return &throttledReadSeeker{
rs: rs,
limiter: rate.NewLimiter(limit, burst),
ctx: ctx,
}
}
func (r *throttledReadSeeker) Read(p []byte) (n int, err error) {
n, err = r.rs.Read(p)
if n > 0 {
if waitErr := r.limiter.WaitN(r.ctx, n); waitErr != nil {
// The original error (like io.EOF) is potentially more important
// than the context error.
if err == nil {
err = waitErr
}
}
}
return
}
func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.rs.Seek(offset, whence)
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline; filename*=utf-8''"+url.PathEscape(fileName))
@@ -44,12 +82,7 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/raw [get]
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedFiles := r.URL.Query().Get("files")
// Decode the URL-encoded path
files, err := url.QueryUnescape(encodedFiles)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
files := r.URL.Query().Get("files")
fileList := strings.Split(files, "||")
return rawFilesHandler(w, r, d, fileList)
}
@@ -61,24 +94,33 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
}
source := splitFile[0]
path = splitFile[1]
var err error
userScope := "/"
if d.user.Username != "publicUser" {
if d.share == nil {
var userScope string
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
if err != nil {
return fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
path = utils.JoinPathAsUnix(userScope, path)
}
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("source %s is not available", source)
}
realPath, _, err := idx.GetRealPath(userScope, path)
_, err = files.FileInfoFaster(iteminfo.FileOptions{
Access: store.Access,
Username: d.user.Username,
Path: path,
Source: source,
Expand: false,
})
if err != nil {
return fmt.Errorf("failed to get real path for %s: %v", path, err)
return err
}
realPath, _, _ := idx.GetRealPath(path)
info, err := os.Stat(realPath)
if err != nil {
return err
@@ -132,15 +174,20 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
}
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
})
} else {
// For a single file, use the base name as the archive path
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
}
// For a single file, use the base name as the archive path
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
}
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
file, err := os.Open(realPath)
if err != nil {
// If we get "is a directory" error, this is likely a symlink to a directory
// that wasn't properly detected. Skip it gracefully.
if strings.Contains(err.Error(), "is a directory") {
return nil
}
return err
}
defer file.Close()
@@ -150,6 +197,11 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
return err
}
// Double-check if this is actually a directory (in case of symlinks)
if info.IsDir() {
return nil
}
if tarWriter != nil {
header, err := tar.FileInfoHeader(info, "")
if err != nil {
@@ -188,20 +240,23 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
firstFileSource := splitFile[0]
firstFilePath := splitFile[1]
fileName := filepath.Base(firstFilePath)
// decode url encoded source name
var err error
userscope := "/"
if d.user.Username != "publicUser" {
var userscope string
fileName := filepath.Base(firstFilePath)
if d.share == nil {
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, firstFileSource)
if err != nil {
return http.StatusForbidden, err
}
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
}
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(firstFileSource)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
}
realPath, isDir, err := idx.GetRealPath(userscope, firstFilePath)
realPath, isDir, err := idx.GetRealPath(firstFilePath)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -232,11 +287,19 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
sizeInMB := estimatedSize / 1024 / 1024
// if larger than 500 MB, log it
if sizeInMB > 500 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName)
}
// serve content allows for range requests.
// video scrubbing, etc.
http.ServeContent(w, r, fileName, fileInfo.ModTime(), fd)
var reader io.ReadSeeker = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
http.ServeContent(w, r, fileName, fileInfo.ModTime(), reader)
return 200, nil
}
@@ -295,7 +358,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
sizeInMB := fileInfo.Size() / 1024 / 1024
if sizeInMB > 500 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName)
}
// Set headers AFTER computing actual archive size
@@ -304,10 +367,18 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
w.Header().Set("Content-Type", "application/octet-stream")
// Stream the file
_, err = io.Copy(w, fd)
var reader io.Reader = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
_, err = io.Copy(w, reader)
os.Remove(archiveData) // Remove the file after streaming
if err != nil {
logger.Error(fmt.Sprintf("Failed to copy archive data to response: %v", err))
logger.Errorf("Failed to copy archive data to response: %v", err)
return http.StatusInternalServerError, err
}
@@ -323,26 +394,31 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
}
source := splitFile[0]
path := splitFile[1]
var err error
idx := indexing.GetIndex(source)
if idx == nil {
return 0, fmt.Errorf("source %s is not available", source)
}
var err error
userScope := "/"
if d.user.Username != "publicUser" {
var userScope string
if d.share == nil {
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
if err != nil {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
path = utils.JoinPathAsUnix(userScope, path)
}
realPath, isDir, err := idx.GetRealPath(userScope, path)
// For shares, the path is already correctly resolved by publicRawHandler
realPath, isDir, err := idx.GetRealPath(path)
if err != nil {
return http.StatusInternalServerError, err
}
indexPath := idx.MakeIndexPath(realPath)
info, ok := idx.GetReducedMetadata(indexPath, isDir)
if !ok {
return 0, fmt.Errorf("failed to get metadata info for %s", path)
info, err = idx.GetFsDirInfo(indexPath)
if err != nil {
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
}
}
estimatedSize += info.Size
}
@@ -362,7 +438,7 @@ func createZip(d *requestContext, tmpDirPath string, filenames ...string) error
for _, fname := range filenames {
err := addFile(fname, d, nil, zipWriter, false)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add %s to ZIP: %v", fname, err))
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
return err
}
}
@@ -385,7 +461,7 @@ func createTarGz(d *requestContext, tmpDirPath string, filenames ...string) erro
for _, fname := range filenames {
err := addFile(fname, d, tarWriter, nil, false)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add %s to TAR.GZ: %v", fname, err))
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
return err
}
}
+236 -60
View File
@@ -2,7 +2,10 @@ package http
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
@@ -12,15 +15,45 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"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/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
)
// validateMoveOperation checks if a move/rename operation is valid at the HTTP level
// It prevents moving a directory into itself or its subdirectories
func validateMoveOperation(src, dst string, isSrcDir bool) error {
// Clean and normalize paths
src = filepath.Clean(src)
dst = filepath.Clean(dst)
// If source is a directory, check if destination is within source
if isSrcDir {
// Get the parent directory of the destination
dstParent := filepath.Dir(dst)
// Check if destination parent is the source directory or a subdirectory of it
if strings.HasPrefix(dstParent+string(filepath.Separator), src+string(filepath.Separator)) || dstParent == src {
return fmt.Errorf("cannot move directory '%s' to a location within itself: '%s'", src, dst)
}
}
// Check if destination parent directory exists
dstParent := filepath.Dir(dst)
if dstParent != "." && dstParent != "/" {
if _, err := os.Stat(dstParent); os.IsNotExist(err) {
return fmt.Errorf("destination directory does not exist: '%s'", dstParent)
}
}
return nil
}
// resourceGetHandler retrieves information about a resource.
// @Summary Get resource information
// @Description Returns metadata and optionally file contents for a specified resource path.
@@ -28,8 +61,7 @@ import (
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param source query string true "Source name for the desired source, default is used if not provided"
// @Param content query string false "Include file content if true"
// @Param checksum query string false "Optional checksum validation"
// @Success 200 {object} iteminfo.FileInfo "Resource metadata"
@@ -39,25 +71,32 @@ import (
func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
scopePath := utils.JoinPathAsUnix(userscope, path)
getContent := r.URL.Query().Get("content") == "true"
if d.share != nil && d.share.DisableFileViewer {
getContent = false
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: scopePath,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Content: r.URL.Query().Get("content") == "true",
Access: store.Access,
Username: d.user.Username,
Path: scopePath,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Content: getContent,
})
if err != nil {
return errToStatus(err), err
@@ -96,8 +135,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param source query string true "Source name for the desired source, default is used if not provided"
// @Success 200 "Resource deleted successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
@@ -107,8 +145,11 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
@@ -123,10 +164,12 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
Access: store.Access,
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
})
if err != nil {
return errToStatus(err), err
@@ -149,8 +192,8 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Destination path where to place the files inside the destination source, a directory must end in / to create a directory"
// @Param source query string false "Name for the desired filebrowser destination source name, default is used if not provided"
// @Param path query string true "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory"
// @Param source query string true "Name for the desired filebrowser destination source name, default is used if not provided"
// @Param override query bool false "Override existing file if true"
// @Success 200 "Resource created successfully"
// @Failure 403 {object} map[string]string "Forbidden"
@@ -161,46 +204,148 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
logger.Debugf("invalid source encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
path, err = url.QueryUnescape(path)
if err != nil {
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if !d.user.Permissions.Modify {
logger.Debugf("user is not allowed to create or modify")
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
fileOpts := iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
Access: store.Access,
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
}
idx := indexing.GetIndex(source)
if idx == nil {
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(userscope, path)
isDir := strings.HasSuffix(path, "/")
// Directories creation on POST.
if strings.HasSuffix(path, "/") {
if isDir {
err = files.WriteDirectory(fileOpts)
if err != nil {
logger.Debugf("error writing directory: %v", err)
return errToStatus(err), err
}
return http.StatusOK, nil
}
// Handle Chunked Uploads
chunkOffsetStr := r.Header.Get("X-File-Chunk-Offset")
if chunkOffsetStr != "" {
var offset int64
offset, err = strconv.ParseInt(chunkOffsetStr, 10, 64)
if err != nil {
logger.Debugf("invalid chunk offset: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid chunk offset: %v", err)
}
var totalSize int64
totalSizeStr := r.Header.Get("X-File-Total-Size")
totalSize, err = strconv.ParseInt(totalSizeStr, 10, 64)
if err != nil {
logger.Debugf("invalid total size: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid total size: %v", err)
}
// On the first chunk, check for conflicts or handle override
if offset == 0 {
var fileInfo iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(fileOpts)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
logger.Debugf("Resource already exists: %v", fileInfo.RealPath)
return http.StatusConflict, nil
}
// If overriding, delete existing thumbnails
preview.DelThumbs(r.Context(), fileInfo)
}
}
// Use a temporary file in the cache directory for chunks.
// Create a unique name for the temporary file to avoid collisions.
hasher := md5.New() //nolint:gosec
hasher.Write([]byte(realPath))
uploadID := hex.EncodeToString(hasher.Sum(nil))
tempFilePath := filepath.Join(settings.Config.Server.CacheDir, "uploads", uploadID)
if err = os.MkdirAll(filepath.Dir(tempFilePath), 0755); err != nil {
logger.Debugf("could not create temp dir: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not create temp dir: %v", err)
}
// Create or open the temporary file
var outFile *os.File
outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
logger.Debugf("could not open temp file: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not open temp file: %v", err)
}
defer outFile.Close()
// Seek to the correct offset to write the chunk
_, err = outFile.Seek(offset, 0)
if err != nil {
logger.Debugf("could not seek in temp file: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not seek in temp file: %v", err)
}
// Write the request body (the chunk) to the file
var chunkSize int64
chunkSize, err = io.Copy(outFile, r.Body)
if err != nil {
logger.Debugf("could not write chunk to temp file: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not write chunk to temp file: %v", err)
}
// check if the file is complete
if (offset + chunkSize) >= totalSize {
// close file before moving
outFile.Close()
// Move the completed file from the temp location to the final destination
err = fileutils.MoveFile(tempFilePath, realPath)
if err != nil {
logger.Debugf("could not move temp file to destination: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not move temp file to destination: %v", err)
}
go files.RefreshIndex(source, realPath, false) //nolint:errcheck
}
return http.StatusOK, nil
}
logger.Debugf("non-chunked upload logic for %s", realPath)
// Non-chunked upload logic (original code)
fileInfo, err := files.FileInfoFaster(fileOpts)
if err == nil {
if r.URL.Query().Get("override") != "true" {
logger.Debug(fmt.Sprintf("Resource already exists: %v", fileInfo.RealPath))
logger.Debugf("Resource already exists: %v", fileInfo.RealPath)
return http.StatusConflict, nil
}
// Permission for overwriting the file
if !d.user.Permissions.Modify {
return http.StatusForbidden, nil
}
preview.DelThumbs(r.Context(), fileInfo)
}
err = files.WriteFile(fileOpts, r.Body)
if err != nil {
logger.Debugf("error writing file: %v", err)
return errToStatus(err), err
}
@@ -214,8 +359,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Accept json
// @Produce json
// @Param path query string true "Destination path where to place the files inside the destination source"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param source query string true "Source name for the desired source, default is used if not provided"
// @Success 200 "Resource updated successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
@@ -224,8 +368,11 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Router /api/resources [put]
func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
if !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
@@ -246,10 +393,12 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusForbidden, err
}
fileOpts := iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
Access: store.Access,
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
}
err = files.WriteFile(fileOpts, r.Body)
return errToStatus(err), err
@@ -322,9 +471,9 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
return http.StatusNotFound, fmt.Errorf("source %s not found", dstIndex)
}
// check target dir exists
parentDir, _, err := idx.GetRealPath(userscopeDst, filepath.Dir(dst))
parentDir, isDstDir, err := idx.GetRealPath(userscopeDst, filepath.Dir(dst))
if err != nil {
logger.Debug(fmt.Sprintf("Could not get real path for parent dir: %v %v %v", userscopeDst, filepath.Dir(dst), err))
logger.Debugf("Could not get real path for parent dir: %v %v %v", userscopeDst, filepath.Dir(dst), err)
return http.StatusNotFound, err
}
realDest := parentDir + "/" + filepath.Base(dst)
@@ -347,9 +496,26 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if overwrite && !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("forbidden: user does not have permission to overwrite file")
}
err = patchAction(r.Context(), action, realSrc, realDest, d, isSrcDir, srcIndex, dstIndex)
// Validate move/rename operation to prevent circular references
if action == "rename" || action == "move" {
if err = validateMoveOperation(realSrc, realDest, isSrcDir); err != nil {
return http.StatusBadRequest, err
}
}
err = patchAction(r.Context(), patchActionParams{
action: action,
srcIndex: srcIndex,
dstIndex: dstIndex,
src: realSrc,
dst: realDest,
d: d,
isSrcDir: isSrcDir,
isDstDir: isDstDir,
})
if err != nil {
logger.Debug(fmt.Sprintf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err))
logger.Debugf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err)
}
return errToStatus(err), err
}
@@ -370,19 +536,32 @@ func addVersionSuffix(source string) string {
return source
}
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, isSrcDir bool, srcIndex, destIndex string) error {
switch action {
type patchActionParams struct {
action string
srcIndex string
dstIndex string
src string
dst string
d *requestContext
isSrcDir bool
isDstDir bool
}
func patchAction(ctx context.Context, params patchActionParams) error {
switch params.action {
case "copy":
err := files.CopyResource(srcIndex, destIndex, src, dst)
err := files.CopyResource(params.isSrcDir, params.isDstDir, params.srcIndex, params.dstIndex, params.src, params.dst)
return err
case "rename", "move":
idx := indexing.GetIndex(srcIndex)
srcPath := idx.MakeIndexPath(src)
idx := indexing.GetIndex(params.srcIndex)
srcPath := idx.MakeIndexPath(params.src)
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Access: store.Access,
Username: params.d.user.Username,
Path: srcPath,
Source: srcIndex,
IsDir: isSrcDir,
Modify: d.user.Permissions.Modify,
Source: params.srcIndex,
IsDir: params.isSrcDir,
Modify: params.d.user.Permissions.Modify,
Expand: false,
ReadHeader: false,
})
@@ -393,18 +572,15 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
// delete thumbnails
preview.DelThumbs(ctx, fileInfo)
return files.MoveResource(srcIndex, destIndex, src, dst)
return files.MoveResource(params.isSrcDir, params.isDstDir, params.srcIndex, params.dstIndex, params.src, params.dst, store.Share)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
return fmt.Errorf("unsupported action %s: %w", params.action, errors.ErrInvalidRequestParams)
}
}
func inspectIndex(w http.ResponseWriter, r *http.Request) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
@@ -425,6 +601,6 @@ func mockData(w http.ResponseWriter, r *http.Request) {
if err != nil || err2 != nil {
return
}
mockDir := utils.CreateMockData(NumDirs, numFiles)
mockDir := indexing.CreateMockData(NumDirs, numFiles)
renderJSON(w, r, mockDir) // nolint:errcheck
}
+3 -4
View File
@@ -50,6 +50,7 @@ import (
// @Accept json
// @Produce json
// @Param query query string true "Search query"
// @Param source query string true "Source name for the desired source"
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only"
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
// @Success 200 {array} indexing.SearchResult "List of search results"
@@ -58,9 +59,6 @@ import (
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
query := r.URL.Query().Get("query")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
scope := r.URL.Query().Get("scope")
unencodedScope, err := url.QueryUnescape(scope)
if err != nil {
@@ -78,11 +76,12 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusForbidden, err
}
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope))
combinedPath = strings.TrimSuffix(combinedPath, "/") + "/" // Ensure trailing slash
// Perform the search using the provided query and user scope
response := index.Search(query, combinedPath, sessionId)
for i := range response {
// Remove the user scope from the path
response[i].Path = strings.TrimPrefix(response[i].Path, userscope)
response[i].Path = strings.TrimPrefix(response[i].Path, combinedPath)
if response[i].Path == "" {
response[i].Path = "/"
}
+339 -54
View File
@@ -7,17 +7,45 @@ import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// ShareResponse represents a share with computed username field and download URL
type ShareResponse struct {
*share.Link
Username string `json:"username,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
}
// convertToShareResponse converts shares to response format with usernames
func convertToShareResponse(r *http.Request, shares []*share.Link) ([]*ShareResponse, error) {
responses := make([]*ShareResponse, len(shares))
for i, s := range shares {
user, err := store.Users.Get(s.UserID)
username := ""
if err == nil {
username = user.Username
}
responses[i] = &ShareResponse{
Link: s,
Username: username,
DownloadURL: getDownloadURL(r, s.Hash),
}
}
return responses, nil
}
// shareListHandler returns a list of all share links.
// @Summary List share links
// @Description Returns a list of share links for the current user, or all links if the user is an admin.
@@ -28,29 +56,22 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/shares [get]
func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
var (
s []*share.Link
err error
)
var err error
var shares []*share.Link
if d.user.Permissions.Admin {
s, err = store.Share.All()
shares, err = store.Share.All()
} else {
s, err = store.Share.FindByUserID(d.user.ID)
shares, err = store.Share.FindByUserID(d.user.ID)
}
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
if err != nil && err != errors.ErrNotExist {
return http.StatusInternalServerError, err
}
shares = utils.NonNilSlice(shares)
sharesWithUsernames, err := convertToShareResponse(r, shares)
if err != nil {
return http.StatusInternalServerError, err
}
sort.Slice(s, func(i, j int) bool {
if s[i].UserID != s[j].UserID {
return s[i].UserID < s[j].UserID
}
return s[i].Expire < s[j].Expire
})
return renderJSON(w, r, s)
return renderJSON(w, r, sharesWithUsernames)
}
// shareGetsHandler retrieves share links for a specific resource path.
@@ -66,29 +87,34 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Router /api/share [get]
func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
sourceName := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
sourcePath, ok := config.Server.NameToSource[source]
sourceInfo, ok := config.Server.NameToSource[sourceName] // backend source is path
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", sourceName)
}
s, err := store.Share.Gets(path, sourcePath.Path, d.user.ID)
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, sourceName)
if err != nil {
return http.StatusForbidden, err
}
scopePath := utils.JoinPathAsUnix(userscope, path)
s, err := store.Share.Gets(scopePath, sourceInfo.Path, d.user.ID)
if err == errors.ErrNotExist || len(s) == 0 {
return renderJSON(w, r, []*ShareResponse{})
}
// DownloadURL will be set in convertToShareResponse
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
}
return renderJSON(w, r, s)
sharesWithUsernames, err := convertToShareResponse(r, s)
if err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, r, sharesWithUsernames)
}
// shareDeleteHandler deletes a specific share link by its hash.
@@ -97,11 +123,11 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash path string true "Hash of the share link to delete"
// @Param hash query string true "Hash of the share link to delete"
// @Success 200 "Share link deleted successfully"
// @Failure 400 {object} map[string]string "Bad request - missing or invalid hash"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/shares/{hash} [delete]
// @Router /api/shares [delete]
func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
@@ -123,26 +149,28 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Tags Shares
// @Accept json
// @Produce json
// @Param body body share.CreateBody true "Share link creation parameters"
// @Param path path string true "Source Path of the files to share"
// @Param source path string true "Source name of the files to share"
// @Param body body share.CreateBody true "Share creation parameters"
// @Success 200 {object} share.Link "Created share link"
// @Failure 400 {object} map[string]string "Bad request - failed to decode body"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/shares [post]
// @Router /api/share [post]
func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
var s *share.Link
var err error
var body share.CreateBody
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err = json.NewDecoder(r.Body).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
}
defer r.Body.Close()
}
secure_hash, err := generateShortUUID()
if err != nil {
return http.StatusInternalServerError, err
// check if body.Hash is a valid hash
if body.Hash != "" {
s, err = store.Share.GetByHash(body.Hash)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid hash provided")
}
}
var expire int64 = 0
@@ -183,33 +211,279 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
token = base64.URLEncoding.EncodeToString(tokenBuffer)
stringHash = string(hash)
}
if s != nil {
s.Expire = expire
s.PasswordHash = stringHash
s.Token = token
// Preserve immutable fields for updates. Path and Source should not change on edits.
// If the request attempts to provide empty values (or any values) for these,
// keep the existing ones from the stored share.
body.Path = s.Path
body.Source = s.Source
s.CommonShare = body.CommonShare
if err = store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
// Convert to ShareResponse format with username
var user *users.User
user, err = store.Users.Get(s.UserID)
username := ""
if err == nil {
username = user.Username
}
response := &ShareResponse{
Link: s,
Username: username,
}
return renderJSON(w, r, response)
}
source, ok := config.Server.NameToSource[body.Source]
if !ok {
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", body.Source)
}
if source.Config.Private {
return http.StatusForbidden, fmt.Errorf("the target source is private, sharing is not permitted")
}
// create a new share link
secure_hash, err := generateShortUUID()
if err != nil {
return http.StatusInternalServerError, err
}
// validate source path exists
idx := indexing.GetIndex(source.Name)
if idx == nil {
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", body.Source)
}
// validate path exists as file or folder
_, exists := idx.GetReducedMetadata(body.Path, true) // true to check if it exists
if !exists {
// could be a file instead
_, exists := idx.GetReducedMetadata(utils.GetParentDirectoryPath(body.Path), true)
if !exists {
return http.StatusForbidden, fmt.Errorf("path not found: %s", body.Path)
}
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source.Name)
if err != nil {
return http.StatusForbidden, err
}
scopePath := utils.JoinPathAsUnix(userscope, body.Path)
body.Path = scopePath
body.Source = source.Path // backend source is path
s = &share.Link{
Expire: expire,
UserID: d.user.ID,
Hash: secure_hash,
PasswordHash: stringHash,
Token: token,
CommonShare: body.CommonShare,
}
if err = store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
// Convert to ShareResponse format with username
var user *users.User
user, err = store.Users.Get(s.UserID)
username := ""
if err == nil {
username = user.Username
}
response := &ShareResponse{
Link: s,
Username: username,
}
return renderJSON(w, r, response)
}
// DirectDownloadResponse represents the response for direct download endpoint
type DirectDownloadResponse struct {
Status string `json:"status"`
Hash string `json:"hash"`
DownloadURL string `json:"url"`
}
// shareDirectDownloadHandler creates a direct download link for files only.
// @Summary Create direct download link
// @Description Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.
// @Tags Shares
// @Accept json
// @Produce json
// @Param path query string true "File path to create download link for"
// @Param source query string true "Source name for the file"
// @Param duration query string false "Duration in minutes for link validity (default: 60)"
// @Param count query string false "Maximum number of downloads allowed (default: unlimited)"
// @Param speed query string false "Download speed limit in kbps (default: unlimited)"
// @Success 201 {object} DirectDownloadResponse "Direct download link created"
// @Failure 400 {object} map[string]string "Bad request - invalid parameters or path is not a file"
// @Failure 403 {object} map[string]string "Forbidden - access denied"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/share/direct [get]
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Extract query parameters
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
duration := r.URL.Query().Get("duration")
downloadCountStr := r.URL.Query().Get("count")
downloadSpeedStr := r.URL.Query().Get("speed")
// Validate required parameters
if encodedPath == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
sourceName := r.URL.Query().Get("source")
if sourceName == "" {
sourceName = config.Server.DefaultSource.Name
}
source := config.Server.NameToSource[sourceName]
s = &share.Link{
Path: path,
Hash: secure_hash,
Source: source.Path, // path instead to persist accoss name change
Expire: expire,
UserID: d.user.ID,
PasswordHash: stringHash,
Token: token,
// Validate source exists
sourceInfo, ok := config.Server.NameToSource[source]
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
}
if err := store.Share.Save(s); err != nil {
// Get user scope for this source
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
// Validate the path exists and is a file (not a folder)
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", source)
}
metadata, exists := idx.GetReducedMetadata(path, false)
if !exists {
return http.StatusBadRequest, fmt.Errorf("path is either not a file or not found: %s", path)
}
// Check if it's a file (not a directory)
if metadata.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("path must be a file, not a directory: %s", path)
}
// Set default duration to 60 minutes if not provided
if duration == "" {
duration = "60"
}
// Parse download count
var downloadCount int
if downloadCountStr != "" {
downloadCount, err = strconv.Atoi(downloadCountStr)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid downloadCount: %v", err)
}
}
// Parse download speed (in bytes per second)
var downloadSpeed int
if downloadSpeedStr != "" {
downloadSpeed, err = strconv.Atoi(downloadSpeedStr)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid downloadSpeed: %v", err)
}
}
// Calculate expiration time
durationNum, err := strconv.Atoi(duration)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid duration: %v", err)
}
expire := time.Now().Add(time.Minute * time.Duration(durationNum)).Unix()
// Generate secure hash for the share
secureHash, err := generateShortUUID()
if err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, r, s)
// Create the scope path
scopePath := utils.JoinPathAsUnix(userscope, path)
// Check if an existing share already matches these parameters
existingShares, err := store.Share.Gets(scopePath, sourceInfo.Path, d.user.ID)
if err == nil && len(existingShares) > 0 {
// Look for a share that matches our parameters
for _, existing := range existingShares {
if existing.DownloadsLimit == downloadCount &&
existing.MaxBandwidth == downloadSpeed &&
existing.QuickDownload &&
(existing.Expire == 0 || existing.Expire >= expire) { // Existing expires later or never
response := DirectDownloadResponse{
Status: "201",
Hash: existing.Hash,
DownloadURL: getDownloadURL(r, existing.Hash),
}
return renderJSON(w, r, response)
}
}
}
// No matching existing share found, create a new one
shareLink := &share.Link{
Expire: expire,
UserID: d.user.ID,
Hash: secureHash,
CommonShare: share.CommonShare{
Path: scopePath,
Source: idx.Path,
DownloadsLimit: downloadCount,
MaxBandwidth: downloadSpeed,
QuickDownload: true, // Enable quick download for direct downloads
},
}
// Save the share
if err := store.Share.Save(shareLink); err != nil {
return http.StatusInternalServerError, err
}
// Return response
response := DirectDownloadResponse{
Status: "200",
Hash: secureHash,
DownloadURL: getDownloadURL(r, secureHash),
}
return renderJSON(w, r, response)
}
func getDownloadURL(r *http.Request, hash string) string {
var downloadURL string
if config.Server.ExternalUrl != "" {
downloadURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
} else {
// Prefer X-Forwarded-Host for proxy support
var host string
var scheme string
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
scheme = forwardedProto
} else {
scheme = "https"
}
} else {
// Fallback to simple approach
host = r.Host
scheme = getScheme(r)
}
downloadURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s", scheme, host, config.Server.BaseURL, hash)
}
return downloadURL
}
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
@@ -239,3 +513,14 @@ func generateShortUUID() (string, error) {
// Trim the length to 22 characters for a shorter ID
return uuid[:22], nil
}
func redirectToShare(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Remove the base URL and "/share/" prefix to get the full path after share
sharePath := strings.TrimPrefix(r.URL.Path, config.Server.BaseURL+"share/")
newURL := config.Server.BaseURL + "public/share/" + sharePath
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
return http.StatusMovedPermanently, nil
}
+127 -39
View File
@@ -9,6 +9,7 @@ import (
"text/template"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
)
@@ -16,10 +17,19 @@ var templateRenderer *TemplateRenderer
type TemplateRenderer struct {
templates *template.Template
devMode bool
}
// Render renders a template document with headers and data
func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data interface{}) error {
// If in dev mode, reload templates on every render.
if t.devMode {
var err error
t.templates, err = t.templates.ParseFS(assetFs, "public/index.html")
if err != nil {
return fmt.Errorf("error reloading template: %w", err)
}
}
// Set headers
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
w.Header().Set("Pragma", "no-cache")
@@ -29,60 +39,140 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return t.templates.ExecuteTemplate(w, name, data)
}
func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentType string) {
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
w.Header().Set("Content-Type", contentType)
auther, err := store.Auth.Get("password")
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
return http.StatusInternalServerError, err
}
userSelectedTheme := ""
if d.user != nil {
theme, ok := config.Frontend.Styling.CustomThemes[d.user.CustomTheme]
if ok {
userSelectedTheme = theme.CSS
}
}
defaultThemeColor := "#455a64"
staticURL := config.Server.BaseURL + "static"
publicStaticURL := config.Server.BaseURL + "public/static"
data := map[string]interface{}{
"Name": config.Frontend.Name,
"DisableExternal": config.Frontend.DisableDefaultLinks,
"darkMode": settings.Config.UserDefaults.DarkMode,
"BaseURL": config.Server.BaseURL,
"Version": version.Version,
"CommitSHA": version.CommitSHA,
"StaticURL": config.Server.BaseURL + "static",
"Signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
"NoAuth": config.Auth.Methods.NoAuth,
"PasswordAuth": config.Auth.Methods.PasswordAuth,
"LoginPage": auther.LoginPage(),
"CSS": false,
"EnableThumbs": !config.Server.DisablePreviews,
"ExternalLinks": config.Frontend.ExternalLinks,
"ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"SourceCount": len(config.Server.Sources),
"OidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"PasswordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"MediaAvailable": config.Integrations.Media.FfmpegPath != "",
"title": config.Frontend.Name,
"customCSS": config.Frontend.Styling.CustomCSS,
"userSelectedTheme": userSelectedTheme,
"lightBackground": config.Frontend.Styling.LightBackground,
"darkBackground": config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
"baseURL": config.Server.BaseURL,
"favicon": staticURL + "/img/icons/favicon-256x256.png",
"color": defaultThemeColor,
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
"appIcon": staticURL + "/img/icons/android-chrome-256x256.png",
"description": "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server",
}
shareProps := map[string]interface{}{
"isShare": false,
"isValid": false,
"banner": "",
"title": "",
"quickDownload": false,
"description": "",
"themeColor": "",
"disableThumbnails": false,
"viewMode": "list",
"disableFileViewer": false,
"disableShareCard": false,
"disableSidebar": false,
"isPasswordProtected": false,
"hash": "",
}
disableNavButtons := settings.Config.Frontend.DisableNavButtons
if d.share != nil {
shareProps["isShare"] = true
shareProps["isValid"] = d.shareValid
shareProps["hash"] = d.share.Hash
b, err := json.Marshal(data)
if d.shareValid {
disableNavButtons = disableNavButtons || d.share.HideNavButtons
shareProps["viewMode"] = d.share.ViewMode
shareProps["banner"] = d.share.Banner
shareProps["title"] = d.share.Title
shareProps["description"] = d.share.Description
shareProps["themeColor"] = d.share.ThemeColor
shareProps["quickDownload"] = d.share.QuickDownload
shareProps["disableThumbnails"] = d.share.DisableThumbnails
shareProps["disableFileViewer"] = d.share.DisableFileViewer
shareProps["disableShareCard"] = d.share.DisableShareCard
shareProps["disableSidebar"] = d.share.DisableSidebar
shareProps["isPasswordProtected"] = d.share.PasswordHash != ""
shareProps["downloadURL"] = getDownloadURL(r, d.share.Hash)
if d.share.Favicon != "" {
if strings.HasPrefix(d.share.Favicon, "http") {
data["favicon"] = d.share.Favicon
} else {
data["favicon"] = publicStaticURL + "/" + d.share.Favicon
}
}
if d.share.Description != "" {
data["description"] = d.share.Description
}
if d.share.Title != "" {
data["title"] = d.share.Title
}
}
// base url could be different for routes behind proxy
data["staticURL"] = publicStaticURL
data["favicon"] = publicStaticURL + "/img/icons/favicon-256x256.png"
data["winIcon"] = publicStaticURL + "/img/icons/mstile-144x144.png"
data["appIcon"] = publicStaticURL + "/img/icons/android-chrome-256x256.png"
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
"name": config.Frontend.Name,
"disableExternal": config.Frontend.DisableDefaultLinks,
"darkMode": settings.Config.UserDefaults.DarkMode,
"baseURL": config.Server.BaseURL,
"version": version.Version,
"commitSHA": version.CommitSHA,
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
"noAuth": config.Auth.Methods.NoAuth,
"loginPage": auther.LoginPage(),
"enableThumbs": !config.Server.DisablePreviews,
"externalLinks": config.Frontend.ExternalLinks,
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"sourceCount": len(config.Server.SourceMap),
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"mediaAvailable": config.Integrations.Media.FfmpegPath != "",
"muPdfAvailable": config.Server.MuPdfAvailable,
"updateAvailable": utils.GetUpdateAvailableUrl(),
"disableNavButtons": disableNavButtons,
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
"share": shareProps,
}
jsonVars, err := json.Marshal(data["globalVars"])
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
return http.StatusInternalServerError, err
}
data["globalVars"] = strings.ReplaceAll(string(b), `'`, `\'`)
data["globalVars"] = strings.ReplaceAll(string(jsonVars), `'`, `\'`)
// Render the template with global variables
if err := templateRenderer.Render(w, file, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
func staticFilesHandler(w http.ResponseWriter, r *http.Request) {
const maxAge = 86400 // 1 day
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
// Remove "/static/" from the request path
adjustedPath := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("%vstatic/", config.Server.BaseURL))
adjustedCompressed := adjustedPath + ".gz"
if strings.HasSuffix(adjustedPath, ".js") {
adjustedCompressed := r.URL.Path + ".gz"
if strings.HasSuffix(r.URL.Path, ".js") {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8") // Set the correct MIME type for JavaScript files
}
// Check if the gzipped version of the file exists
@@ -95,15 +185,13 @@ func staticFilesHandler(w http.ResponseWriter, r *http.Request) {
}
} else {
// Otherwise, serve the regular file
http.StripPrefix(fmt.Sprintf("%vstatic/", config.Server.BaseURL), http.FileServer(http.FS(assetFs))).ServeHTTP(w, r)
http.FileServer(http.FS(assetFs)).ServeHTTP(w, r)
}
}
func indexHandler(w http.ResponseWriter, r *http.Request) {
func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if r.Method != http.MethodGet {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
return http.StatusNotFound, nil
}
handleWithStaticData(w, r, "index.html", "text/html")
return handleWithStaticData(w, r, d, "index.html", "text/html")
}
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff. Show more