Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018be705a6 | ||
|
|
ff5010ed4d | ||
|
|
226038b11d | ||
|
|
206180de0d | ||
|
|
5b0f388fa5 | ||
|
|
7d0707ebe0 | ||
|
|
41cb97a1c5 | ||
|
|
0b212ce1b4 | ||
|
|
7f0a0209af | ||
|
|
642333ec68 | ||
|
|
ec43926d3c | ||
|
|
9202043000 | ||
|
|
4d04e02c31 | ||
|
|
04d72998b6 | ||
|
|
f836a2c4df | ||
|
|
cc0212b754 | ||
|
|
f66a4d9313 | ||
|
|
3e59bf90f7 | ||
|
|
812f30cccd | ||
|
|
93a2e3e05e | ||
|
|
1f1d1c5b44 | ||
|
|
c581ea760e | ||
|
|
e3dc484c28 | ||
|
|
0ada9eeffa | ||
|
|
412c68232c | ||
|
|
5abbe1d64b | ||
|
|
80b1259dd1 | ||
|
|
96cf2d6a5e | ||
|
|
a69ad4b879 | ||
|
|
f40209751e |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
backend/**.db
|
||||
backend/tmp
|
||||
backend/backend*
|
||||
backend/vendor/**
|
||||
frontend/dist/**
|
||||
frontend/node_modules/**
|
||||
+98
-64
@@ -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
|
||||
@@ -111,3 +143,5 @@ jobs:
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
REVISION=${{ steps.meta.outputs.revision }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -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:
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: ./_docker/Dockerfile.slim
|
||||
file: ./_docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
+14
-12
@@ -21,9 +21,8 @@ jobs:
|
||||
run: npm i && npm run build
|
||||
working-directory: frontend
|
||||
- name: Install OS Requirements
|
||||
run: sudo apt update && sudo apt install -y upx gcc-multilib g++-multilib
|
||||
- name: Install Cross Compilers
|
||||
run: sudo apt update && sudo apt install -y gcc-mingw-w64 gcc-aarch64-linux-gnu
|
||||
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:
|
||||
@@ -65,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",
|
||||
@@ -126,26 +128,26 @@ jobs:
|
||||
images: |
|
||||
gtstef/filebrowser
|
||||
ghcr.io/gtsteffaniak/filebrowser
|
||||
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
|
||||
- name: Modify tags (strip 'v' and add '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)
|
||||
| sub(":(.+)"; ":\(.captures[0])-slim")
|
||||
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",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta"
|
||||
"gtstef/filebrowser:beta-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
|
||||
]
|
||||
else . end |
|
||||
if any(.tags[]; test("stable")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable"
|
||||
"gtstef/filebrowser:stable-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
|
||||
]
|
||||
else . end
|
||||
')
|
||||
|
||||
@@ -10,6 +10,7 @@ rice-box.go
|
||||
/backend/filebrowser.exe
|
||||
/backend/backend.exe
|
||||
/backend/tmp
|
||||
/backend/dist
|
||||
/frontend/dist
|
||||
/frontend/pkg
|
||||
/frontend/test-results
|
||||
@@ -47,3 +48,4 @@ yarn-error.log*
|
||||
bin/
|
||||
build/
|
||||
*__debug*
|
||||
backend/custom.css
|
||||
+344
-4
@@ -2,9 +2,351 @@
|
||||
|
||||
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.7.6-beta
|
||||
## v0.8.3-beta
|
||||
|
||||
NOTE: if using docker arm32 image, you will need to switch to the slim images. The regular docker images are much larger now and support generating office previews out of the box without any only office running. However, they don't support arm32. Also, be aware the docker images are much larger now (600MB I believe) because of the office document preview support -- if thats not something you care about you can switch to the slim images.
|
||||
**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)
|
||||
@@ -17,11 +359,9 @@ NOTE: if using docker arm32 image, you will need to switch to the slim images. T
|
||||
- ".cbz", // CBZ
|
||||
- ".svg", // SVG
|
||||
- ".txt", // TXT
|
||||
- ".doc", // DOC
|
||||
- ".docx", // DOCX
|
||||
- ".ppt", // PPT
|
||||
- ".pptx", // PPTX
|
||||
- ".xls", // excel xls
|
||||
- ".xlsx", // exel XLSX
|
||||
- ".hwp", // HWP
|
||||
- ".hwp", // HWPX
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
# Contributing to FileBrowser Quantum
|
||||
|
||||
Thank you for your interest in contributing to FileBrowser Quantum! This guide will help you get started with development.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.25+** (see `backend/go.mod`)
|
||||
- **Node.js 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`
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://hub.docker.com/r/gtstef/filebrowser)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
[](https://www.paypal.com/donate/?business=W5XKNXHJM2WPE&no_recurring=0¤cy_code=USD)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A#is-there-a-way-to-donate-or-support-this-project)
|
||||
|
||||
<img width="150" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" title="Logo">
|
||||
<h3>FileBrowser Quantum</h3>
|
||||
@@ -16,13 +16,27 @@
|
||||
</div>
|
||||
|
||||
> [!WARNING]
|
||||
> There is no stable version -- planned for 2025. ([Read more](https://github.com/gtsteffaniak/filebrowser/discussions/628))
|
||||
> 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 + 2FA, and proxy.
|
||||
3. ✅ Revamped UI
|
||||
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,28 +47,26 @@ 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)
|
||||
|
||||
## The UI
|
||||
|
||||
The UI has a simple three-component navigation system:
|
||||
@@ -94,6 +106,10 @@ See [Office Support Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Offic
|
||||
|
||||
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
|
||||
@@ -103,13 +119,13 @@ Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
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 |
|
||||
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+ |
|
||||
@@ -118,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 | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
|
||||
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
|
||||
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
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: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
+13
-15
@@ -1,15 +1,17 @@
|
||||
FROM gtstef/ffmpeg:7.1.1 AS ffmpeg
|
||||
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 -tags pdf -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:lts-slim AS nbuild
|
||||
WORKDIR /app
|
||||
@@ -20,26 +22,22 @@ RUN npm run build-docker
|
||||
|
||||
FROM alpine:latest
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
# requirements to enable pdf support
|
||||
RUN apk add --no-cache libffi mupdf && \
|
||||
find / -name "libmupdf.so*" -type f -exec mv {} /usr/lib/libmupdf.so \;
|
||||
|
||||
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 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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
FROM gtstef/playwright-base
|
||||
+9
-30
@@ -1,14 +1,15 @@
|
||||
FROM golang:1.24-alpine AS base
|
||||
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 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
|
||||
@@ -18,40 +19,18 @@ 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/*
|
||||
|
||||
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 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 --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" ]
|
||||
RUN [ "ffmpeg", "-version" ]
|
||||
RUN [ "ffprobe", "-version" ]
|
||||
|
||||
USER root
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
demo-127.0.0.1:$apr1$j5FRA4Bc$RYdNFuM7YAAo9Qtie8w4b/
|
||||
@@ -1,6 +1,6 @@
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/"
|
||||
baseURL: "/subpath"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
@@ -32,5 +32,5 @@ userDefaults:
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: false
|
||||
share: true
|
||||
api: false
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
tmp/
|
||||
air.log
|
||||
filebrowser
|
||||
generated.yaml
|
||||
test_config.yaml
|
||||
+145
-231
@@ -1,232 +1,146 @@
|
||||
# This code is licensed under the terms of the MIT license https://opensource.org/license/mit
|
||||
# Copyright (c) 2021 Marat Reymers
|
||||
|
||||
## Golden config for golangci-lint v1.59.1
|
||||
#
|
||||
# This is the best config for golangci-lint based on my experience and opinion.
|
||||
# It is very strict, but not extremely strict.
|
||||
# Feel free to adapt and change it for your needs.
|
||||
|
||||
run:
|
||||
# Timeout for analysis, e.g. 30s, 5m.
|
||||
# Default: 1m
|
||||
timeout: 3m
|
||||
|
||||
|
||||
# This file contains only configs which differ from defaults.
|
||||
# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml
|
||||
linters-settings:
|
||||
cyclop:
|
||||
# The maximal code complexity to report.
|
||||
# Default: 10
|
||||
max-complexity: 30
|
||||
# The maximal average package complexity.
|
||||
# If it's higher than 0.0 (float) the check is enabled
|
||||
# Default: 0.0
|
||||
package-average: 10.0
|
||||
|
||||
errcheck:
|
||||
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
|
||||
# Such cases aren't reported by default.
|
||||
# Default: false
|
||||
check-type-assertions: true
|
||||
|
||||
exhaustive:
|
||||
# Program elements to check for exhaustiveness.
|
||||
# Default: [ switch ]
|
||||
check:
|
||||
- switch
|
||||
- map
|
||||
|
||||
exhaustruct:
|
||||
# List of regular expressions to exclude struct packages and their names from checks.
|
||||
# Regular expressions must match complete canonical struct package/name/structname.
|
||||
# Default: []
|
||||
exclude:
|
||||
# std libs
|
||||
- "^net/http.Client$"
|
||||
- "^net/http.Cookie$"
|
||||
- "^net/http.Request$"
|
||||
- "^net/http.Response$"
|
||||
- "^net/http.Server$"
|
||||
- "^net/http.Transport$"
|
||||
- "^net/url.URL$"
|
||||
- "^os/exec.Cmd$"
|
||||
- "^reflect.StructField$"
|
||||
# public libs
|
||||
- "^github.com/Shopify/sarama.Config$"
|
||||
- "^github.com/Shopify/sarama.ProducerMessage$"
|
||||
- "^github.com/mitchellh/mapstructure.DecoderConfig$"
|
||||
- "^github.com/prometheus/client_golang/.+Opts$"
|
||||
- "^github.com/spf13/cobra.Command$"
|
||||
- "^github.com/spf13/cobra.CompletionOptions$"
|
||||
- "^github.com/stretchr/testify/mock.Mock$"
|
||||
- "^github.com/testcontainers/testcontainers-go.+Request$"
|
||||
- "^github.com/testcontainers/testcontainers-go.FromDockerfile$"
|
||||
- "^golang.org/x/tools/go/analysis.Analyzer$"
|
||||
- "^google.golang.org/protobuf/.+Options$"
|
||||
- "^gopkg.in/yaml.v3.Node$"
|
||||
|
||||
funlen:
|
||||
# Checks the number of lines in a function.
|
||||
# If lower than 0, disable the check.
|
||||
# Default: 60
|
||||
lines: 100
|
||||
# Checks the number of statements in a function.
|
||||
# If lower than 0, disable the check.
|
||||
# Default: 40
|
||||
statements: 50
|
||||
# Ignore comments when counting lines.
|
||||
# Default false
|
||||
ignore-comments: true
|
||||
|
||||
gocognit:
|
||||
# Minimal code complexity to report.
|
||||
# Default: 30 (but we recommend 10-20)
|
||||
min-complexity: 20
|
||||
|
||||
gocritic:
|
||||
# Settings passed to gocritic.
|
||||
# The settings key is the name of a supported gocritic checker.
|
||||
# The list of supported checkers can be find in https://go-critic.github.io/overview.
|
||||
settings:
|
||||
captLocal:
|
||||
# Whether to restrict checker to params only.
|
||||
# Default: true
|
||||
paramsOnly: false
|
||||
underef:
|
||||
# Whether to skip (*x).method() calls where x is a pointer receiver.
|
||||
# Default: true
|
||||
skipRecvDeref: false
|
||||
|
||||
gomodguard:
|
||||
blocked:
|
||||
# List of blocked modules.
|
||||
# Default: []
|
||||
modules:
|
||||
- github.com/golang/protobuf:
|
||||
recommendations:
|
||||
- google.golang.org/protobuf
|
||||
reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules"
|
||||
- github.com/satori/go.uuid:
|
||||
recommendations:
|
||||
- github.com/google/uuid
|
||||
reason: "satori's package is not maintained"
|
||||
- github.com/gofrs/uuid:
|
||||
recommendations:
|
||||
- github.com/gofrs/uuid/v5
|
||||
reason: "gofrs' package was not go module before v5"
|
||||
|
||||
govet:
|
||||
# Enable all analyzers.
|
||||
# Default: false
|
||||
enable-all: true
|
||||
# Disable analyzers by name.
|
||||
# Run `go tool vet help` to see all analyzers.
|
||||
# Default: []
|
||||
disable:
|
||||
- fieldalignment # too strict
|
||||
# Settings per analyzer.
|
||||
settings:
|
||||
shadow:
|
||||
# Whether to be strict about shadowing; can be noisy.
|
||||
# Default: false
|
||||
strict: false
|
||||
|
||||
inamedparam:
|
||||
# Skips check for interface methods with only a single parameter.
|
||||
# Default: false
|
||||
skip-single-param: true
|
||||
|
||||
mnd:
|
||||
# List of function patterns to exclude from analysis.
|
||||
# Values always ignored: `time.Date`,
|
||||
# `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`,
|
||||
# `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`.
|
||||
# Default: []
|
||||
ignored-functions:
|
||||
- args.Error
|
||||
- flag.Arg
|
||||
- flag.Duration.*
|
||||
- flag.Float.*
|
||||
- flag.Int.*
|
||||
- flag.Uint.*
|
||||
- os.Chmod
|
||||
- os.Mkdir.*
|
||||
- os.OpenFile
|
||||
- os.WriteFile
|
||||
- prometheus.ExponentialBuckets.*
|
||||
- prometheus.LinearBuckets
|
||||
|
||||
nakedret:
|
||||
# Make an issue if func has more lines of code than this setting, and it has naked returns.
|
||||
# Default: 30
|
||||
max-func-lines: 0
|
||||
|
||||
nolintlint:
|
||||
# Exclude following linters from requiring an explanation.
|
||||
# Default: []
|
||||
allow-no-explanation: [ funlen, gocognit, lll ]
|
||||
# Enable to require an explanation of nonzero length after each nolint directive.
|
||||
# Default: false
|
||||
require-explanation: true
|
||||
# Enable to require nolint directives to mention the specific linter being suppressed.
|
||||
# Default: false
|
||||
require-specific: true
|
||||
|
||||
perfsprint:
|
||||
# Optimizes into strings concatenation.
|
||||
# Default: true
|
||||
strconcat: false
|
||||
|
||||
rowserrcheck:
|
||||
# database/sql is always checked
|
||||
# Default: []
|
||||
packages:
|
||||
- github.com/jmoiron/sqlx
|
||||
|
||||
sloglint:
|
||||
# Enforce not using global loggers.
|
||||
# Values:
|
||||
# - "": disabled
|
||||
# - "all": report all global loggers
|
||||
# - "default": report only the default slog logger
|
||||
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global
|
||||
# Default: ""
|
||||
no-global: "all"
|
||||
# Enforce using methods that accept a context.
|
||||
# Values:
|
||||
# - "": disabled
|
||||
# - "all": report all contextless calls
|
||||
# - "scope": report only if a context exists in the scope of the outermost function
|
||||
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only
|
||||
# Default: ""
|
||||
context: "scope"
|
||||
|
||||
tenv:
|
||||
# The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures.
|
||||
# Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked.
|
||||
# Default: false
|
||||
all: true
|
||||
|
||||
|
||||
issues:
|
||||
# Maximum count of issues with the same text.
|
||||
# Set to 0 to disable.
|
||||
# Default: 3
|
||||
max-same-issues: 50
|
||||
|
||||
exclude-rules:
|
||||
- source: "(noinspection|TODO)"
|
||||
linters: [ godot ]
|
||||
- source: "//noinspection"
|
||||
linters: [ gocritic ]
|
||||
- path: "_test\\.go"
|
||||
linters:
|
||||
- bodyclose
|
||||
- dupl
|
||||
version: "2"
|
||||
linters:
|
||||
settings:
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- '-QF1001'
|
||||
- '-ST1003'
|
||||
- '-ST1016'
|
||||
- '-QF1004'
|
||||
- '-QF1003'
|
||||
cyclop:
|
||||
max-complexity: 30
|
||||
package-average: 10
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
exhaustive:
|
||||
check:
|
||||
- switch
|
||||
- map
|
||||
exhaustruct:
|
||||
exclude:
|
||||
- ^net/http.Client$
|
||||
- ^net/http.Cookie$
|
||||
- ^net/http.Request$
|
||||
- ^net/http.Response$
|
||||
- ^net/http.Server$
|
||||
- ^net/http.Transport$
|
||||
- ^net/url.URL$
|
||||
- ^os/exec.Cmd$
|
||||
- ^reflect.StructField$
|
||||
- ^github.com/Shopify/sarama.Config$
|
||||
- ^github.com/Shopify/sarama.ProducerMessage$
|
||||
- ^github.com/mitchellh/mapstructure.DecoderConfig$
|
||||
- ^github.com/prometheus/client_golang/.+Opts$
|
||||
- ^github.com/spf13/cobra.Command$
|
||||
- ^github.com/spf13/cobra.CompletionOptions$
|
||||
- ^github.com/stretchr/testify/mock.Mock$
|
||||
- ^github.com/testcontainers/testcontainers-go.+Request$
|
||||
- ^github.com/testcontainers/testcontainers-go.FromDockerfile$
|
||||
- ^golang.org/x/tools/go/analysis.Analyzer$
|
||||
- ^google.golang.org/protobuf/.+Options$
|
||||
- ^gopkg.in/yaml.v3.Node$
|
||||
funlen:
|
||||
lines: 100
|
||||
statements: 50
|
||||
ignore-comments: true
|
||||
gocognit:
|
||||
min-complexity: 20
|
||||
gocritic:
|
||||
settings:
|
||||
captLocal:
|
||||
paramsOnly: false
|
||||
underef:
|
||||
skipRecvDeref: false
|
||||
gomodguard:
|
||||
blocked:
|
||||
modules:
|
||||
- github.com/golang/protobuf:
|
||||
recommendations:
|
||||
- google.golang.org/protobuf
|
||||
reason: see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules
|
||||
- github.com/satori/go.uuid:
|
||||
recommendations:
|
||||
- github.com/google/uuid
|
||||
reason: satori's package is not maintained
|
||||
- github.com/gofrs/uuid:
|
||||
recommendations:
|
||||
- github.com/gofrs/uuid/v5
|
||||
reason: gofrs' package was not go module before v5
|
||||
govet:
|
||||
disable:
|
||||
- fieldalignment
|
||||
enable-all: true
|
||||
settings:
|
||||
shadow:
|
||||
strict: false
|
||||
inamedparam:
|
||||
skip-single-param: true
|
||||
mnd:
|
||||
ignored-functions:
|
||||
- args.Error
|
||||
- flag.Arg
|
||||
- flag.Duration.*
|
||||
- flag.Float.*
|
||||
- flag.Int.*
|
||||
- flag.Uint.*
|
||||
- os.Chmod
|
||||
- os.Mkdir.*
|
||||
- os.OpenFile
|
||||
- os.WriteFile
|
||||
- prometheus.ExponentialBuckets.*
|
||||
- prometheus.LinearBuckets
|
||||
nakedret:
|
||||
max-func-lines: 0
|
||||
nolintlint:
|
||||
require-explanation: true
|
||||
require-specific: true
|
||||
allow-no-explanation:
|
||||
- funlen
|
||||
- goconst
|
||||
- gosec
|
||||
- noctx
|
||||
- wrapcheck
|
||||
- gocognit
|
||||
- lll
|
||||
perfsprint:
|
||||
strconcat: false
|
||||
rowserrcheck:
|
||||
packages:
|
||||
- github.com/jmoiron/sqlx
|
||||
sloglint:
|
||||
no-global: all
|
||||
context: scope
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
- linters:
|
||||
- godot
|
||||
source: (noinspection|TODO)
|
||||
- linters:
|
||||
- gocritic
|
||||
source: //noinspection
|
||||
- linters:
|
||||
- bodyclose
|
||||
- dupl
|
||||
- funlen
|
||||
- goconst
|
||||
- gosec
|
||||
- noctx
|
||||
- wrapcheck
|
||||
path: _test\.go
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
issues:
|
||||
max-same-issues: 50
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
+16
-22
@@ -7,38 +7,40 @@ builds:
|
||||
- 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:
|
||||
- arm64
|
||||
- amd64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux amd64
|
||||
# Build configuration for linux arm64
|
||||
- id: linux-arm64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=aarch64-linux-gnu-gcc
|
||||
- CXX=aarch64-linux-gnu-g++
|
||||
tags:
|
||||
- mupdf
|
||||
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 }} # Compress the binary with UPX
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux
|
||||
- id: linux-arm32
|
||||
@@ -60,8 +62,7 @@ builds:
|
||||
- id: windows
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=x86_64-w64-mingw32-gcc
|
||||
- CXX=x86_64-w64-mingw32-g++
|
||||
- CC=zig cc -target x86_64-windows
|
||||
tags:
|
||||
- mupdf
|
||||
ldflags: *ldflags
|
||||
@@ -76,7 +77,7 @@ builds:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
|
||||
# Build configuration for freebsd without arm & upx
|
||||
- id: freeBSD
|
||||
- id: freeBSD and freebsd-arm
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
@@ -84,9 +85,6 @@ builds:
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
|
||||
# Build configuration for macos without upx
|
||||
- id: macos
|
||||
@@ -96,11 +94,8 @@ builds:
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
- amd64
|
||||
|
||||
archives:
|
||||
- name_template: >
|
||||
@@ -113,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
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"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/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -29,44 +30,35 @@ import (
|
||||
|
||||
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 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)
|
||||
}
|
||||
} 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
|
||||
@@ -82,7 +74,7 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -145,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 {
|
||||
@@ -153,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
|
||||
}
|
||||
|
||||
@@ -228,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 {
|
||||
@@ -245,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
|
||||
}
|
||||
@@ -264,9 +270,7 @@ 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)
|
||||
}
|
||||
|
||||
// getContent reads and returns the file content if it's considered an editable text file.
|
||||
@@ -342,7 +346,6 @@ func getContent(realPath string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Handle empty file (original logic - returns specific string)
|
||||
if len(content) == 0 {
|
||||
return "empty-file-x6OlSil", nil
|
||||
|
||||
@@ -20,12 +20,12 @@ func MakeUserDir(fullPath string) 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 && !disableScopeChange {
|
||||
@@ -33,19 +33,19 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
|
||||
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 {
|
||||
@@ -55,7 +55,7 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
|
||||
// just assigning scope to path provided, so just check that it exists
|
||||
path := filepath.Join(source.Path, scope.Scope)
|
||||
if !Exists(path) {
|
||||
return fmt.Errorf("create user: scope folder does not exist: %s", path)
|
||||
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
|
||||
}
|
||||
}
|
||||
u.Scopes[i] = scope
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"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.
|
||||
@@ -63,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
|
||||
}
|
||||
@@ -97,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
|
||||
}
|
||||
|
||||
@@ -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{}{}
|
||||
}
|
||||
@@ -1,278 +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/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
logger.Debugf("hook auth %v", action)
|
||||
|
||||
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, 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
|
||||
}
|
||||
+3
-13
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// JSONAuth is a json implementation of an Auther.
|
||||
@@ -20,10 +19,10 @@ type JSONAuth struct {
|
||||
|
||||
// Auth authenticates the user via a json in content body.
|
||||
func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
|
||||
password := r.URL.Query().Get("password")
|
||||
username := r.URL.Query().Get("username")
|
||||
recaptcha := r.URL.Query().Get("recaptcha")
|
||||
totpCode := r.URL.Query().Get("code")
|
||||
password := r.Header.Get("X-Password")
|
||||
totpCode := r.Header.Get("X-Secret")
|
||||
|
||||
// If ReCaptcha is enabled, check the code.
|
||||
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
|
||||
@@ -54,21 +53,12 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
|
||||
}
|
||||
err = VerifyTotpCode(user, totpCode, userStore)
|
||||
if err != nil {
|
||||
logger.Error("OTP verification failed")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
logger.Warningf("user %s is not allowed to login with password authentication, bypassing and updating login method", user.Username)
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
// Perform the user update
|
||||
err = userStore.Update(user, true, "LoginMethod")
|
||||
if err != nil {
|
||||
logger.Debug(err.Error())
|
||||
}
|
||||
//http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
//return
|
||||
return nil, errors.ErrWrongLoginMethod
|
||||
}
|
||||
|
||||
return user, nil
|
||||
|
||||
+17
-8
@@ -2,10 +2,13 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"log"
|
||||
|
||||
"crypto/sha256"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// StorageBackend is a storage backend for auth storage.
|
||||
@@ -31,17 +34,23 @@ 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
|
||||
}
|
||||
encryptionKey, err = base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
|
||||
if err != nil {
|
||||
logger.Warning("failed to decode TOTP secret, using default key. This is insecure and should be fixed.")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, err
|
||||
}
|
||||
// set cache so verify can attempt to use it but not require it for user yet.
|
||||
TotpCache.Set(user.Username, secretText+"||"+nonce)
|
||||
url := "otpauth://totp/FileBrowser%20Quantum?secret=" + secretText
|
||||
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
@@ -190,11 +190,14 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
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")
|
||||
}
|
||||
// save user
|
||||
if err := userStore.Update(user, user.Permissions.Admin, "TOTPSecret", "TOTPNonce"); err != nil {
|
||||
logger.Debug("error updating user with OTP token:", err)
|
||||
return fmt.Errorf("error updating user with OTP token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+18
-15
@@ -11,10 +11,10 @@ import (
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"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/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -36,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)
|
||||
@@ -53,12 +58,11 @@ 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
|
||||
@@ -81,10 +85,7 @@ 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{
|
||||
@@ -109,14 +110,16 @@ func runCLI() bool {
|
||||
} else {
|
||||
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.Errorf("could not create user: %v", err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
logger.Warningf("user %s is not allowed to login with password authentication, bypassing and updating login method", user.Username)
|
||||
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
|
||||
@@ -255,7 +258,7 @@ func createConfig(configpath string) {
|
||||
if err == nil {
|
||||
var isDir bool
|
||||
// Resolve symlinks and get the real path
|
||||
realPath, isDir, _ = utils.ResolveSymlinks(absolutePath)
|
||||
realPath, isDir, _ = iteminfo.ResolveSymlinks(absolutePath)
|
||||
if realPath != "" && isDir {
|
||||
break // Valid path found, exit loop
|
||||
}
|
||||
|
||||
+14
-4
@@ -7,20 +7,23 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
_ "net/http/pprof"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"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)
|
||||
@@ -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()
|
||||
@@ -110,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")
|
||||
}
|
||||
|
||||
+15
-22
@@ -19,11 +19,8 @@ func validateUserInfo() {
|
||||
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,6 +33,16 @@ 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.")
|
||||
@@ -44,31 +51,17 @@ func validateUserInfo() {
|
||||
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
|
||||
}
|
||||
}
|
||||
err := store.Users.Save(user, false, true)
|
||||
err := store.Users.Save(user, changePass, true)
|
||||
if err != nil {
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings.Config.Auth.ResetAdminOnStart {
|
||||
logger.Info("Resetting admin user to default username and password.")
|
||||
adminUser, err := store.Users.Get(1)
|
||||
if err != nil {
|
||||
logger.Fatalf("could not load admin user: %v", err)
|
||||
}
|
||||
adminUser.Username = settings.Config.Auth.AdminUsername
|
||||
adminUser.Password = settings.Config.Auth.AdminPassword
|
||||
adminUser.Permissions.Admin = true
|
||||
err = store.Users.Save(adminUser, true, true)
|
||||
if err != nil {
|
||||
logger.Errorf("could not Save admin user: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateUserScopes(user *users.User) bool {
|
||||
newScopes := []users.SourceScope{}
|
||||
seen := make(map[string]bool)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
// Build map for existing scopes by Name
|
||||
existing := make(map[string]users.SourceScope)
|
||||
@@ -98,12 +91,12 @@ func updateUserScopes(user *users.User) bool {
|
||||
Name: realsource.Path,
|
||||
Scope: existingScope.Scope,
|
||||
})
|
||||
seen[realsource.Path] = true
|
||||
seen[realsource.Path] = struct{}{}
|
||||
}
|
||||
|
||||
// Preserve user-defined scopes not matching current sources, append to end
|
||||
for _, s := range user.Scopes {
|
||||
if !seen[s.Name] {
|
||||
if _, ok := seen[s.Name]; !ok {
|
||||
newScopes = append(newScopes, s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
}}
|
||||
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.DefaultSource = sourceB
|
||||
settings.Config.Server.SourceMap = map[string]settings.Source{
|
||||
"/pathA": sourceA,
|
||||
"/pathB": sourceB,
|
||||
@@ -161,7 +160,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
}}
|
||||
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.DefaultSource = sourceB
|
||||
settings.Config.Server.SourceMap = map[string]settings.Source{
|
||||
"/pathA": sourceA,
|
||||
"/pathB": sourceB,
|
||||
|
||||
@@ -17,8 +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")
|
||||
)
|
||||
@@ -14,11 +14,10 @@ import (
|
||||
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".
|
||||
ResetAdminOnStart bool `json:"resetAdminOnStart"` // if set to true, the admin user will be reset to the default username and password on startup.
|
||||
TotpSecret string `json:"totpSecret"` // secret used to encrypt TOTP secrets
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
@@ -31,10 +30,10 @@ type LoginMethods struct {
|
||||
|
||||
type PasswordAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MinLength int `json:"minLength" validate:"omitempty,min=5"` // minimum pasword length required.
|
||||
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.
|
||||
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 {
|
||||
@@ -57,9 +56,12 @@ type OidcConfig struct {
|
||||
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 user identifier to use for authentication. Default is "username", can be "email" or "username", or "phone"
|
||||
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
|
||||
}
|
||||
@@ -67,13 +69,19 @@ type OidcConfig struct {
|
||||
// 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")
|
||||
}
|
||||
if oidcCfg.UserIdentifier == "" {
|
||||
oidcCfg.UserIdentifier = "username"
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// If disableVerifyTLS is true, create a custom HTTP client
|
||||
@@ -98,9 +106,6 @@ func validateOidcAuth() error {
|
||||
}
|
||||
oidcCfg.Provider = provider
|
||||
oidcCfg.Verifier = provider.Verifier(&oidc.Config{ClientID: oidcCfg.ClientID})
|
||||
if oidcCfg.Scopes == "" {
|
||||
oidcCfg.Scopes = "openid email profile"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -24,28 +24,30 @@ const (
|
||||
)
|
||||
|
||||
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 += "please review your config for typos and invalid keys which are no longer supported. "
|
||||
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()
|
||||
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),
|
||||
@@ -57,6 +59,32 @@ 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 {
|
||||
@@ -76,6 +104,9 @@ func setupSources(generate bool) {
|
||||
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 == "\\" {
|
||||
@@ -93,10 +124,9 @@ func setupSources(generate bool) {
|
||||
} else {
|
||||
source.Name = name
|
||||
}
|
||||
if Config.Server.DefaultSource.Path == "" {
|
||||
Config.Server.DefaultSource = source
|
||||
}
|
||||
}
|
||||
modifyExcludeInclude(&source)
|
||||
|
||||
if source.Config.DefaultUserScope == "" {
|
||||
source.Config.DefaultUserScope = "/"
|
||||
}
|
||||
@@ -108,8 +138,6 @@ func setupSources(generate bool) {
|
||||
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 {
|
||||
@@ -117,15 +145,8 @@ func setupSources(generate bool) {
|
||||
}
|
||||
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,
|
||||
@@ -136,19 +157,6 @@ func setupSources(generate bool) {
|
||||
logger.Warningf("source %v is not configured correctly, skipping", sourcePathOnly.Path)
|
||||
}
|
||||
}
|
||||
if Config.Server.DefaultSource.Path == "" {
|
||||
Config.Server.DefaultSource = potentialDefaultSource
|
||||
}
|
||||
sourceList2 := []Source{}
|
||||
for _, s := range sourceList {
|
||||
if s.Path == Config.Server.DefaultSource.Path {
|
||||
s.Config.DefaultEnabled = true
|
||||
Config.Server.SourceMap[s.Path] = s
|
||||
Config.Server.NameToSource[s.Name] = s
|
||||
}
|
||||
sourceList2 = append(sourceList2, s)
|
||||
}
|
||||
sourceList = sourceList2
|
||||
Config.UserDefaults.DefaultScopes = defaultScopes
|
||||
Config.Server.Sources = sourceList
|
||||
}
|
||||
@@ -160,13 +168,24 @@ 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")
|
||||
}
|
||||
@@ -180,9 +199,9 @@ func setupAuth() {
|
||||
logger.Warning("Configured with no authentication, this is not recommended.")
|
||||
Config.Auth.AuthMethods = []string{"disabled"}
|
||||
}
|
||||
if Config.Auth.Methods.OidcAuth.Enabled {
|
||||
if Config.Auth.Methods.OidcAuth.Enabled || generate {
|
||||
err := validateOidcAuth()
|
||||
if err != nil {
|
||||
if err != nil && !generate {
|
||||
logger.Fatalf("Error validating OIDC auth: %v", err)
|
||||
}
|
||||
logger.Info("OIDC Auth configured successfully")
|
||||
@@ -206,8 +225,14 @@ func setupLogging() {
|
||||
}
|
||||
}
|
||||
for _, logConfig := range Config.Server.Logging {
|
||||
// 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: logConfig.Levels,
|
||||
Levels: levels,
|
||||
ApiLevels: logConfig.ApiLevels,
|
||||
Output: logConfig.Output,
|
||||
Utc: logConfig.Utc,
|
||||
@@ -221,12 +246,21 @@ func setupLogging() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
logger.Warningf("could not open config file '%v', using default settings", configFile)
|
||||
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()
|
||||
@@ -260,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")
|
||||
@@ -277,20 +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
|
||||
if cpus := runtime.NumCPU(); cpus > 0 {
|
||||
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: numCpus,
|
||||
BaseURL: "",
|
||||
Database: "database.db",
|
||||
Database: database,
|
||||
SourceMap: map[string]Source{},
|
||||
NameToSource: map[string]Source{},
|
||||
MaxArchiveSizeGB: 50,
|
||||
@@ -298,7 +366,6 @@ func setDefaults() Settings {
|
||||
},
|
||||
Auth: Auth{
|
||||
AdminUsername: "admin",
|
||||
AdminPassword: "admin",
|
||||
TokenExpirationHours: 2,
|
||||
Methods: LoginMethods{
|
||||
PasswordAuth: PasswordAuthConfig{
|
||||
@@ -311,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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -439,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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -79,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()
|
||||
@@ -167,7 +174,7 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
|
||||
}
|
||||
|
||||
func GenerateYaml() {
|
||||
_ = loadConfigWithDefaults("")
|
||||
_ = loadConfigWithDefaults("", true)
|
||||
Config.Server.Sources = []Source{
|
||||
{
|
||||
Path: ".",
|
||||
@@ -175,10 +182,10 @@ func GenerateYaml() {
|
||||
}
|
||||
|
||||
setupLogging()
|
||||
setupAuth()
|
||||
setupAuth(true)
|
||||
setupSources(true)
|
||||
setupUrls()
|
||||
setupFrontend()
|
||||
setupFrontend(true)
|
||||
input := "common/settings/settings.go" // "path to Go source file or directory containing structs"
|
||||
output := "generated.yaml" // "output YAML file"
|
||||
|
||||
@@ -219,27 +226,35 @@ func formatLine(line string) string {
|
||||
const targetCol = 42
|
||||
const defaultPadding = 1
|
||||
|
||||
idx := strings.Index(line, "#")
|
||||
// If there's no comment, do nothing.
|
||||
if idx == -1 {
|
||||
return line
|
||||
// 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
|
||||
}
|
||||
paddingNeeded := targetCol - len(trimmedContent)
|
||||
return trimmedContent + strings.Repeat(" ", paddingNeeded) + commentPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separate the content (before #) from the comment (from # onwards).
|
||||
contentPart := line[:idx]
|
||||
commentPart := line[idx:]
|
||||
|
||||
// The YAML encoder adds a space; trim it so we measure only the content length.
|
||||
trimmedContent := strings.TrimRight(contentPart, " ")
|
||||
|
||||
// If the content already extends past our target column, just add a single space.
|
||||
if len(trimmedContent) >= targetCol {
|
||||
return trimmedContent + strings.Repeat(" ", defaultPadding) + commentPart
|
||||
}
|
||||
|
||||
// Otherwise, calculate the needed padding to reach the target column and add it.
|
||||
paddingNeeded := targetCol - len(trimmedContent)
|
||||
return trimmedContent + strings.Repeat(" ", paddingNeeded) + commentPart
|
||||
// No comment found
|
||||
return line
|
||||
}
|
||||
|
||||
// alignComments formats each line of the YAML string independently.
|
||||
|
||||
@@ -49,7 +49,7 @@ func ApplyUserDefaults(u *users.User) {
|
||||
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
|
||||
|
||||
@@ -34,15 +34,14 @@ type Server struct {
|
||||
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"`
|
||||
PdfAvailable bool `json:"-"` // used internally if compiled with pdf support
|
||||
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 {
|
||||
@@ -78,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 {
|
||||
@@ -102,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 {
|
||||
@@ -113,21 +138,33 @@ type ExternalLink struct {
|
||||
// UserDefaults is a type that holds the default values
|
||||
// for some fields on User.
|
||||
type UserDefaults struct {
|
||||
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
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // comma separated list of file extensions to disable onlyoffice preview 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
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,12 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
math "math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -52,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)
|
||||
@@ -60,7 +70,7 @@ 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.Errorf("expired share could not be deleted: %v", err.Error())
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ 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{}
|
||||
|
||||
@@ -126,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)
|
||||
@@ -145,6 +158,9 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
|
||||
if user.LoginMethod == "" {
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
}
|
||||
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)
|
||||
@@ -226,10 +242,11 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
field := t.Field(i)
|
||||
// which=all can't update password
|
||||
switch strings.ToLower(field.Name) {
|
||||
case "id", "username", "loginmethod", "password":
|
||||
case "id", "username", "password", "apikeys", "totpenabled", "totpsecret", "totpnonce":
|
||||
// Skip these fields
|
||||
continue
|
||||
}
|
||||
|
||||
fields = append(fields, field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,27 +7,16 @@ import (
|
||||
"strings"
|
||||
|
||||
storm "github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"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)
|
||||
@@ -39,20 +28,18 @@ func InitializeDb(path string) (*Storage, bool, error) {
|
||||
}
|
||||
logger.Fatalf("could not open database: %v", err)
|
||||
}
|
||||
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
|
||||
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)
|
||||
}
|
||||
@@ -80,33 +67,40 @@ 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.Debugf("Creating user as admin: %v %v", user.Username, user.Password)
|
||||
err = store.Users.Save(user, true, 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
|
||||
if userInfo.LoginMethod == "password" {
|
||||
if userInfo.Password == "" {
|
||||
@@ -119,17 +113,13 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
|
||||
}
|
||||
userInfo.Password = hashpass
|
||||
}
|
||||
// must have username or password to create
|
||||
// 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)
|
||||
settings.ApplyUserDefaults(newUser)
|
||||
if asAdmin {
|
||||
userInfo.Permissions = settings.AdminPerms()
|
||||
}
|
||||
// create new home directories
|
||||
err := store.Users.Save(newUser, true, false)
|
||||
err := userStore.Save(newUser, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.
|
||||
@@ -29,8 +30,43 @@ type Store interface {
|
||||
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{},
|
||||
}
|
||||
@@ -124,18 +161,8 @@ func (s *Storage) Save(user *User, changePass, disableScopeChange 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
|
||||
|
||||
@@ -41,12 +41,14 @@ type Sorting struct {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -75,30 +77,41 @@ type SourceScope struct {
|
||||
|
||||
// json tags must match variable name with smaller case first letter
|
||||
type NonAdminEditable struct {
|
||||
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
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // comma separated list of file extensions to disable onlyoffice preview for
|
||||
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 {
|
||||
|
||||
+90
-70
@@ -1,90 +1,103 @@
|
||||
module github.com/gtsteffaniak/filebrowser/backend
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.14.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/gen2brain/go-fitz v1.24.14
|
||||
github.com/go-playground/validator/v10 v10.26.0
|
||||
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/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/shirou/gopsutil/v3 v3.24.5
|
||||
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
|
||||
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.33.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/boombuler/barcode v1.0.2 // 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/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-jose/go-jose/v4 v4.1.0 // 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
|
||||
@@ -101,16 +114,18 @@ require (
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289 // 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
|
||||
@@ -122,28 +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.4.1 // 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/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,25 +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/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
|
||||
@@ -178,19 +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/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
|
||||
@@ -201,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
|
||||
)
|
||||
+275
-152
@@ -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,45 +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/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.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
|
||||
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
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/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=
|
||||
@@ -124,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.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
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/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=
|
||||
@@ -176,26 +230,33 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
|
||||
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/gen2brain/go-fitz v1.24.14 h1:09weRkjVtLYNGo7l0J7DyOwBExbwi8SJ9h8YPhw9WEo=
|
||||
github.com/gen2brain/go-fitz v1.24.14/go.mod h1:0KaZeQgASc20Yp5R/pFzyy7SmP01XcoHKNF842U2/S4=
|
||||
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=
|
||||
@@ -205,8 +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.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
||||
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=
|
||||
@@ -215,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=
|
||||
@@ -232,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=
|
||||
@@ -262,6 +320,8 @@ 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-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
@@ -269,13 +329,29 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
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-20250516193853-92f93c4cb289 h1:HeOFbnyPys/vx/t+d4fwZM782mnjRVtbjxVkDittTUs=
|
||||
github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289/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=
|
||||
@@ -313,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=
|
||||
@@ -373,6 +451,8 @@ github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063 h1:CoLbeqTmM
|
||||
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=
|
||||
@@ -391,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=
|
||||
@@ -412,8 +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.4.1 h1:Obg+xpuA+rRkRTEN7xu3IagjNDMO8fRUhOPpLEztPYI=
|
||||
github.com/jupiterrider/ffi v0.4.1/go.mod h1:Ba3hjU7Sz2+TO8HupjZwGssVnzo7RG3xF77byUBueB4=
|
||||
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=
|
||||
@@ -423,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=
|
||||
@@ -433,34 +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/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=
|
||||
@@ -474,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=
|
||||
@@ -510,20 +616,22 @@ 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=
|
||||
@@ -550,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=
|
||||
@@ -571,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=
|
||||
@@ -583,10 +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/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=
|
||||
@@ -597,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=
|
||||
@@ -642,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=
|
||||
@@ -672,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=
|
||||
@@ -687,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=
|
||||
@@ -724,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=
|
||||
@@ -736,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=
|
||||
@@ -774,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=
|
||||
@@ -820,8 +939,8 @@ 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=
|
||||
@@ -845,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=
|
||||
@@ -859,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=
|
||||
@@ -881,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=
|
||||
@@ -892,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=
|
||||
@@ -927,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=
|
||||
@@ -988,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=
|
||||
@@ -1071,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=
|
||||
@@ -1103,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=
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
+36
-36
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/golang-jwt/jwt/v4/request"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
@@ -50,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, " ")
|
||||
@@ -76,10 +76,12 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
return nil, err
|
||||
}
|
||||
if config.Auth.Methods.ProxyAuth.CreateUser {
|
||||
err = storage.CreateUser(users.User{
|
||||
user := users.User{
|
||||
LoginMethod: users.LoginMethodProxy,
|
||||
Username: proxyUser,
|
||||
}, false)
|
||||
}
|
||||
settings.ApplyUserDefaults(&user)
|
||||
err = storage.CreateUser(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -92,14 +94,7 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
}
|
||||
}
|
||||
if data.user.LoginMethod != users.LoginMethodProxy {
|
||||
logger.Warningf("user %s is not allowed to login with proxy authentication, bypassing and updating login method", data.user.Username)
|
||||
data.user.LoginMethod = users.LoginMethodProxy
|
||||
// Perform the user update
|
||||
err := store.Users.Update(data.user, true, "LoginMethod")
|
||||
if err != nil {
|
||||
logger.Debug(err.Error())
|
||||
}
|
||||
//return nil, fmt.Errorf("user %s is not allowed to login with proxy authentication", proxyUser)
|
||||
return nil, errors.ErrWrongLoginMethod
|
||||
}
|
||||
return data.user, nil
|
||||
}
|
||||
@@ -124,40 +119,44 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
return printToken(w, r, d.user) // Pass the data object
|
||||
}
|
||||
|
||||
// logoutHandler handles user logout, specifically used for OIDC or proxy users.
|
||||
// logoutHandler handles user logout
|
||||
// @Summary User Logout
|
||||
// @Description logs a user out of the application.
|
||||
// @Description Returns a logout URL for the frontend to redirect to.
|
||||
// @Tags Auth
|
||||
// @Success 302 {string} string "Redirect to redirect URL if configured in oidc config."
|
||||
// @Router /api/auth/logout [get]
|
||||
// @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) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
|
||||
}
|
||||
logoutUrl := fmt.Sprintf("%s/login", origin)
|
||||
proxyRedirectUrl := config.Auth.Methods.ProxyAuth.LogoutRedirectUrl
|
||||
oidcRedirectUrl := settings.Config.Auth.Methods.OidcAuth.LogoutRedirectUrl
|
||||
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
|
||||
|
||||
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
|
||||
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 {
|
||||
} else if d.user.LoginMethod == users.LoginMethodOidc {
|
||||
oidcRedirectUrl := config.Auth.Methods.OidcAuth.LogoutRedirectUrl
|
||||
if oidcRedirectUrl != "" {
|
||||
logoutUrl = oidcRedirectUrl
|
||||
}
|
||||
}
|
||||
// clear cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "auth",
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
Path: "/",
|
||||
})
|
||||
http.Redirect(w, r, logoutUrl, http.StatusFound)
|
||||
return 302, nil
|
||||
if logoutUrl == "" {
|
||||
logger.Debug("no logout url found, using default")
|
||||
logoutUrl = fmt.Sprintf("%vlogin", config.Server.BaseURL)
|
||||
}
|
||||
response := map[string]string{
|
||||
"logoutUrl": logoutUrl,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
err := json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
type signupBody struct {
|
||||
@@ -197,7 +196,8 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
err = storage.CreateUser(user, false)
|
||||
user.Permissions = settings.Config.UserDefaults.Permissions
|
||||
err = storage.CreateUser(user)
|
||||
if err != nil {
|
||||
logger.Debug(err.Error())
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
|
||||
@@ -8,6 +8,17 @@ import (
|
||||
"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{}
|
||||
|
||||
+143
-28
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
@@ -13,7 +14,7 @@ import (
|
||||
|
||||
"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,15 +35,26 @@ 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
|
||||
@@ -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))
|
||||
@@ -73,7 +96,7 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
|
||||
|
||||
// Auth routes
|
||||
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
|
||||
api.HandleFunc("GET /auth/logout", withoutUser(logoutHandler))
|
||||
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))
|
||||
@@ -97,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))
|
||||
|
||||
@@ -118,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{
|
||||
@@ -201,6 +302,20 @@ 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()
|
||||
|
||||
+197
-38
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +15,7 @@ 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/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
@@ -20,13 +23,13 @@ import (
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -42,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
|
||||
@@ -61,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.Errorf("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
|
||||
@@ -100,6 +119,81 @@ 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
|
||||
@@ -114,7 +208,7 @@ func userWithoutOTPhelper(fn handleFunc) handleFunc {
|
||||
// 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.URL.Query().Get("password")
|
||||
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)
|
||||
@@ -158,6 +252,9 @@ func withUserHelper(fn handleFunc) handleFunc {
|
||||
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)
|
||||
@@ -168,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) {
|
||||
@@ -186,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)
|
||||
}
|
||||
}
|
||||
@@ -285,6 +388,10 @@ 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))
|
||||
}
|
||||
@@ -345,28 +452,80 @@ 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)
|
||||
|
||||
// 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()),
|
||||
|
||||
@@ -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{
|
||||
@@ -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
|
||||
|
||||
+195
-97
@@ -2,12 +2,16 @@ 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"
|
||||
@@ -18,19 +22,108 @@ import (
|
||||
|
||||
// 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"`
|
||||
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
|
||||
}
|
||||
|
||||
// oidcCallbackHandler handles the OIDC callback after the user authenticates with the provider.
|
||||
// It exchanges the authorization code for tokens, attempts to verify the ID token.
|
||||
// If ID token verification or essential claims extraction fails, it falls back to the UserInfo endpoint.
|
||||
// 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
|
||||
@@ -57,7 +150,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// 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/api/auth/oidc/callback", getScheme(r), r.Host)
|
||||
redirectURL := fmt.Sprintf("%s://%s%sapi/auth/oidc/callback", getScheme(r), r.Host, config.Server.BaseURL)
|
||||
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: oidcCfg.ClientID,
|
||||
@@ -81,6 +174,12 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
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.")
|
||||
@@ -89,35 +188,19 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
|
||||
idToken, err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
logger.Warningf("failed to verify ID token: %v. Falling back to UserInfo endpoint.", err)
|
||||
// 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 {
|
||||
var claims map[string]interface{}
|
||||
// Decode the ID token claims into a map to handle arbitrary structure
|
||||
// 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(&claims); err != nil {
|
||||
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", claims)
|
||||
logger.Debugf("ID Token verified and claims decoded: %+v", userdata)
|
||||
|
||||
// Populate userdata from ID token claims
|
||||
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
|
||||
}
|
||||
// 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 {
|
||||
@@ -127,6 +210,11 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
loginUsername = userdata.Email
|
||||
}
|
||||
case "username":
|
||||
if userdata.Username != "" {
|
||||
claimsFromIDToken = true
|
||||
loginUsername = userdata.Username
|
||||
}
|
||||
case "preferred_username":
|
||||
if userdata.PreferredUsername != "" {
|
||||
claimsFromIDToken = true
|
||||
loginUsername = userdata.PreferredUsername
|
||||
@@ -153,9 +241,9 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
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 directly into the userdata struct
|
||||
// Decode the UserInfo response using custom unmarshaller
|
||||
// The UserInfo endpoint is expected to return standard JSON
|
||||
if err := userInfoResp.Claims(&userdata); err != nil {
|
||||
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)
|
||||
}
|
||||
@@ -165,89 +253,88 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
case "email":
|
||||
loginUsername = userdata.Email
|
||||
case "username":
|
||||
loginUsername = userdata.Username
|
||||
case "preferred_username":
|
||||
loginUsername = userdata.PreferredUsername
|
||||
case "phone":
|
||||
loginUsername = userdata.Phone
|
||||
}
|
||||
}
|
||||
if loginUsername == "" {
|
||||
logger.Error("No valid username found in ID token or UserInfo response.")
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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/api/auth/oidc/callback", origin),
|
||||
Scopes: strings.Split(oidcCfg.Scopes, " "),
|
||||
}
|
||||
|
||||
nonce := utils.InsecureRandomIdentifier(16)
|
||||
fbRedirect := r.URL.Query().Get("fb_redirect")
|
||||
state := fmt.Sprintf("%s:%s", nonce, fbRedirect)
|
||||
|
||||
authURL := oauth2Config.AuthCodeURL(state)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
return 0, nil
|
||||
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) (int, error) {
|
||||
logger.Debugf("Successfully authenticated OIDC username: %s", username)
|
||||
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
|
||||
}
|
||||
|
||||
err = storage.CreateUser(users.User{
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
Username: username,
|
||||
}, false)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
user, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
} 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 {
|
||||
logger.Warningf("user %s is not allowed to login with oidc authentication, bypassing and updating login method", user.Username)
|
||||
user.LoginMethod = users.LoginMethodOidc
|
||||
err = store.Users.Update(user, true, "LoginMethod")
|
||||
if err != nil {
|
||||
logger.Debug(err.Error())
|
||||
}
|
||||
//return http.StatusForbidden, fmt.Errorf("user %s is not allowed to login with OIDC", username)
|
||||
return http.StatusForbidden, errors.ErrWrongLoginMethod
|
||||
}
|
||||
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
|
||||
if err != nil {
|
||||
|
||||
// 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(err.Error(), "key already exists with same name") {
|
||||
return http.StatusConflict, fmt.Errorf("token name conflict: %v", err)
|
||||
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)
|
||||
}
|
||||
@@ -272,20 +359,31 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string)
|
||||
// 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
|
||||
|
||||
fbRedirect := config.Server.BaseURL // Default redirect to the base URL
|
||||
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
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+18
-14
@@ -40,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 {
|
||||
@@ -72,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, ".")
|
||||
@@ -84,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,
|
||||
},
|
||||
@@ -109,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 != "" {
|
||||
@@ -134,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 {
|
||||
|
||||
+15
-16
@@ -46,15 +46,11 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = settings.Config.Server.DefaultSource.Name
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
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")
|
||||
@@ -64,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
|
||||
@@ -97,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")
|
||||
@@ -147,6 +145,7 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
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
|
||||
}
|
||||
+34
-58
@@ -7,11 +7,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"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/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.Errorf("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,39 +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
|
||||
} else {
|
||||
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")
|
||||
}
|
||||
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.Debugf("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)
|
||||
}
|
||||
+103
-27
@@ -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/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
|
||||
}
|
||||
@@ -236,7 +291,15 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -304,7 +367,15 @@ 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.Errorf("Failed to copy archive data to response: %v", 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
|
||||
}
|
||||
|
||||
+232
-87
@@ -2,7 +2,10 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -12,6 +15,7 @@ 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/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
@@ -21,6 +25,35 @@ import (
|
||||
"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,32 +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
|
||||
} else {
|
||||
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)
|
||||
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
|
||||
@@ -103,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"
|
||||
@@ -114,15 +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
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
@@ -137,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
|
||||
@@ -163,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"
|
||||
@@ -175,37 +204,136 @@ 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
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
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" {
|
||||
@@ -213,15 +341,11 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
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
|
||||
|
||||
}
|
||||
@@ -235,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"
|
||||
@@ -245,15 +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
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
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")
|
||||
@@ -274,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
|
||||
@@ -350,7 +471,7 @@ 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.Debugf("Could not get real path for parent dir: %v %v %v", userscopeDst, filepath.Dir(dst), err)
|
||||
return http.StatusNotFound, err
|
||||
@@ -375,7 +496,24 @@ 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.Debugf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err)
|
||||
}
|
||||
@@ -398,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,
|
||||
})
|
||||
@@ -421,21 +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
|
||||
} else {
|
||||
// decode url encoded source name
|
||||
source, _ = url.QueryUnescape(source)
|
||||
}
|
||||
// Decode the URL-encoded path
|
||||
path, _ := url.QueryUnescape(encodedPath)
|
||||
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
|
||||
@@ -456,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
-11
@@ -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,16 +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
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
scope := r.URL.Query().Get("scope")
|
||||
unencodedScope, err := url.QueryUnescape(scope)
|
||||
if err != nil {
|
||||
@@ -85,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 = "/"
|
||||
}
|
||||
|
||||
+336
-65
@@ -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,36 +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
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -104,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")
|
||||
|
||||
@@ -130,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
|
||||
@@ -190,40 +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
|
||||
} else {
|
||||
var err error
|
||||
// decode url encoded source name
|
||||
sourceName, err = url.QueryUnescape(sourceName)
|
||||
|
||||
// Validate source exists
|
||||
sourceInfo, ok := config.Server.NameToSource[source]
|
||||
if !ok {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
|
||||
}
|
||||
|
||||
// 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 source encoding: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid downloadCount: %v", err)
|
||||
}
|
||||
}
|
||||
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,
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.Share.Save(s); err != nil {
|
||||
// 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) {
|
||||
@@ -253,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
@@ -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,
|
||||
"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.SourceMap),
|
||||
"OidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
|
||||
"PasswordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"MediaAvailable": config.Integrations.Media.FfmpegPath != "",
|
||||
"PdfAvailable": config.Server.PdfAvailable,
|
||||
"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")
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid TOTP token"
|
||||
// @Router /api/auth/otp/verify [post]
|
||||
func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
code := r.URL.Query().Get("code")
|
||||
code := r.Header.Get("X-Secret")
|
||||
if code == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("code is required")
|
||||
}
|
||||
|
||||
+40
-25
@@ -15,23 +15,22 @@ import (
|
||||
)
|
||||
|
||||
type UserRequest struct {
|
||||
What string `json:"what"`
|
||||
Which []string `json:"which"`
|
||||
Data *users.User `json:"data"`
|
||||
Which []string `json:"which"`
|
||||
User users.User `json:"data"`
|
||||
}
|
||||
|
||||
// userGetHandler retrieves a user by ID.
|
||||
// @Summary Retrieve a user by ID
|
||||
// @Description Returns a user's details based on their ID.
|
||||
// @Description Returns a user's details based on their ID, or all users if no id is provided.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID" or "self"
|
||||
// @Success 200 {object} users.User "User details"
|
||||
// @Param id query string false "User ID or 'self'"
|
||||
// @Success 200 {object} users.User "User details or list of users"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Not Found"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users/{id} [get]
|
||||
// @Router /api/users [get]
|
||||
func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
givenUserIdString := r.URL.Query().Get("id")
|
||||
|
||||
@@ -100,11 +99,11 @@ func prepForFrontend(u *users.User) {
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Param id query string true "User ID"
|
||||
// @Success 200 "User deleted successfully"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users/{id} [delete]
|
||||
// @Router /api/users [delete]
|
||||
func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
givenUserIdString := r.URL.Query().Get("id")
|
||||
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
|
||||
@@ -118,10 +117,6 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
return http.StatusForbidden, fmt.Errorf("cannot delete users without admin permissions")
|
||||
}
|
||||
|
||||
if givenUserId == 1 {
|
||||
return http.StatusForbidden, fmt.Errorf("cannot delete the default admin user")
|
||||
}
|
||||
|
||||
// Delete the user
|
||||
err := store.Users.Delete(givenUserId)
|
||||
if err != nil {
|
||||
@@ -157,20 +152,24 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
r.Body.Close()
|
||||
|
||||
if req.User.Username == "" {
|
||||
return http.StatusBadRequest, errors.ErrEmptyUsername
|
||||
}
|
||||
|
||||
if len(req.Which) != 0 {
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
if req.Data.Password == "" && req.Data.LoginMethod == "password" {
|
||||
if req.User.Password == "" && req.User.LoginMethod == "password" {
|
||||
return http.StatusBadRequest, errors.ErrEmptyPassword
|
||||
}
|
||||
|
||||
err = storage.CreateUser(*req.Data, req.Data.Permissions.Admin)
|
||||
err = storage.CreateUser(req.User)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
w.Header().Set("Location", "/settings/users/"+strconv.FormatUint(uint64(req.Data.ID), 10))
|
||||
w.Header().Set("Location", "/settings/users/"+strconv.FormatUint(uint64(req.User.ID), 10))
|
||||
return http.StatusCreated, nil
|
||||
}
|
||||
|
||||
@@ -180,15 +179,17 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Param id query string false "user ID to update"
|
||||
// @Param id query string false "usename to update"
|
||||
// @Param data body users.User true "User data to update"
|
||||
// @Success 200 {object} users.User "Updated user details"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users/{id} [put]
|
||||
// @Router /api/users [put]
|
||||
func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
givenUserIdString := r.URL.Query().Get("id")
|
||||
username := r.URL.Query().Get("username")
|
||||
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
|
||||
givenUserId := uint(num)
|
||||
|
||||
@@ -208,18 +209,32 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
if err = json.Unmarshal(body, &req); err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
if !req.Data.OtpEnabled {
|
||||
req.Data.TOTPSecret = ""
|
||||
req.Data.TOTPNonce = ""
|
||||
if givenUserId != 0 {
|
||||
u, err2 := store.Users.Get(givenUserId)
|
||||
if err2 != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("no user not found, please provide a valid id or username")
|
||||
}
|
||||
req.User.ID = u.ID
|
||||
req.User.Username = u.Username
|
||||
} else {
|
||||
u, err2 := store.Users.Get(username)
|
||||
if err2 != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("no user not found, please provide a valid id or username")
|
||||
}
|
||||
req.User.ID = u.ID
|
||||
req.User.Username = u.Username
|
||||
}
|
||||
if !req.User.OtpEnabled {
|
||||
req.User.TOTPSecret = ""
|
||||
req.User.TOTPNonce = ""
|
||||
}
|
||||
|
||||
// Perform the user update
|
||||
err = store.Users.Update(req.Data, d.user.Permissions.Admin, req.Which...)
|
||||
err = store.Users.Update(&req.User, d.user.Permissions.Admin, req.Which...)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
// Return the updated user (with the password hidden) as JSON response
|
||||
req.Data.Password = ""
|
||||
return renderJSON(w, r, req.Data)
|
||||
req.User.Password = ""
|
||||
return renderJSON(w, r, req.User)
|
||||
}
|
||||
@@ -22,8 +22,6 @@ func errToStatus(err error) int {
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, libErrors.ErrInvalidRequestParams):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, libErrors.ErrRootUserDeletion):
|
||||
return http.StatusForbidden
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -3,7 +3,32 @@
|
||||
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func CheckWindowsHidden(realpath string) bool {
|
||||
// Non-Windows platforms don't support hidden attributes in the same way
|
||||
return false
|
||||
}
|
||||
|
||||
// getFilesystemSize returns the filesystem size (fallback method)
|
||||
func getPartitionSize(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Total size in bytes: Blocks * Block size
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func getFileDetails(sys any) (uint64, uint64, uint64, bool) {
|
||||
if stat, ok := sys.(*syscall.Stat_t); ok {
|
||||
// Use allocated size for `du`-like behavior
|
||||
realSize := uint64(stat.Blocks * 512)
|
||||
return realSize, uint64(stat.Nlink), stat.Ino, true
|
||||
}
|
||||
return 0, 1, 0, false
|
||||
}
|
||||
@@ -31,3 +31,23 @@ func CheckWindowsHidden(realpath string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getPartitionSize(path string) (uint64, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var freeBytes, totalBytes, totalFreeBytes uint64
|
||||
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalBytes, nil
|
||||
}
|
||||
|
||||
func getFileDetails(sys any) (uint64, uint64, uint64, bool) {
|
||||
// On Windows, os.FileInfo.Sys() returns *syscall.Win32FileAttributeData,
|
||||
// which does not contain inode or link count information.
|
||||
// We return false to indicate that we should use the fallback mechanism.
|
||||
return 0, 1, 0, false
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/indexing/iteminfo"
|
||||
@@ -22,8 +23,8 @@ var RealPathCache = cache.NewCache(48*time.Hour, 72*time.Hour)
|
||||
// reduced index is json exposed to the client
|
||||
type ReducedIndex struct {
|
||||
IdxName string `json:"name"`
|
||||
DiskUsed int64 `json:"used"`
|
||||
DiskTotal int64 `json:"total"`
|
||||
DiskUsed uint64 `json:"used"`
|
||||
DiskTotal uint64 `json:"total"`
|
||||
Status IndexStatus `json:"status"`
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
@@ -39,11 +40,16 @@ type Index struct {
|
||||
CurrentSchedule int `json:"-"`
|
||||
settings.Source `json:"-"`
|
||||
Directories map[string]*iteminfo.FileInfo `json:"-"`
|
||||
DirectoriesLedger map[string]struct{} `json:"-"`
|
||||
runningScannerCount int `json:"-"`
|
||||
SmartModifier time.Duration `json:"-"`
|
||||
FilesChangedDuringIndexing bool `json:"-"`
|
||||
mock bool
|
||||
mu sync.RWMutex
|
||||
hasIndex bool
|
||||
FoundHardLinks map[string]uint64 `json:"-"` // hardlink path -> size
|
||||
processedInodes map[uint64]struct{} `json:"-"`
|
||||
totalSize uint64 `json:"-"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -66,34 +72,38 @@ func init() {
|
||||
func Initialize(source settings.Source, mock bool) {
|
||||
indexesMutex.Lock()
|
||||
newIndex := Index{
|
||||
mock: mock,
|
||||
Source: source,
|
||||
Directories: make(map[string]*iteminfo.FileInfo),
|
||||
mock: mock,
|
||||
Source: source,
|
||||
Directories: make(map[string]*iteminfo.FileInfo),
|
||||
DirectoriesLedger: make(map[string]struct{}),
|
||||
processedInodes: make(map[uint64]struct{}),
|
||||
FoundHardLinks: make(map[string]uint64),
|
||||
}
|
||||
newIndex.ReducedIndex = ReducedIndex{
|
||||
Status: "indexing",
|
||||
IdxName: source.Name,
|
||||
Assessment: "unknown",
|
||||
}
|
||||
indexes[newIndex.Source.Name] = &newIndex
|
||||
indexes[newIndex.Name] = &newIndex
|
||||
indexesMutex.Unlock()
|
||||
if !newIndex.Source.Config.Disabled {
|
||||
if !newIndex.Config.DisableIndexing {
|
||||
time.Sleep(time.Second)
|
||||
logger.Infof("initializing index: [%v]", newIndex.Source.Name)
|
||||
logger.Infof("initializing index: [%v]", newIndex.Name)
|
||||
newIndex.RunIndexing("/", false)
|
||||
go newIndex.setupIndexingScanners()
|
||||
} else {
|
||||
logger.Debug("indexing disabled for source: " + newIndex.Source.Name)
|
||||
newIndex.Status = "ready"
|
||||
logger.Debug("indexing disabled for source: " + newIndex.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Define a function to recursively index files and directories
|
||||
func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) error {
|
||||
realPath := strings.TrimRight(idx.Source.Path, "/") + adjustedPath
|
||||
realPath := strings.TrimRight(idx.Path, "/") + adjustedPath
|
||||
// Open the directory
|
||||
dir, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
idx.RemoveDirectory(adjustedPath) // Remove, must have been deleted
|
||||
// must have been deleted
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
@@ -102,6 +112,20 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if excluded from indexing
|
||||
hidden := isHidden(dirInfo, idx.Path+adjustedPath)
|
||||
if idx.shouldSkip(dirInfo.IsDir(), hidden, adjustedPath, dirInfo.Name()) {
|
||||
return errors.ErrNotIndexed
|
||||
}
|
||||
|
||||
// if indexing, mark the directory as valid and indexed.
|
||||
if recursive {
|
||||
// Prevent race conditions if scanning becomes concurrent in the future.
|
||||
idx.mu.Lock()
|
||||
idx.DirectoriesLedger[adjustedPath] = struct{}{}
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
combinedPath := adjustedPath + "/"
|
||||
if adjustedPath == "/" {
|
||||
combinedPath = "/"
|
||||
@@ -109,9 +133,9 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
|
||||
// get whats currently in cache
|
||||
idx.mu.RLock()
|
||||
cacheDirItems := []iteminfo.ItemInfo{}
|
||||
modChange := true // default to true
|
||||
modChange := false
|
||||
cachedDir, exists := idx.Directories[adjustedPath]
|
||||
if exists && quick {
|
||||
if exists {
|
||||
modChange = dirInfo.ModTime() != cachedDir.ModTime
|
||||
cacheDirItems = cachedDir.Folders
|
||||
}
|
||||
@@ -119,55 +143,141 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
|
||||
|
||||
// If the directory has not been modified since the last index, skip expensive readdir
|
||||
// recursively check cached dirs for mod time changes as well
|
||||
if !modChange && recursive {
|
||||
for _, item := range cacheDirItems {
|
||||
err = idx.indexDirectory(combinedPath+item.Name, quick, true)
|
||||
if err != nil {
|
||||
logger.Errorf("error indexing directory %v : %v", combinedPath+item.Name, err)
|
||||
if recursive {
|
||||
if modChange {
|
||||
idx.mu.Lock()
|
||||
idx.FilesChangedDuringIndexing = true
|
||||
idx.mu.Unlock()
|
||||
} else if quick {
|
||||
for _, item := range cacheDirItems {
|
||||
err = idx.indexDirectory(combinedPath+item.Name, quick, true)
|
||||
if err != nil && err != errors.ErrNotIndexed {
|
||||
logger.Errorf("error indexing directory %v : %v", combinedPath+item.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
dirFileInfo, err2 := idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, quick, recursive, true)
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
// Update the current directory metadata in the index
|
||||
idx.UpdateMetadata(dirFileInfo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error) {
|
||||
realPath, isDir, err := idx.GetRealPath(adjustedPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
originalPath := realPath
|
||||
|
||||
dir, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dir.Close()
|
||||
|
||||
dirInfo, err := dir.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !dirInfo.IsDir() {
|
||||
fileInfo := iteminfo.FileInfo{
|
||||
Path: adjustedPath,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: filepath.Base(originalPath),
|
||||
Size: dirInfo.Size(),
|
||||
ModTime: dirInfo.ModTime(),
|
||||
},
|
||||
}
|
||||
fileInfo.DetectType(adjustedPath, false)
|
||||
return &fileInfo, nil
|
||||
}
|
||||
combinedPath := adjustedPath + "/"
|
||||
if adjustedPath == "/" {
|
||||
combinedPath = "/"
|
||||
}
|
||||
var response *iteminfo.FileInfo
|
||||
response, err = idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, false, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isDir {
|
||||
baseName := filepath.Base(originalPath)
|
||||
idx.MakeIndexPath(realPath)
|
||||
found := false
|
||||
for _, item := range response.Files {
|
||||
if item.Name == baseName {
|
||||
response = &iteminfo.FileInfo{
|
||||
Path: adjustedPath + "/" + item.Name,
|
||||
ItemInfo: item,
|
||||
}
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("file not found in directory: %s", adjustedPath)
|
||||
}
|
||||
|
||||
if quick {
|
||||
idx.mu.Lock()
|
||||
idx.FilesChangedDuringIndexing = true
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
return response, nil
|
||||
|
||||
}
|
||||
|
||||
func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjustedPath, combinedPath string, quick, recursive, checkSkip bool) (*iteminfo.FileInfo, error) {
|
||||
// Read directory contents
|
||||
files, err := dir.Readdir(-1)
|
||||
files, err := dirInfo.Readdir(-1)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var totalSize int64
|
||||
fileInfos := []iteminfo.ItemInfo{}
|
||||
dirInfos := []iteminfo.ItemInfo{}
|
||||
|
||||
// Process each file and directory in the current directory
|
||||
for _, file := range files {
|
||||
isHidden := isHidden(file, idx.Source.Path+combinedPath)
|
||||
hidden := isHidden(file, idx.Path+combinedPath)
|
||||
isDir := iteminfo.IsDirectory(file)
|
||||
fullCombined := combinedPath + file.Name()
|
||||
if idx.shouldSkip(isDir, isHidden, fullCombined) {
|
||||
baseName := file.Name()
|
||||
fullCombined := combinedPath + baseName
|
||||
if adjustedPath == "/" {
|
||||
if !idx.shouldInclude(isDir, combinedPath, file.Name()) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if checkSkip && idx.shouldSkip(isDir, hidden, fullCombined, baseName) {
|
||||
continue
|
||||
}
|
||||
itemInfo := &iteminfo.ItemInfo{
|
||||
Name: file.Name(),
|
||||
ModTime: file.ModTime(),
|
||||
Hidden: isHidden,
|
||||
Hidden: hidden,
|
||||
}
|
||||
|
||||
if isDir {
|
||||
|
||||
dirPath := combinedPath + file.Name()
|
||||
if idx.hasIndex && recursive && len(idx.Config.NeverWatchPaths) > 0 {
|
||||
if slices.Contains(idx.Config.NeverWatchPaths, fullCombined) {
|
||||
realDirInfo, exists := idx.GetMetadataInfo(dirPath, true)
|
||||
if exists {
|
||||
itemInfo.Size = realDirInfo.Size
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
// skip non-indexable dirs.
|
||||
if file.Name() == "$RECYCLE.BIN" || file.Name() == "System Volume Information" {
|
||||
continue
|
||||
}
|
||||
|
||||
dirPath := combinedPath + file.Name()
|
||||
if recursive {
|
||||
// clear for garbage collection
|
||||
file = nil
|
||||
// Recursively index the subdirectory
|
||||
err = idx.indexDirectory(dirPath, quick, recursive)
|
||||
if err != nil {
|
||||
@@ -182,18 +292,33 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
|
||||
totalSize += itemInfo.Size
|
||||
itemInfo.Type = "directory"
|
||||
dirInfos = append(dirInfos, *itemInfo)
|
||||
idx.NumDirs++
|
||||
if recursive {
|
||||
idx.NumDirs++
|
||||
}
|
||||
} else {
|
||||
size, shouldCountSize := idx.handleFile(file, fullCombined)
|
||||
itemInfo.DetectType(fullCombined, false)
|
||||
itemInfo.Size = file.Size()
|
||||
itemInfo.Size = int64(size)
|
||||
fileInfos = append(fileInfos, *itemInfo)
|
||||
totalSize += itemInfo.Size
|
||||
idx.NumFiles++
|
||||
if shouldCountSize {
|
||||
totalSize += itemInfo.Size
|
||||
}
|
||||
if recursive {
|
||||
idx.NumFiles++
|
||||
}
|
||||
}
|
||||
}
|
||||
if totalSize == 0 && idx.Source.Config.IgnoreZeroSizeFolders {
|
||||
return nil
|
||||
|
||||
if totalSize == 0 && idx.Config.Exclude.ZeroSizeFolders {
|
||||
return nil, errors.ErrNotIndexed
|
||||
}
|
||||
|
||||
if adjustedPath == "/" {
|
||||
idx.mu.Lock()
|
||||
idx.DiskUsed = uint64(totalSize)
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
|
||||
// Create FileInfo for the current directory
|
||||
dirFileInfo := &iteminfo.FileInfo{
|
||||
Path: adjustedPath,
|
||||
@@ -201,16 +326,13 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
|
||||
Folders: dirInfos,
|
||||
}
|
||||
dirFileInfo.ItemInfo = iteminfo.ItemInfo{
|
||||
Name: dirInfo.Name(),
|
||||
Name: filepath.Base(dirInfo.Name()),
|
||||
Type: "directory",
|
||||
Size: totalSize,
|
||||
ModTime: dirInfo.ModTime(),
|
||||
ModTime: stat.ModTime(),
|
||||
}
|
||||
|
||||
dirFileInfo.SortItems()
|
||||
// Update the current directory metadata in the index
|
||||
idx.UpdateMetadata(dirFileInfo)
|
||||
return nil
|
||||
return dirFileInfo, nil
|
||||
}
|
||||
|
||||
// input should be non-index path.
|
||||
@@ -218,12 +340,12 @@ func (idx *Index) MakeIndexPath(subPath string) string {
|
||||
if strings.HasPrefix(subPath, "./") {
|
||||
subPath = strings.TrimPrefix(subPath, ".")
|
||||
}
|
||||
if idx.Source.Path == subPath || subPath == "." {
|
||||
if idx.Path == subPath || subPath == "." {
|
||||
return "/"
|
||||
}
|
||||
// clean path
|
||||
subPath = strings.TrimSuffix(subPath, "/")
|
||||
adjustedPath := strings.TrimPrefix(subPath, idx.Source.Path)
|
||||
adjustedPath := strings.TrimPrefix(subPath, idx.Path)
|
||||
// remove index prefix
|
||||
adjustedPath = strings.ReplaceAll(adjustedPath, "\\", "/")
|
||||
// remove trailing slash
|
||||
@@ -247,7 +369,7 @@ func (idx *Index) recursiveUpdateDirSizes(childInfo *iteminfo.FileInfo, previous
|
||||
}
|
||||
|
||||
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
|
||||
combined := append([]string{idx.Source.Path}, relativePath...)
|
||||
combined := append([]string{idx.Path}, relativePath...)
|
||||
joinedPath := filepath.Join(combined...)
|
||||
isDir, _ := RealPathCache.Get(joinedPath + ":isdir").(bool)
|
||||
cached, ok := RealPathCache.Get(joinedPath).(string)
|
||||
@@ -260,7 +382,7 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
|
||||
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
|
||||
}
|
||||
// Resolve symlinks and get the real path
|
||||
realPath, isDir, err := utils.ResolveSymlinks(absolutePath)
|
||||
realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath)
|
||||
if err == nil {
|
||||
RealPathCache.Set(joinedPath, realPath)
|
||||
RealPathCache.Set(joinedPath+":isdir", isDir)
|
||||
@@ -279,7 +401,7 @@ func (idx *Index) RefreshFileInfo(opts iteminfo.FileOptions) error {
|
||||
}
|
||||
err := idx.indexDirectory(refreshOptions.Path, false, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path)
|
||||
return err
|
||||
}
|
||||
file, exists := idx.GetMetadataInfo(refreshOptions.Path, true)
|
||||
if !exists {
|
||||
@@ -316,54 +438,88 @@ func isHidden(file os.FileInfo, srcPath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined string) bool {
|
||||
// check inclusions first
|
||||
if isDir && len(idx.Source.Config.Include.Folders) > 0 {
|
||||
if !slices.Contains(idx.Source.Config.Include.Folders, fullCombined) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !isDir && len(idx.Source.Config.Include.Files) > 0 {
|
||||
if !slices.Contains(idx.Source.Config.Include.Files, fullCombined) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if !isDir && len(idx.Source.Config.Include.FileEndsWith) > 0 {
|
||||
shouldSkip := true
|
||||
for _, end := range idx.Source.Config.Include.FileEndsWith {
|
||||
if strings.HasSuffix(fullCombined, end) {
|
||||
shouldSkip = false
|
||||
break
|
||||
func (idx *Index) shouldInclude(isDir bool, fullCombined, baseName string) bool {
|
||||
rules := idx.Config.Include
|
||||
hasRules := false
|
||||
if len(rules.RootFolders) > 0 {
|
||||
hasRules = true
|
||||
for _, p := range rules.RootFolders {
|
||||
if strings.HasPrefix(fullCombined, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if shouldSkip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// check exclusions
|
||||
if isDir && slices.Contains(idx.Source.Config.Exclude.Folders, fullCombined) {
|
||||
return true
|
||||
}
|
||||
if !isDir && slices.Contains(idx.Source.Config.Exclude.Files, fullCombined) {
|
||||
return true
|
||||
}
|
||||
if idx.Source.Config.IgnoreHidden && isHidden {
|
||||
return true
|
||||
}
|
||||
|
||||
if !isDir && len(idx.Source.Config.Exclude.FileEndsWith) > 0 {
|
||||
shouldSkip := false
|
||||
for _, end := range idx.Source.Config.Exclude.FileEndsWith {
|
||||
if strings.HasSuffix(fullCombined, end) {
|
||||
shouldSkip = true
|
||||
break
|
||||
if len(rules.RootFiles) > 0 {
|
||||
hasRules = true
|
||||
for _, p := range rules.RootFiles {
|
||||
if strings.HasPrefix(fullCombined, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return shouldSkip
|
||||
}
|
||||
if !hasRules {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined, baseName string) bool {
|
||||
if idx.Config.DisableIndexing {
|
||||
return true
|
||||
}
|
||||
|
||||
rules := idx.Config.Exclude
|
||||
|
||||
if isDir {
|
||||
if len(rules.FolderPaths) > 0 {
|
||||
for _, p := range rules.FolderPaths {
|
||||
if strings.HasPrefix(fullCombined, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rules.FolderNames) > 0 && slices.Contains(rules.FolderNames, baseName) {
|
||||
return true
|
||||
}
|
||||
if len(rules.FolderEndsWith) > 0 {
|
||||
for _, end := range rules.FolderEndsWith {
|
||||
if strings.HasSuffix(baseName, end) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if len(rules.FilePaths) > 0 {
|
||||
for _, p := range rules.FilePaths {
|
||||
if strings.HasPrefix(fullCombined, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rules.FileNames) > 0 && slices.Contains(rules.FileNames, baseName) {
|
||||
return true
|
||||
}
|
||||
if len(rules.FileEndsWith) > 0 {
|
||||
for _, end := range rules.FileEndsWith {
|
||||
if strings.HasSuffix(baseName, end) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Exclude if parent directory matches FolderPaths
|
||||
if len(rules.FolderPaths) > 0 {
|
||||
parent := filepath.Dir(fullCombined)
|
||||
for _, p := range rules.FolderPaths {
|
||||
if strings.HasPrefix(parent, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rules.Hidden && isHidden {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -372,19 +528,18 @@ type DiskUsage struct {
|
||||
Used uint64 `json:"used"`
|
||||
}
|
||||
|
||||
func (idx *Index) SetUsage(usage DiskUsage) {
|
||||
func (idx *Index) SetUsage(totalBytes uint64) {
|
||||
if settings.Config.Frontend.DisableUsedPercentage {
|
||||
return
|
||||
}
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
idx.ReducedIndex.DiskUsed = int64(usage.Used)
|
||||
idx.ReducedIndex.DiskTotal = int64(usage.Total)
|
||||
idx.DiskTotal = totalBytes
|
||||
}
|
||||
|
||||
func (idx *Index) SetStatus(status IndexStatus) {
|
||||
idx.mu.Lock()
|
||||
idx.ReducedIndex.Status = status
|
||||
idx.Status = status
|
||||
switch status {
|
||||
case INDEXING:
|
||||
idx.runningScannerCount++
|
||||
@@ -394,3 +549,40 @@ func (idx *Index) SetStatus(status IndexStatus) {
|
||||
idx.mu.Unlock()
|
||||
idx.SendSourceUpdateEvent()
|
||||
}
|
||||
|
||||
func (idx *Index) handleFile(file os.FileInfo, fullCombined string) (size uint64, shouldCountSize bool) {
|
||||
var realSize uint64
|
||||
var nlink uint64 = 1
|
||||
var ino uint64 = 0
|
||||
canUseSyscall := false
|
||||
|
||||
if sys := file.Sys(); sys != nil {
|
||||
realSize, nlink, ino, canUseSyscall = getFileDetails(sys)
|
||||
}
|
||||
|
||||
if !canUseSyscall {
|
||||
// Fallback for non-unix systems or if syscall info is unavailable
|
||||
realSize = uint64(file.Size())
|
||||
}
|
||||
|
||||
if nlink > 1 {
|
||||
// It's a hard link
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
if _, exists := idx.processedInodes[ino]; exists {
|
||||
// Already seen, don't count towards global total, or directory total.
|
||||
return realSize, false
|
||||
}
|
||||
// First time seeing this inode.
|
||||
idx.processedInodes[ino] = struct{}{}
|
||||
idx.FoundHardLinks[fullCombined] = realSize
|
||||
idx.totalSize += realSize
|
||||
return realSize, true // Count size for directory total.
|
||||
}
|
||||
|
||||
// It's a regular file.
|
||||
idx.mu.Lock()
|
||||
idx.totalSize += realSize
|
||||
idx.mu.Unlock()
|
||||
return realSize, true // Count size.
|
||||
}
|
||||
@@ -9,15 +9,17 @@ import (
|
||||
)
|
||||
|
||||
// schedule in minutes
|
||||
var scanSchedule = []time.Duration{
|
||||
5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
|
||||
10 * time.Minute,
|
||||
20 * time.Minute,
|
||||
40 * time.Minute, // [3] element is 40 minutes, reset anchor for full scan
|
||||
1 * time.Hour,
|
||||
2 * time.Hour,
|
||||
3 * time.Hour,
|
||||
4 * time.Hour, // 4 hours for quick scan & 20 hours for a full scan
|
||||
var scanSchedule = map[int]time.Duration{
|
||||
0: 5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
|
||||
1: 10 * time.Minute,
|
||||
2: 20 * time.Minute,
|
||||
3: 40 * time.Minute, // reset anchor for full scan
|
||||
4: 1 * time.Hour,
|
||||
5: 2 * time.Hour,
|
||||
6: 3 * time.Hour, // [6]
|
||||
7: 4 * time.Hour, // [7] 4 hours for quick scan & 20 hours for a full scan
|
||||
8: 8 * time.Hour,
|
||||
9: 12 * time.Hour,
|
||||
}
|
||||
|
||||
var fullScanAnchor = 3 // index of the schedule for a full scan
|
||||
@@ -31,8 +33,8 @@ func (idx *Index) newScanner(origin string) {
|
||||
if idx.Assessment == "simple" {
|
||||
sleepTime = scanSchedule[idx.CurrentSchedule] - idx.SmartModifier
|
||||
}
|
||||
if idx.Source.Config.IndexingInterval > 0 {
|
||||
sleepTime = time.Duration(idx.Source.Config.IndexingInterval) * time.Minute
|
||||
if idx.Config.IndexingInterval > 0 {
|
||||
sleepTime = time.Duration(idx.Config.IndexingInterval) * time.Minute
|
||||
}
|
||||
// Log and sleep before indexing
|
||||
logger.Debugf("Next scan in %v", sleepTime)
|
||||
@@ -53,6 +55,8 @@ func (idx *Index) PreScan() {
|
||||
|
||||
func (idx *Index) PostScan() {
|
||||
idx.mu.Lock()
|
||||
idx.garbageCollection()
|
||||
idx.hasIndex = true
|
||||
idx.runningScannerCount--
|
||||
idx.mu.Unlock()
|
||||
if idx.runningScannerCount == 0 {
|
||||
@@ -60,6 +64,19 @@ func (idx *Index) PostScan() {
|
||||
}
|
||||
}
|
||||
|
||||
func (idx *Index) garbageCollection() {
|
||||
for path := range idx.Directories {
|
||||
_, ok := idx.DirectoriesLedger[path]
|
||||
if !ok {
|
||||
idx.Directories[path] = nil
|
||||
delete(idx.Directories, path)
|
||||
idx.NumDeleted++
|
||||
}
|
||||
}
|
||||
// Reset the ledger for the next scan.
|
||||
idx.DirectoriesLedger = make(map[string]struct{})
|
||||
}
|
||||
|
||||
func (idx *Index) UpdateSchedule() {
|
||||
// Adjust schedule based on file changes
|
||||
if idx.FilesChangedDuringIndexing {
|
||||
@@ -109,15 +126,23 @@ func (idx *Index) SendSourceUpdateEvent() {
|
||||
}
|
||||
|
||||
func (idx *Index) RunIndexing(origin string, quick bool) {
|
||||
if idx.runningScannerCount > 0 {
|
||||
logger.Debugf("Indexing already in progress for [%v]", idx.Name)
|
||||
return
|
||||
}
|
||||
idx.PreScan()
|
||||
prevNumDirs := idx.NumDirs
|
||||
prevNumFiles := idx.NumFiles
|
||||
if quick {
|
||||
logger.Debugf("Starting quick scan for [%v]", idx.Source.Name)
|
||||
logger.Debugf("Starting quick scan for [%v]", idx.Name)
|
||||
} else {
|
||||
logger.Debugf("Starting full scan for [%v]", idx.Source.Name)
|
||||
logger.Debugf("Starting full scan for [%v]", idx.Name)
|
||||
idx.mu.Lock()
|
||||
idx.NumDirs = 0
|
||||
idx.NumFiles = 0
|
||||
idx.processedInodes = make(map[uint64]struct{})
|
||||
idx.FoundHardLinks = make(map[string]uint64)
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
startTime := time.Now()
|
||||
idx.FilesChangedDuringIndexing = false
|
||||
@@ -126,13 +151,13 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
|
||||
if err != nil {
|
||||
logger.Errorf("Error during indexing: %v", err)
|
||||
}
|
||||
firstRun := idx.LastIndexed == time.Time{}
|
||||
firstRun := time.Time.Equal(idx.LastIndexed, time.Time{})
|
||||
// Update the LastIndexed time
|
||||
idx.LastIndexed = time.Now()
|
||||
idx.LastIndexedUnix = idx.LastIndexed.Unix()
|
||||
if quick {
|
||||
idx.QuickScanTime = int(time.Since(startTime).Seconds())
|
||||
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.QuickScanTime)
|
||||
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Name, idx.QuickScanTime)
|
||||
} else {
|
||||
idx.FullScanTime = int(time.Since(startTime).Seconds())
|
||||
// update smart indexing
|
||||
@@ -147,14 +172,14 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
|
||||
idx.Assessment = "normal"
|
||||
}
|
||||
if firstRun {
|
||||
logger.Infof("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.Assessment, idx.NumDirs, idx.NumFiles)
|
||||
logger.Infof("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Name, idx.Assessment, idx.NumDirs, idx.NumFiles)
|
||||
} else {
|
||||
logger.Debugf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.Assessment, idx.NumDirs, idx.NumFiles)
|
||||
logger.Debugf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Name, idx.Assessment, idx.NumDirs, idx.NumFiles)
|
||||
}
|
||||
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
|
||||
idx.FilesChangedDuringIndexing = true
|
||||
}
|
||||
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.FullScanTime)
|
||||
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Name, idx.FullScanTime)
|
||||
}
|
||||
|
||||
idx.PostScan()
|
||||
|
||||
@@ -124,77 +124,93 @@ func TestMakeIndexPathRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCheckIndexExclude(b *testing.B) {
|
||||
func TestCheckIndexExclude(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isDir bool
|
||||
isHidden bool
|
||||
fullPath string
|
||||
baseName string
|
||||
exclude settings.ExcludeIndexFilter
|
||||
expect bool
|
||||
}{
|
||||
{false, false, "/test/.test"},
|
||||
{true, false, "/test/.test"},
|
||||
{true, true, "/test/.test"},
|
||||
{false, false, "/test/filepath"},
|
||||
{false, true, "/test/filepath"},
|
||||
{true, true, "/test/filepath"},
|
||||
// FilePaths exclusion (prefix match)
|
||||
{"FilePaths exclusion (file)", false, false, "/test/filepath/child.txt", "child.txt", settings.ExcludeIndexFilter{FilePaths: []string{"/test/filepath"}}, true},
|
||||
{"FilePaths exclusion (folder)", true, false, "/test/folderpath/sub", "sub", settings.ExcludeIndexFilter{FolderPaths: []string{"/test/folderpath"}}, true},
|
||||
|
||||
// FileNames exclusion (exact match)
|
||||
{"FileNames exclusion (file)", false, false, "/test/abc.txt", "abc.txt", settings.ExcludeIndexFilter{FileNames: []string{"abc.txt"}}, true},
|
||||
{"FolderNames exclusion (folder)", true, false, "/test/.thumbnails", ".thumbnails", settings.ExcludeIndexFilter{FolderNames: []string{".thumbnails"}}, true},
|
||||
|
||||
// FileEndsWith exclusion (suffix match)
|
||||
{"FileEndsWith exclusion (file)", false, false, "/test/archive.zip", "archive.zip", settings.ExcludeIndexFilter{FileEndsWith: []string{".zip"}}, true},
|
||||
// FolderEndsWith exclusion (suffix match)
|
||||
{"FolderEndsWith exclusion (folder)", true, false, "/test/special_folder", "special_folder", settings.ExcludeIndexFilter{FolderEndsWith: []string{"_folder"}}, true},
|
||||
|
||||
// Negative cases (should not skip)
|
||||
{"FilePaths not excluded (file)", false, false, "/test/otherfile", "otherfile", settings.ExcludeIndexFilter{FilePaths: []string{"/test/filepath"}}, false},
|
||||
{"FileNames not excluded (file)", false, false, "/test/other.txt", "other.txt", settings.ExcludeIndexFilter{FileNames: []string{"abc.txt"}}, false},
|
||||
{"FolderNames not excluded (folder)", true, false, "/test/otherfolder", "otherfolder", settings.ExcludeIndexFilter{FolderNames: []string{".thumbnails"}}, false},
|
||||
{"FileEndsWith not excluded (file)", false, false, "/test/file.tar", "file.tar", settings.ExcludeIndexFilter{FileEndsWith: []string{".zip"}}, false},
|
||||
{"FolderEndsWith not excluded (folder)", true, false, "/test/normal", "normal", settings.ExcludeIndexFilter{FolderEndsWith: []string{"_folder"}}, false},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
idx := Index{
|
||||
Source: settings.Source{
|
||||
Name: "files",
|
||||
Config: settings.SourceConfig{
|
||||
IgnoreHidden: true,
|
||||
Exclude: settings.IndexFilter{
|
||||
Files: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
|
||||
Folders: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
|
||||
FileEndsWith: []string{".zip", ".tar", ".jpeg"},
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
idx := Index{
|
||||
Source: settings.Source{
|
||||
Name: "files",
|
||||
Config: settings.SourceConfig{
|
||||
Exclude: tc.exclude,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
result := idx.shouldSkip(tc.isDir, tc.isHidden, tc.fullPath, tc.baseName)
|
||||
if result != tc.expect {
|
||||
t.Errorf("shouldSkip(%v, %v, %q, %q) = %v; want %v", tc.isDir, tc.isHidden, tc.fullPath, tc.baseName, result, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, v := range tests {
|
||||
idx.shouldSkip(v.isDir, v.isHidden, v.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
func BenchmarkCheckIndexConditionsInclude(b *testing.B) {
|
||||
|
||||
func TestCheckIndexInclude(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isDir bool
|
||||
isHidden bool
|
||||
fullPath string
|
||||
baseName string
|
||||
include settings.IncludeIndexFilter
|
||||
expect bool
|
||||
}{
|
||||
{false, false, "/test/.test"},
|
||||
{true, false, "/test/.test"},
|
||||
{true, true, "/test/.test"},
|
||||
{false, false, "/test/filepath"},
|
||||
{false, true, "/test/filepath"},
|
||||
{true, true, "/test/filepath"},
|
||||
// RootFolders inclusion (prefix match)
|
||||
{"RootFolders include (folder)", true, "/folder1", "folder1", settings.IncludeIndexFilter{RootFolders: []string{"/folder1"}}, true},
|
||||
{"RootFolders include (subfolder)", true, "/folder1/sub", "sub", settings.IncludeIndexFilter{RootFolders: []string{"/folder1"}}, true},
|
||||
{"RootFolders not include (folder)", true, "/otherfolder", "otherfolder", settings.IncludeIndexFilter{RootFolders: []string{"/folder1"}}, false},
|
||||
|
||||
// RootFiles inclusion (prefix match)
|
||||
{"RootFiles include (file)", false, "/file1.txt", "file1.txt", settings.IncludeIndexFilter{RootFiles: []string{"/file1.txt"}}, true},
|
||||
{"RootFiles include (file in subfolder)", false, "/folder1/file1.txt", "file1.txt", settings.IncludeIndexFilter{RootFiles: []string{"/folder1/file1.txt"}}, true},
|
||||
{"RootFiles not include (file)", false, "/otherfile.txt", "otherfile.txt", settings.IncludeIndexFilter{RootFiles: []string{"/file1.txt"}}, false},
|
||||
|
||||
// No rules: should include everything
|
||||
{"No rules include (file)", false, "/anyfile.txt", "anyfile.txt", settings.IncludeIndexFilter{}, true},
|
||||
{"No rules include (folder)", true, "/anyfolder", "anyfolder", settings.IncludeIndexFilter{}, true},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
idx2 := Index{
|
||||
Source: settings.Source{
|
||||
Name: "files",
|
||||
Config: settings.SourceConfig{
|
||||
IgnoreHidden: true,
|
||||
Include: settings.IndexFilter{
|
||||
Files: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
|
||||
Folders: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
|
||||
FileEndsWith: []string{".zip", ".tar", ".jpeg"},
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
idx := Index{
|
||||
Source: settings.Source{
|
||||
Name: "files",
|
||||
Config: settings.SourceConfig{
|
||||
Include: tc.include,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
result := idx.shouldInclude(tc.isDir, tc.fullPath, tc.baseName)
|
||||
if result != tc.expect {
|
||||
t.Errorf("shouldInclude(%v, %q, %q) = %v; want %v", tc.isDir, tc.fullPath, tc.baseName, result, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, v := range tests {
|
||||
idx2.shouldSkip(v.isDir, v.isHidden, v.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user