Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018be705a6 | ||
|
|
ff5010ed4d | ||
|
|
226038b11d | ||
|
|
206180de0d | ||
|
|
5b0f388fa5 | ||
|
|
7d0707ebe0 | ||
|
|
41cb97a1c5 | ||
|
|
0b212ce1b4 | ||
|
|
7f0a0209af | ||
|
|
642333ec68 | ||
|
|
ec43926d3c | ||
|
|
9202043000 | ||
|
|
4d04e02c31 | ||
|
|
04d72998b6 | ||
|
|
f836a2c4df | ||
|
|
cc0212b754 | ||
|
|
f66a4d9313 | ||
|
|
3e59bf90f7 | ||
|
|
812f30cccd | ||
|
|
93a2e3e05e |
No files matched your search
+97
-63
@@ -3,84 +3,116 @@ 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:
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
name: Build docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -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:
|
||||
- "**"
|
||||
|
||||
|
||||
@@ -48,3 +48,4 @@ yarn-error.log*
|
||||
bin/
|
||||
build/
|
||||
*__debug*
|
||||
backend/custom.css
|
||||
+279
@@ -2,6 +2,285 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v0.8.3-beta
|
||||
|
||||
**BugFixes**:
|
||||
- fixed search bar style bug in mobile #1147
|
||||
|
||||
## v0.8.2-beta
|
||||
|
||||
**New Features**:
|
||||
- added `source.config.denyByDefault` configuration to enable a deny-by-default access rule. A source enabled with this will deny access unless an "allow" rule was specifically created. (Similar to creating a root-level denyAll rule)
|
||||
- allow oidc user source access and permission based on username and groups is fulfilled by denyByDefault source with access rules https://github.com/gtsteffaniak/filebrowser/issues/824
|
||||
- "open parent folder" in context menu and search results https://github.com/gtsteffaniak/filebrowser/issues/1121
|
||||
- added friendly "share not found" page.
|
||||
|
||||
**Notes**:
|
||||
- 8.0 ffmpeg version bundled with docker
|
||||
- go 1.25 upgrade with green tea GC enabled
|
||||
- totp secrets accept non-secure strings, only throwing warning
|
||||
- adjusted download limit so it also counts viewing text "content" of files (like in editor). You can also "disable file viewing" to stop the editor from showing. lower quality file image previews are not counted as downloads.
|
||||
- updated invalid share message to be more clear https://github.com/gtsteffaniak/filebrowser/issues/1120
|
||||
|
||||
**BugFixes**:
|
||||
- fixed /public/static routes issue
|
||||
- shares redirect to login - https://github.com/gtsteffaniak/filebrowser/issues/1109
|
||||
- some static assets not available to anonymous user - https://github.com/gtsteffaniak/filebrowser/issues/1102
|
||||
- more safari style issues https://github.com/gtsteffaniak/filebrowser/issues/1110
|
||||
- fix public share download issues https://github.com/gtsteffaniak/filebrowser/issues/1118 https://github.com/gtsteffaniak/filebrowser/issues/1089
|
||||
- fixed disable file viewer setting and enforced on backend
|
||||
|
||||
## v0.8.1-beta
|
||||
|
||||
**New Features**:
|
||||
- api for generate download link (see swagger) https://github.com/gtsteffaniak/filebrowser/issues/1007
|
||||
- added `source.config.disabled` option to disable a source without removing it from config file.
|
||||
- added `source.config.private` option to designate as private -- currently just means no sharing permitted.
|
||||
- hide share card in share
|
||||
- download count for a share shows up on share management
|
||||
|
||||
**Notes**:
|
||||
- updated description for indexingIntervalMinutes https://github.com/gtsteffaniak/filebrowser/issues/1067
|
||||
|
||||
**BugFixes**:
|
||||
- fixed styling issues https://github.com/gtsteffaniak/filebrowser/issues/1086 https://github.com/gtsteffaniak/filebrowser/issues/1081 https://github.com/gtsteffaniak/filebrowser/issues/1082 https://github.com/gtsteffaniak/filebrowser/issues/1098
|
||||
- fix download limit issue https://github.com/gtsteffaniak/filebrowser/issues/1085
|
||||
- fixed oidc user defaults for new user https://github.com/gtsteffaniak/filebrowser/issues/1071
|
||||
- shares get updated when files moved in ui https://github.com/gtsteffaniak/filebrowser/issues/760
|
||||
- click listing behavior doesn't clear (introduced in 0.8.0) https://github.com/gtsteffaniak/filebrowser/issues/1101
|
||||
- show download count and limit in share list in settings https://github.com/gtsteffaniak/filebrowser/issues/1103
|
||||
- fix windows alt+arrow movement issue https://github.com/gtsteffaniak/filebrowser/issues/1094
|
||||
- nav memory issue for filenames with brackets https://github.com/gtsteffaniak/filebrowser/issues/1092
|
||||
- files with "+"" in name issue https://github.com/gtsteffaniak/filebrowser/issues/1089
|
||||
- fixed editor bug in share view https://github.com/gtsteffaniak/filebrowser/issues/1084
|
||||
- other share related issues https://github.com/gtsteffaniak/filebrowser/issues/1087 https://github.com/gtsteffaniak/filebrowser/issues/1064
|
||||
|
||||
|
||||
## v0.8.0-beta
|
||||
|
||||
This is a major release, new features and changes could introduce breaking behavior. Here are the known potentially breaking changes:
|
||||
|
||||
- all public api and share url's get a `/public` prefix, making it easier to use with a reverse proxy. Any existing share link will still work but get redirected.
|
||||
- a small change to styling you may need to update your custom styling, for example the id `#input` was renamed `#search-input`
|
||||
|
||||
**New Features**:
|
||||
- New access control system. You can add new allow / deny / denyAll rules for users/groups for specific paths on specific sources.
|
||||
- groups currently only works with provided oidc groups, but will add a full group management option for manual creation. https://github.com/gtsteffaniak/filebrowser/issues/545
|
||||
- share view changes -- now aligns with the standard listing view. This means files can be viewed and edited (if permission allows) just like a normal listing.
|
||||
- many share links customization enhancements
|
||||
- only share to certain authenticated users https://github.com/gtsteffaniak/filebrowser/issues/656 https://github.com/gtsteffaniak/filebrowser/issues/985
|
||||
- one-time download links
|
||||
- customize share theme https://github.com/gtsteffaniak/filebrowser/issues/827 https://github.com/gtsteffaniak/filebrowser/issues/1029
|
||||
- share link public changes https://github.com/gtsteffaniak/filebrowser/issues/473
|
||||
- shares can be modified/configured after creation.
|
||||
- download throttling for shares
|
||||
|
||||
**Notes**:
|
||||
- hover effect on list/compact view https://github.com/gtsteffaniak/filebrowser/issues/1036
|
||||
|
||||
**BugFixes**:
|
||||
- fix new file "true" content issue https://github.com/gtsteffaniak/filebrowser/issues/1048
|
||||
- editor allows device default popup https://github.com/gtsteffaniak/filebrowser/issues/1049
|
||||
|
||||
## v0.7.18-beta
|
||||
|
||||
**Notes**:
|
||||
- desktop context menu "select multiple" enabled as optional user default (#1000)
|
||||
- onlyoffice readonly document types (".pages", ".numbers", ".key") list (#1018)
|
||||
- onlyoffice tweaks to make more consistent, added logging (#1015)
|
||||
|
||||
**BugFixes**:
|
||||
- fix lightBackground issue (#1021)
|
||||
- fix user save issues (#1020, #1027)
|
||||
- fix image preview cache issue (#989)
|
||||
- fix file/folder count issue (#989)
|
||||
- only first file was upload on drag-n-drop (#1024)
|
||||
|
||||
## v0.7.17-beta
|
||||
|
||||
See an example of custom css styling that uses the reduce-rounded-corners.css by default and allows users to choose other themes. You can add your own themes as well that users can choose from in profile settings:
|
||||
|
||||
```
|
||||
frontend:
|
||||
styling:
|
||||
lightBackground: "#f0f0f0" # or names of css colors
|
||||
darkBackground: "#121212"
|
||||
customCSS: "custom.css" # custom css file always applies first, then user themes on top of that.
|
||||
customThemes:
|
||||
"default": # if "default" is specified as the name, it will be the default option
|
||||
description: "Reduce rounded corners"
|
||||
css: "reduce-rounded-corners.css" # path to css file to use
|
||||
"original":
|
||||
description: "Original rounded theme"
|
||||
css: "" # you could default to no styling changes this way.
|
||||
```
|
||||
|
||||
**New Features**:
|
||||
- more custom styling options (thanks @mordilloSan for #997)
|
||||
- background colors can be easily set in config
|
||||
- provided an example `reduce-rounded-corners.css` available by default in docker. (#986, #837)
|
||||
- added feature to specify multiple css themes that users can choose from in profile settings
|
||||
- swipe between photos on mobile (#825)
|
||||
|
||||
**Notes**:
|
||||
- changed partition calculations on linux for total disk size (#982)
|
||||
- upload conflict detection for folders offers "replace all" if the folder already exists in target location.
|
||||
|
||||
**BugFixes**:
|
||||
- TOTP prompt not showing generated code issue https://github.com/gtsteffaniak/filebrowser/issues/996
|
||||
- select mulitple deselect on mobile (#1002)
|
||||
- viewing svg images.
|
||||
|
||||
## v0.7.16-beta
|
||||
|
||||
**Notes**:
|
||||
- more server logging for uploads when debug logging is enabled
|
||||
|
||||
**BugFixes**:
|
||||
- fix onlyoffice integration viewing bug (#990)
|
||||
- fix uploading files with exec permissions (#984)
|
||||
- fix redirect on no source path (#989)
|
||||
- refresh file info on rename (#989)
|
||||
- listing refreshes when uploads finish (#989)
|
||||
- disable edit mode for certain onlyoffice files (#971)
|
||||
|
||||
## v0.7.15-beta
|
||||
|
||||
**New Features**:
|
||||
- added userDefault `disableViewingExt`. The new properties apply to all files, not just office.
|
||||
- code blocks in markdown viewer have line numbers and each line is highlightable
|
||||
|
||||
**Notes**:
|
||||
- replaced `disableOfficePreviewExt` with more generally applicable `disablePreviewExt` to disable preview for any specific file type.
|
||||
- more tooltip descriptions for settings options
|
||||
|
||||
**BugFixes**:
|
||||
- fix chinese and other language error (#972, #969)
|
||||
- fix docker dockerfile for `docker run` (#973)
|
||||
- fix double slash href on single source (#968)
|
||||
- fix sources named "files" or "share" issue (#949, #574)
|
||||
- focus input field on popups (#976)
|
||||
- hopeful fix for size calculation (#982)
|
||||
- edit button is not working on .md files (#983)
|
||||
|
||||
## v0.7.14-beta
|
||||
|
||||
**Notes**:
|
||||
- Updated translations https://github.com/gtsteffaniak/filebrowser/issues/957
|
||||
- enabled more doc types for onlyoffice https://github.com/gtsteffaniak/filebrowser/discussions/945
|
||||
|
||||
**BugFixes**:
|
||||
- noauth user issue https://github.com/gtsteffaniak/filebrowser/issues/955
|
||||
- error 403 on source name with special characters https://github.com/gtsteffaniak/filebrowser/issues/952
|
||||
- delete pictures in previewer issue https://github.com/gtsteffaniak/filebrowser/issues/456
|
||||
- trailing slash source name issue https://github.com/gtsteffaniak/filebrowser/issues/920
|
||||
- image lazy loading issue causing all items to get previews at one time, not just whats in view.
|
||||
|
||||
## v0.7.13-beta
|
||||
|
||||
**New Features**:
|
||||
- copy and Move files between sources https://github.com/gtsteffaniak/filebrowser/issues/689
|
||||
- new enhanced upload prompt
|
||||
- uses chunked uploads https://github.com/gtsteffaniak/filebrowser/issues/770
|
||||
- all or individual uploads can be paused/resumed
|
||||
- individual uploads can be retried
|
||||
- individual file upload progress https://github.com/gtsteffaniak/filebrowser/issues/871
|
||||
- keeps screen on https://github.com/gtsteffaniak/filebrowser/issues/900
|
||||
|
||||
**Notes**:
|
||||
- lots of UI improvements
|
||||
- reworked a lot of the frontend path/source logic to be more consistent.
|
||||
- updated sort behavior to be natural sort https://github.com/gtsteffaniak/filebrowser/issues/551
|
||||
- optional quick save icon https://github.com/gtsteffaniak/filebrowser/issues/918
|
||||
- improved language support: zh-tw chinese traditional (tawain)
|
||||
|
||||
**BugFixes**:
|
||||
- more accurate disk used calculation -- accounting for hard links and sparse files. https://github.com/gtsteffaniak/filebrowser/issues/921
|
||||
- fix api key revoking mechanism
|
||||
- fixed shift-select https://github.com/gtsteffaniak/filebrowser/issues/929
|
||||
- video preview images on safari https://github.com/gtsteffaniak/filebrowser/issues/932
|
||||
- sticky mode isn't sticky https://github.com/gtsteffaniak/filebrowser/issues/916
|
||||
|
||||
## v0.7.12-beta
|
||||
|
||||
Happy 4th of July!
|
||||
|
||||
The most noteworthy change is that no sources will be automatically enabled for any user. In order for a user to use a source, it needs to be added for that user. Or to keep a source available for all users, you can specify `defaultEnabled` in the source config to maintain the same behavior. See the wiki
|
||||
|
||||
**New Features**:
|
||||
- setting added `deleteWithoutConfirming`, useful for quickly deleting files -- does not apply to folders.
|
||||
- more options for minimal UI https://github.com/gtsteffaniak/filebrowser/issues/745
|
||||
- dedicated section for sidebar customization in profile settings https://github.com/gtsteffaniak/filebrowser/issues/437
|
||||
|
||||
**Notes**:
|
||||
- Filebrowser no longer requires a default source, users can be created without any sources.
|
||||
- Disables changing login type fallback behavior https://github.com/gtsteffaniak/filebrowser/issues/620
|
||||
- Uses calculated index size as "used" and total partition size as "total" https://github.com/gtsteffaniak/filebrowser/issues/875
|
||||
- Select multiple won't show up in context menu when using a desktop browser (with keyboard), opting for keyboard shortcuts
|
||||
- Updated translations that were not complete, such as simplified chinese https://github.com/gtsteffaniak/filebrowser/issues/895
|
||||
- larger min drop target size https://github.com/gtsteffaniak/filebrowser/issues/902
|
||||
- refresh page after file actions https://github.com/gtsteffaniak/filebrowser/issues/894
|
||||
- improved user PUT handler for easier user modification via API https://github.com/gtsteffaniak/filebrowser/issues/897
|
||||
- optional sidebar actions for upload/create https://github.com/gtsteffaniak/filebrowser/issues/885
|
||||
|
||||
**BugFixes**:
|
||||
- fix delete in preview when moving between pictures. https://github.com/gtsteffaniak/filebrowser/issues/456
|
||||
- getting file info issue when indexing is disabled.
|
||||
- fixed initial sort order https://github.com/gtsteffaniak/filebrowser/issues/551
|
||||
- incorrect filename Drag and Drop fixes https://github.com/gtsteffaniak/filebrowser/issues/880
|
||||
- fix share duration always showing just now https://github.com/gtsteffaniak/filebrowser/issues/896
|
||||
|
||||
## v0.7.11-beta
|
||||
|
||||
**Breaking Changes**:
|
||||
- `auth.resetAdminOnStart` has been removed. Instead, if you have `auth.adminPassword` set it will always be reset on startup. If you want to change your default admin password afterwards, make sure to unset `auth.adminPassword` so it doesn't get reset on startup.
|
||||
- renamed include/exclude rules see [updated example wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration-And-Examples#example-advanced-source-config)!
|
||||
|
||||
**New Features**:
|
||||
- more comprehensive exclude/include rules (see example wiki above).
|
||||
- include/exclude parts of folder names as well https://github.com/gtsteffaniak/filebrowser/issues/854
|
||||
- include/exclude file or folder names globally.
|
||||
- `source.config.neverWatchPaths` is now functional -- a list of paths that get indexed initially, but skips re-indexing. Useful for directories you don't expect to change ever, still show up in search but get don't contribute to indexing time after initial indexing.
|
||||
|
||||
**Notes**:
|
||||
- updated swagger docs https://github.com/gtsteffaniak/filebrowser/issues/849
|
||||
|
||||
**BugFixes**:
|
||||
- fix version update notification for binary https://github.com/gtsteffaniak/filebrowser/issues/836
|
||||
- ctrl-click cache issue https://github.com/gtsteffaniak/filebrowser/issues/735
|
||||
- fix admin user reset OIDC user https://github.com/gtsteffaniak/filebrowser/issues/811 https://github.com/gtsteffaniak/filebrowser/issues/851
|
||||
- fix windows and binary muPdf issue https://github.com/gtsteffaniak/filebrowser/issues/744
|
||||
- fix logout oidc issue https://github.com/gtsteffaniak/filebrowser/issues/829 https://github.com/gtsteffaniak/filebrowser/issues/662
|
||||
- file name upload bug https://github.com/gtsteffaniak/filebrowser/issues/662
|
||||
- could not create share with absolute timestamps enabled https://github.com/gtsteffaniak/filebrowser/issues/764
|
||||
- context menu off screen issue https://github.com/gtsteffaniak/filebrowser/issues/828
|
||||
|
||||
## v0.7.10-beta
|
||||
|
||||
**OIDC change**: if you specify `oidc.userIdentifier: "username"`, originally this would map to `preferred_username` but now it maps to `username` explicitly. To maintain the same behavior update your config to `userIdentifier: "preferred_username"`. This was updated to allow for `username` to work as [some might need](https://github.com/gtsteffaniak/filebrowser/pull/789).
|
||||
|
||||
**New Features**:
|
||||
- Added settings option to stop sidebar from automatically hiding on editor and previews. https://github.com/gtsteffaniak/filebrowser/issues/744
|
||||
- Added more secrets loadable from environment variables. https://github.com/gtsteffaniak/filebrowser/issues/790
|
||||
- Include/exclude files are checked for existence to assist with configuration, will show as warning if something is configured but doesn't exist.
|
||||
- Added open in new tab link for preview items to view the raw picture, pdf, etc. Especially helpful for safari viewing PDF documents. https://github.com/gtsteffaniak/filebrowser/issues/734
|
||||
- Added autoplay media toggle in user profile, to automatically play videos and audio.
|
||||
|
||||
**Notes**:
|
||||
- Allowed to delete default admin user https://github.com/gtsteffaniak/filebrowser/issues/811 https://github.com/gtsteffaniak/filebrowser/issues/762
|
||||
- Better try/catch error handling for user feedback for shares https://github.com/gtsteffaniak/filebrowser/issues/732
|
||||
|
||||
**BugFixes**:
|
||||
- Fix share scope creation issue https://github.com/gtsteffaniak/filebrowser/issues/809
|
||||
- Fix oidc token logout issue https://github.com/gtsteffaniak/filebrowser/issues/791
|
||||
- Non-admin users OTP issue https://github.com/gtsteffaniak/filebrowser/issues/815
|
||||
- Linewrap issue for a few cases https://github.com/gtsteffaniak/filebrowser/issues/810
|
||||
- BaseUrl redirect issue with proxies https://github.com/gtsteffaniak/filebrowser/issues/796
|
||||
- Fix exclude still shows up in ui issue https://github.com/gtsteffaniak/filebrowser/issues/797
|
||||
- Copy/move functions are async https://github.com/gtsteffaniak/filebrowser/issues/812
|
||||
- fix subtitle fetch issue https://github.com/gtsteffaniak/filebrowser/issues/766
|
||||
- fix location memory issue for url encoded file names
|
||||
|
||||
## v0.7.9-beta
|
||||
|
||||
**New Features**:
|
||||
|
||||
+12
-23
@@ -4,7 +4,7 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.24+** (see `backend/go.mod`)
|
||||
- **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**
|
||||
@@ -15,7 +15,7 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
|
||||
- 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`
|
||||
@@ -31,8 +31,8 @@ cd filebrowser
|
||||
# 2. Initial setup - installs dependencies and creates test config
|
||||
make setup
|
||||
|
||||
# 3. Run in development mode
|
||||
make run
|
||||
# 3. Run in development mode with hot-reloading
|
||||
make dev
|
||||
```
|
||||
|
||||
`make setup` installs all dependencies and creates a test configuration file.
|
||||
@@ -52,14 +52,14 @@ make run
|
||||
- **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 master
|
||||
- **i18n**: 25+ languages with English as main
|
||||
- **Components**: Feature-based organization
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Essential Commands
|
||||
```bash
|
||||
make run # Start development server
|
||||
make dev # Start development server with hot-reloading
|
||||
make test # Run all tests
|
||||
make lint # Check code quality
|
||||
make check-all # Lint + tests
|
||||
@@ -70,12 +70,12 @@ make build # Build Docker image
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
Note: consider using make commands above instead.
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # Dev server with hot reload
|
||||
npm run lint:fix # Auto-fix linting issues
|
||||
npm run typecheck # TypeScript validation
|
||||
npm run i18n:sync # Sync translations
|
||||
npm run i18n:sync # Sync translations changes
|
||||
```
|
||||
|
||||
## Testing
|
||||
@@ -128,12 +128,11 @@ make build-backend # Build Go binary with embedded assets
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
make build # Full image with ffmpeg (~200MB)
|
||||
make run-proxy # Docker Compose with nginx proxy
|
||||
make build # Full image with ffmpeg and muPDF
|
||||
```
|
||||
|
||||
### Configuration
|
||||
- **Test Config**: `backend/test_config.yaml` Auto-generated after running `make setup`
|
||||
- **Test Config**: `backend/test_config.yaml` Auto-generated after running `make setup`
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -141,7 +140,7 @@ make run-proxy # Docker Compose with nginx proxy
|
||||
|
||||
1. Fork and create a feature branch
|
||||
2. Make your changes following the code standards
|
||||
3. Run `make run` to build and run with your changes
|
||||
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
|
||||
|
||||
@@ -175,16 +174,6 @@ cd frontend && rm -rf node_modules && npm install
|
||||
cd backend && go mod tidy && go clean -modcache
|
||||
```
|
||||
|
||||
**Debugging:**
|
||||
```bash
|
||||
# Backend with debug logs
|
||||
cd backend
|
||||
FILEBROWSER_NO_EMBEDDED=true go run -tags mupdf . -c test_config.yaml --log-level debug
|
||||
|
||||
# API documentation
|
||||
# Visit /swagger endpoint when running
|
||||
```
|
||||
|
||||
**Authentication Issues:**
|
||||
|
||||
Always first enable debug logging in your config file if you have issues.
|
||||
|
||||
@@ -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>
|
||||
@@ -18,11 +18,11 @@
|
||||
> [!WARNING]
|
||||
> There is no stable version -- :construction: planned for 2025. ([Read more](https://github.com/gtsteffaniak/filebrowser/discussions/628))
|
||||
|
||||
Poll: [how much RAM does your install use?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
|
||||
[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 web page interface that allows you to create secure shared links, users with their own specific permissions and settings, and offers a great viewing experience for many file types.
|
||||
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.
|
||||
|
||||
@@ -36,7 +36,7 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
|
||||
|
||||
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.
|
||||
@@ -47,14 +47,19 @@ 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):
|
||||
|
||||
- :construction: jobs are not supported yet.
|
||||
- :construction: rules are not supported yet.
|
||||
- ❌ shell commands are completely removed and will not be returned.
|
||||
|
||||
> [!WARNING]
|
||||
@@ -120,7 +125,7 @@ webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
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+ |
|
||||
|
||||
+7
-5
@@ -1,4 +1,4 @@
|
||||
FROM gtstef/ffmpeg:7.1.1-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:8.0-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
@@ -6,6 +6,7 @@ WORKDIR /app
|
||||
COPY ./backend ./
|
||||
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}'" \
|
||||
@@ -23,15 +24,16 @@ FROM alpine:latest
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
|
||||
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
|
||||
ENV FILEBROWSER_NO_EMBEDED="true"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
COPY --from=base --chown=filebrowser:1000 /app/filebrowser ./
|
||||
COPY --from=base --chown=filebrowser:1000 /app/config.yaml ./data/config.yaml
|
||||
COPY --from=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" ]
|
||||
|
||||
@@ -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
|
||||
@@ -4,6 +4,7 @@ ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add upx
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
RUN go build -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
@@ -18,16 +19,16 @@ COPY ./frontend/ ./
|
||||
RUN npm run build-docker
|
||||
|
||||
FROM alpine:latest
|
||||
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.yaml ./data/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" ]
|
||||
USER root
|
||||
|
||||
@@ -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
|
||||
@@ -12,7 +12,7 @@ builds:
|
||||
- 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:
|
||||
|
||||
@@ -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,13 +30,13 @@ 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
|
||||
@@ -50,7 +51,7 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
} else {
|
||||
} else if err == errors.ErrNotIndexed {
|
||||
return response, fmt.Errorf("could not refresh file info: %v", err)
|
||||
}
|
||||
} else {
|
||||
@@ -73,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)
|
||||
}
|
||||
@@ -136,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 {
|
||||
@@ -144,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
|
||||
}
|
||||
|
||||
@@ -219,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 {
|
||||
@@ -236,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
|
||||
}
|
||||
@@ -255,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.
|
||||
@@ -333,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
|
||||
}
|
||||
+1
-10
@@ -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.
|
||||
@@ -59,15 +58,7 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
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, user.Permissions.Admin, "TOTPSecret", "TOTPNonce"); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
+9
-11
@@ -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"
|
||||
)
|
||||
|
||||
@@ -58,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
|
||||
@@ -86,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{
|
||||
@@ -114,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
|
||||
@@ -260,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
|
||||
}
|
||||
|
||||
+3
-2
@@ -14,6 +14,7 @@ import (
|
||||
"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"
|
||||
@@ -22,7 +23,7 @@ import (
|
||||
"github.com/swaggo/swag"
|
||||
)
|
||||
|
||||
var store *storage.Storage
|
||||
var store *bolt.BoltStore
|
||||
|
||||
func getStore(configFile string) bool {
|
||||
// Use the config file (global flag)
|
||||
@@ -119,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,9 +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,11 +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
|
||||
}
|
||||
@@ -69,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
|
||||
@@ -100,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,14 @@ 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.Errorf("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: ".",
|
||||
@@ -267,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")
|
||||
@@ -284,12 +310,43 @@ 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")
|
||||
@@ -309,7 +366,6 @@ func setDefaults() Settings {
|
||||
},
|
||||
Auth: Auth{
|
||||
AdminUsername: "admin",
|
||||
AdminPassword: "admin",
|
||||
TokenExpirationHours: 2,
|
||||
Methods: LoginMethods{
|
||||
PasswordAuth: PasswordAuthConfig{
|
||||
@@ -322,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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -450,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"`
|
||||
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
|
||||
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:"indexingIntervalMinutes"` // optional manual overide interval in seconds 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
|
||||
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,6 +138,10 @@ type ExternalLink struct {
|
||||
// UserDefaults is a type that holds the default values
|
||||
// for some fields on User.
|
||||
type UserDefaults struct {
|
||||
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
|
||||
@@ -123,8 +152,8 @@ type UserDefaults struct {
|
||||
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
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // comma separated list of file extensions to disable office preview for
|
||||
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"`
|
||||
@@ -132,4 +161,10 @@ type UserDefaults struct {
|
||||
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)
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -153,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)
|
||||
@@ -234,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", "apikeys", "totpenabled", "totpsecret", "totpnonce":
|
||||
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,32 +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
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // comma separated list of file extensions to disable office preview for
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
|
||||
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 {
|
||||
|
||||
+33
-26
@@ -1,13 +1,13 @@
|
||||
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/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
|
||||
@@ -15,21 +15,23 @@ require (
|
||||
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.39.0
|
||||
golang.org/x/image v0.28.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.1.0 // indirect
|
||||
@@ -41,6 +43,7 @@ require (
|
||||
github.com/KyleBanks/depth v1.2.1 // 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.6 // indirect
|
||||
@@ -51,10 +54,12 @@ require (
|
||||
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.7.0 // indirect
|
||||
github.com/boombuler/barcode v1.0.2 // 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
|
||||
@@ -71,6 +76,7 @@ require (
|
||||
github.com/chavacava/garif v0.1.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.6 // indirect
|
||||
github.com/dave/dst v0.27.3 // indirect
|
||||
@@ -84,15 +90,14 @@ require (
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.6 // indirect
|
||||
github.com/fsnotify/fsnotify v1.5.4 // 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.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
|
||||
@@ -109,7 +114,8 @@ 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-20250613135800-9e8e59d779cc // 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
|
||||
@@ -136,7 +142,7 @@ require (
|
||||
github.com/jjti/go-spancheck v0.6.4 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/jupiterrider/ffi v0.5.0 // indirect
|
||||
github.com/jupiterrider/ffi v0.5.1 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
|
||||
github.com/kisielk/errcheck v1.9.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
@@ -164,7 +170,7 @@ require (
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // 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
|
||||
@@ -176,7 +182,6 @@ require (
|
||||
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.8.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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
|
||||
@@ -201,7 +206,7 @@ require (
|
||||
github.com/sivchari/containedctx v1.0.3 // 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
|
||||
@@ -212,6 +217,7 @@ 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/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
|
||||
@@ -227,22 +233,22 @@ require (
|
||||
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.1 // indirect
|
||||
go-simpler.org/sloglint v0.11.0 // indirect
|
||||
go.augendre.info/fatcontext v0.8.0 // indirect
|
||||
go.etcd.io/bbolt v1.4.1 // 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.25.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // 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
|
||||
@@ -253,6 +259,7 @@ require (
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/air-verse/air
|
||||
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
|
||||
github.com/swaggo/swag/cmd/swag
|
||||
)
|
||||
+155
-60
@@ -34,6 +34,8 @@ 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=
|
||||
@@ -45,6 +47,8 @@ github.com/Antonboom/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+n
|
||||
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.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
@@ -63,6 +67,8 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsu
|
||||
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=
|
||||
@@ -84,6 +90,8 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ
|
||||
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
|
||||
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=
|
||||
@@ -98,6 +106,32 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
|
||||
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=
|
||||
@@ -105,8 +139,8 @@ github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkAp
|
||||
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/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=
|
||||
@@ -143,13 +177,17 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
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.6 h1:RKuEOSkGpSadkGbvZ6hJ4ddItT3cVZ9Vn9Rybk6xjl8=
|
||||
@@ -163,6 +201,8 @@ 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/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=
|
||||
@@ -190,22 +230,29 @@ 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.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
|
||||
github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo=
|
||||
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/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/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=
|
||||
@@ -219,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=
|
||||
@@ -229,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=
|
||||
@@ -246,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=
|
||||
@@ -276,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=
|
||||
@@ -283,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-20250613135800-9e8e59d779cc h1:HeTandKJybEWXv6wW9o3ybXTNqGGV4Q6kW3S+8RsXbk=
|
||||
github.com/golang/geo v0.0.0-20250613135800-9e8e59d779cc/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=
|
||||
@@ -327,7 +389,6 @@ 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/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=
|
||||
@@ -390,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=
|
||||
@@ -408,6 +471,8 @@ 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.8.1 h1:PPqCYp3K/xlOj5JmIe6O1Mj6r1DbkdbLtR3AJuZo414=
|
||||
@@ -429,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.5.0 h1:j2nSgpabbV1JOwgP4Kn449sJUHq3cVLAZVBoOYn44V8=
|
||||
github.com/jupiterrider/ffi v0.5.0/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
|
||||
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=
|
||||
@@ -454,6 +519,8 @@ 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.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.3 h1:Ag1aGiq2epGePuRJhez2mzOpZ8sI9Gimcb4Sb3+pk9Y=
|
||||
@@ -478,12 +545,16 @@ github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamh
|
||||
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=
|
||||
@@ -501,27 +572,37 @@ 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.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0=
|
||||
@@ -535,10 +616,14 @@ 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.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=
|
||||
@@ -547,8 +632,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q=
|
||||
github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s=
|
||||
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/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=
|
||||
@@ -612,8 +695,6 @@ github.com/securego/gosec/v2 v2.22.3 h1:mRrCNmRF2NgZp4RJ8oJ6yPJ7G4x6OCiAXHd8x4tr
|
||||
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/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/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=
|
||||
@@ -630,8 +711,8 @@ github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCp
|
||||
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=
|
||||
@@ -667,16 +748,24 @@ 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.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=
|
||||
@@ -714,8 +803,10 @@ 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=
|
||||
@@ -727,8 +818,8 @@ go-simpler.org/sloglint v0.11.0/go.mod h1:CFDO8R1i77dlciGfPEPvYke2ZMx4eyGiEIWkye
|
||||
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.1 h1:5mOV+HWjIPLEAlUGMsveaUvK2+byZMFOzojoi7bh7uI=
|
||||
go.etcd.io/bbolt v1.4.1/go.mod h1:c8zu2BnXWTu2XM4XcICtbGSl9cFwsXtcf9zLt2OncM8=
|
||||
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=
|
||||
@@ -753,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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
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=
|
||||
@@ -765,16 +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.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
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=
|
||||
@@ -802,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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.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=
|
||||
@@ -848,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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
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=
|
||||
@@ -873,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.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.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=
|
||||
@@ -887,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=
|
||||
@@ -909,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=
|
||||
@@ -920,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=
|
||||
@@ -955,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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
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=
|
||||
@@ -1016,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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
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=
|
||||
@@ -1136,6 +1229,8 @@ 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()
|
||||
|
||||
+137
-31
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,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"
|
||||
@@ -21,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 {
|
||||
@@ -43,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
|
||||
@@ -62,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
|
||||
@@ -101,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
|
||||
@@ -159,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)
|
||||
@@ -169,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) {
|
||||
@@ -187,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)
|
||||
}
|
||||
}
|
||||
@@ -286,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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+112
-37
@@ -2,6 +2,7 @@ package http
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"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"
|
||||
@@ -26,12 +28,63 @@ type userInfo struct {
|
||||
Email string `json:"email"`
|
||||
Sub string `json:"sub"`
|
||||
Phone string `json:"phone_number"`
|
||||
Groups []string `json:"groups"`
|
||||
Groups []string `json:"-"` // Handled manually by userInfoUnmarshaller
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
@@ -53,15 +106,22 @@ func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
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 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.
|
||||
// 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
|
||||
@@ -114,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.")
|
||||
@@ -126,9 +192,9 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
logger.Debugf("failed to verify ID token: %v. This might be expected, falling back to UserInfo endpoint.", err)
|
||||
// Verification failed, claimsFromIDToken remains false
|
||||
} else {
|
||||
// Decode the ID token claims 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(&userdata); 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 {
|
||||
@@ -175,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)
|
||||
}
|
||||
@@ -195,25 +261,26 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
isAdmin := false // Default to non-admin user
|
||||
if config.Auth.Methods.OidcAuth.AdminGroup != "" {
|
||||
if slices.Contains(userdata.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.", loginUsername, config.Auth.Methods.OidcAuth.AdminGroup)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, isAdmin)
|
||||
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, isAdmin bool) (int, error) {
|
||||
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)
|
||||
@@ -222,10 +289,18 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
if config.Auth.Methods.OidcAuth.CreateUser {
|
||||
err = storage.CreateUser(users.User{
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
if config.Auth.Methods.OidcAuth.AdminGroup == "" {
|
||||
isAdmin = config.UserDefaults.Permissions.Admin
|
||||
}
|
||||
user = &users.User{
|
||||
Username: username,
|
||||
}, isAdmin)
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
}
|
||||
settings.ApplyUserDefaults(user)
|
||||
if isAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
err = storage.CreateUser(*user)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -238,7 +313,7 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
}
|
||||
} else {
|
||||
// update user admin perms
|
||||
if isAdmin != user.Permissions.Admin {
|
||||
if isAdmin != user.Permissions.Admin && config.Auth.Methods.OidcAuth.AdminGroup != "" {
|
||||
user.Permissions.Admin = isAdmin
|
||||
err = store.Users.Update(user, true, "Permissions")
|
||||
if err != nil {
|
||||
@@ -247,19 +322,19 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+13
-14
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
+95
-18
@@ -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))
|
||||
@@ -56,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
|
||||
@@ -127,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()
|
||||
@@ -145,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 {
|
||||
@@ -185,19 +242,21 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
firstFilePath := splitFile[1]
|
||||
// decode url encoded source name
|
||||
var err error
|
||||
var userscope string
|
||||
fileName := filepath.Base(firstFilePath)
|
||||
userscope := "/"
|
||||
if d.user.Username != "publicUser" {
|
||||
if d.share == nil {
|
||||
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, firstFileSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
}
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
idx := indexing.GetIndex(firstFileSource)
|
||||
if idx == nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
|
||||
}
|
||||
realPath, isDir, err := idx.GetRealPath(userscope, firstFilePath)
|
||||
realPath, isDir, err := idx.GetRealPath(firstFilePath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -232,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
|
||||
}
|
||||
|
||||
@@ -300,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)
|
||||
@@ -324,14 +399,16 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
|
||||
if idx == nil {
|
||||
return 0, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
+232
-80
@@ -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,25 +71,32 @@ import (
|
||||
func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = config.Server.DefaultSource.Name
|
||||
}
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
scopePath := utils.JoinPathAsUnix(userscope, path)
|
||||
getContent := r.URL.Query().Get("content") == "true"
|
||||
if d.share != nil && d.share.DisableFileViewer {
|
||||
getContent = false
|
||||
}
|
||||
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
|
||||
Path: scopePath,
|
||||
Modify: d.user.Permissions.Modify,
|
||||
Source: source,
|
||||
Expand: true,
|
||||
Content: r.URL.Query().Get("content") == "true",
|
||||
Access: store.Access,
|
||||
Username: d.user.Username,
|
||||
Path: scopePath,
|
||||
Modify: d.user.Permissions.Modify,
|
||||
Source: source,
|
||||
Expand: true,
|
||||
Content: getContent,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
@@ -96,8 +135,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the resource"
|
||||
// @Param source query string false "Source name for the desired source, default is used if not provided"
|
||||
// @Param source query string false "Name for the desired source, default is used if not provided"
|
||||
// @Param source query string true "Source name for the desired source, default is used if not provided"
|
||||
// @Success 200 "Resource deleted successfully"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Resource not found"
|
||||
@@ -107,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)
|
||||
@@ -130,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
|
||||
@@ -156,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"
|
||||
@@ -168,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" {
|
||||
@@ -206,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
|
||||
|
||||
}
|
||||
@@ -228,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"
|
||||
@@ -238,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")
|
||||
@@ -267,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
|
||||
@@ -343,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
|
||||
@@ -368,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)
|
||||
}
|
||||
@@ -391,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,
|
||||
})
|
||||
@@ -414,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
|
||||
@@ -449,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
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import (
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param query query string true "Search query"
|
||||
// @Param source query string true "Source name for the desired source"
|
||||
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only"
|
||||
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
|
||||
// @Success 200 {array} indexing.SearchResult "List of search results"
|
||||
@@ -58,9 +59,6 @@ import (
|
||||
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
query := r.URL.Query().Get("query")
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = config.Server.DefaultSource.Name
|
||||
}
|
||||
scope := r.URL.Query().Get("scope")
|
||||
unencodedScope, err := url.QueryUnescape(scope)
|
||||
if err != nil {
|
||||
|
||||
+339
-54
@@ -7,17 +7,45 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
)
|
||||
|
||||
// ShareResponse represents a share with computed username field and download URL
|
||||
type ShareResponse struct {
|
||||
*share.Link
|
||||
Username string `json:"username,omitempty"`
|
||||
DownloadURL string `json:"downloadURL,omitempty"`
|
||||
}
|
||||
|
||||
// convertToShareResponse converts shares to response format with usernames
|
||||
func convertToShareResponse(r *http.Request, shares []*share.Link) ([]*ShareResponse, error) {
|
||||
responses := make([]*ShareResponse, len(shares))
|
||||
for i, s := range shares {
|
||||
user, err := store.Users.Get(s.UserID)
|
||||
username := ""
|
||||
if err == nil {
|
||||
username = user.Username
|
||||
}
|
||||
responses[i] = &ShareResponse{
|
||||
Link: s,
|
||||
Username: username,
|
||||
DownloadURL: getDownloadURL(r, s.Hash),
|
||||
}
|
||||
}
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
// shareListHandler returns a list of all share links.
|
||||
// @Summary List share links
|
||||
// @Description Returns a list of share links for the current user, or all links if the user is an admin.
|
||||
@@ -28,29 +56,22 @@ import (
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/shares [get]
|
||||
func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
var (
|
||||
s []*share.Link
|
||||
err error
|
||||
)
|
||||
var err error
|
||||
var shares []*share.Link
|
||||
if d.user.Permissions.Admin {
|
||||
s, err = store.Share.All()
|
||||
shares, err = store.Share.All()
|
||||
} else {
|
||||
s, err = store.Share.FindByUserID(d.user.ID)
|
||||
shares, err = store.Share.FindByUserID(d.user.ID)
|
||||
}
|
||||
if err == errors.ErrNotExist {
|
||||
return renderJSON(w, r, []*share.Link{})
|
||||
if err != nil && err != errors.ErrNotExist {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
shares = utils.NonNilSlice(shares)
|
||||
sharesWithUsernames, err := convertToShareResponse(r, shares)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
sort.Slice(s, func(i, j int) bool {
|
||||
if s[i].UserID != s[j].UserID {
|
||||
return s[i].UserID < s[j].UserID
|
||||
}
|
||||
return s[i].Expire < s[j].Expire
|
||||
})
|
||||
return renderJSON(w, r, s)
|
||||
return renderJSON(w, r, sharesWithUsernames)
|
||||
}
|
||||
|
||||
// shareGetsHandler retrieves share links for a specific resource path.
|
||||
@@ -66,29 +87,34 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Router /api/share [get]
|
||||
func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = settings.Config.Server.DefaultSource.Name
|
||||
}
|
||||
sourceName := r.URL.Query().Get("source")
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
|
||||
sourcePath, ok := config.Server.NameToSource[source]
|
||||
sourceInfo, ok := config.Server.NameToSource[sourceName] // backend source is path
|
||||
if !ok {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", sourceName)
|
||||
}
|
||||
s, err := store.Share.Gets(path, sourcePath.Path, d.user.ID)
|
||||
if err == errors.ErrNotExist {
|
||||
return renderJSON(w, r, []*share.Link{})
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, sourceName)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
scopePath := utils.JoinPathAsUnix(userscope, path)
|
||||
s, err := store.Share.Gets(scopePath, sourceInfo.Path, d.user.ID)
|
||||
if err == errors.ErrNotExist || len(s) == 0 {
|
||||
return renderJSON(w, r, []*ShareResponse{})
|
||||
}
|
||||
// DownloadURL will be set in convertToShareResponse
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
|
||||
}
|
||||
return renderJSON(w, r, s)
|
||||
sharesWithUsernames, err := convertToShareResponse(r, s)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return renderJSON(w, r, sharesWithUsernames)
|
||||
}
|
||||
|
||||
// shareDeleteHandler deletes a specific share link by its hash.
|
||||
@@ -97,11 +123,11 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash path string true "Hash of the share link to delete"
|
||||
// @Param hash query string true "Hash of the share link to delete"
|
||||
// @Success 200 "Share link deleted successfully"
|
||||
// @Failure 400 {object} map[string]string "Bad request - missing or invalid hash"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/shares/{hash} [delete]
|
||||
// @Router /api/shares [delete]
|
||||
func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
|
||||
@@ -123,26 +149,28 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body share.CreateBody true "Share link creation parameters"
|
||||
// @Param path path string true "Source Path of the files to share"
|
||||
// @Param source path string true "Source name of the files to share"
|
||||
// @Param body body share.CreateBody true "Share creation parameters"
|
||||
// @Success 200 {object} share.Link "Created share link"
|
||||
// @Failure 400 {object} map[string]string "Bad request - failed to decode body"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/shares [post]
|
||||
// @Router /api/share [post]
|
||||
func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
var s *share.Link
|
||||
var err error
|
||||
var body share.CreateBody
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
if err = json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
}
|
||||
|
||||
secure_hash, err := generateShortUUID()
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
// check if body.Hash is a valid hash
|
||||
if body.Hash != "" {
|
||||
s, err = store.Share.GetByHash(body.Hash)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid hash provided")
|
||||
}
|
||||
}
|
||||
|
||||
var expire int64 = 0
|
||||
@@ -183,33 +211,279 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
token = base64.URLEncoding.EncodeToString(tokenBuffer)
|
||||
stringHash = string(hash)
|
||||
}
|
||||
if s != nil {
|
||||
s.Expire = expire
|
||||
s.PasswordHash = stringHash
|
||||
s.Token = token
|
||||
// Preserve immutable fields for updates. Path and Source should not change on edits.
|
||||
// If the request attempts to provide empty values (or any values) for these,
|
||||
// keep the existing ones from the stored share.
|
||||
body.Path = s.Path
|
||||
body.Source = s.Source
|
||||
s.CommonShare = body.CommonShare
|
||||
if err = store.Share.Save(s); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// Convert to ShareResponse format with username
|
||||
var user *users.User
|
||||
user, err = store.Users.Get(s.UserID)
|
||||
username := ""
|
||||
if err == nil {
|
||||
username = user.Username
|
||||
}
|
||||
response := &ShareResponse{
|
||||
Link: s,
|
||||
Username: username,
|
||||
}
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
source, ok := config.Server.NameToSource[body.Source]
|
||||
if !ok {
|
||||
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", body.Source)
|
||||
}
|
||||
|
||||
if source.Config.Private {
|
||||
return http.StatusForbidden, fmt.Errorf("the target source is private, sharing is not permitted")
|
||||
}
|
||||
|
||||
// create a new share link
|
||||
secure_hash, err := generateShortUUID()
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// validate source path exists
|
||||
idx := indexing.GetIndex(source.Name)
|
||||
if idx == nil {
|
||||
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", body.Source)
|
||||
}
|
||||
|
||||
// validate path exists as file or folder
|
||||
_, exists := idx.GetReducedMetadata(body.Path, true) // true to check if it exists
|
||||
if !exists {
|
||||
// could be a file instead
|
||||
_, exists := idx.GetReducedMetadata(utils.GetParentDirectoryPath(body.Path), true)
|
||||
if !exists {
|
||||
return http.StatusForbidden, fmt.Errorf("path not found: %s", body.Path)
|
||||
}
|
||||
}
|
||||
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source.Name)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
scopePath := utils.JoinPathAsUnix(userscope, body.Path)
|
||||
body.Path = scopePath
|
||||
body.Source = source.Path // backend source is path
|
||||
s = &share.Link{
|
||||
Expire: expire,
|
||||
UserID: d.user.ID,
|
||||
Hash: secure_hash,
|
||||
PasswordHash: stringHash,
|
||||
Token: token,
|
||||
CommonShare: body.CommonShare,
|
||||
}
|
||||
|
||||
if err = store.Share.Save(s); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// Convert to ShareResponse format with username
|
||||
var user *users.User
|
||||
user, err = store.Users.Get(s.UserID)
|
||||
username := ""
|
||||
if err == nil {
|
||||
username = user.Username
|
||||
}
|
||||
response := &ShareResponse{
|
||||
Link: s,
|
||||
Username: username,
|
||||
}
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
// DirectDownloadResponse represents the response for direct download endpoint
|
||||
type DirectDownloadResponse struct {
|
||||
Status string `json:"status"`
|
||||
Hash string `json:"hash"`
|
||||
DownloadURL string `json:"url"`
|
||||
}
|
||||
|
||||
// shareDirectDownloadHandler creates a direct download link for files only.
|
||||
// @Summary Create direct download link
|
||||
// @Description Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "File path to create download link for"
|
||||
// @Param source query string true "Source name for the file"
|
||||
// @Param duration query string false "Duration in minutes for link validity (default: 60)"
|
||||
// @Param count query string false "Maximum number of downloads allowed (default: unlimited)"
|
||||
// @Param speed query string false "Download speed limit in kbps (default: unlimited)"
|
||||
// @Success 201 {object} DirectDownloadResponse "Direct download link created"
|
||||
// @Failure 400 {object} map[string]string "Bad request - invalid parameters or path is not a file"
|
||||
// @Failure 403 {object} map[string]string "Forbidden - access denied"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /public/share/direct [get]
|
||||
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Extract query parameters
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
duration := r.URL.Query().Get("duration")
|
||||
downloadCountStr := r.URL.Query().Get("count")
|
||||
downloadSpeedStr := r.URL.Query().Get("speed")
|
||||
|
||||
// Validate required parameters
|
||||
if encodedPath == "" || source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and source are required")
|
||||
}
|
||||
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
sourceName := r.URL.Query().Get("source")
|
||||
if sourceName == "" {
|
||||
sourceName = config.Server.DefaultSource.Name
|
||||
}
|
||||
source := config.Server.NameToSource[sourceName]
|
||||
|
||||
s = &share.Link{
|
||||
Path: path,
|
||||
Hash: secure_hash,
|
||||
Source: source.Path, // path instead to persist accoss name change
|
||||
Expire: expire,
|
||||
UserID: d.user.ID,
|
||||
PasswordHash: stringHash,
|
||||
Token: token,
|
||||
// Validate source exists
|
||||
sourceInfo, ok := config.Server.NameToSource[source]
|
||||
if !ok {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
|
||||
}
|
||||
|
||||
if err := store.Share.Save(s); err != nil {
|
||||
// Get user scope for this source
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
// Validate the path exists and is a file (not a folder)
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", source)
|
||||
}
|
||||
|
||||
metadata, exists := idx.GetReducedMetadata(path, false)
|
||||
if !exists {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is either not a file or not found: %s", path)
|
||||
}
|
||||
|
||||
// Check if it's a file (not a directory)
|
||||
if metadata.Type == "directory" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path must be a file, not a directory: %s", path)
|
||||
}
|
||||
|
||||
// Set default duration to 60 minutes if not provided
|
||||
if duration == "" {
|
||||
duration = "60"
|
||||
}
|
||||
|
||||
// Parse download count
|
||||
var downloadCount int
|
||||
if downloadCountStr != "" {
|
||||
downloadCount, err = strconv.Atoi(downloadCountStr)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid downloadCount: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse download speed (in bytes per second)
|
||||
var downloadSpeed int
|
||||
if downloadSpeedStr != "" {
|
||||
downloadSpeed, err = strconv.Atoi(downloadSpeedStr)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid downloadSpeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiration time
|
||||
durationNum, err := strconv.Atoi(duration)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
expire := time.Now().Add(time.Minute * time.Duration(durationNum)).Unix()
|
||||
|
||||
// Generate secure hash for the share
|
||||
secureHash, err := generateShortUUID()
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return renderJSON(w, r, s)
|
||||
// Create the scope path
|
||||
scopePath := utils.JoinPathAsUnix(userscope, path)
|
||||
|
||||
// Check if an existing share already matches these parameters
|
||||
existingShares, err := store.Share.Gets(scopePath, sourceInfo.Path, d.user.ID)
|
||||
if err == nil && len(existingShares) > 0 {
|
||||
// Look for a share that matches our parameters
|
||||
for _, existing := range existingShares {
|
||||
if existing.DownloadsLimit == downloadCount &&
|
||||
existing.MaxBandwidth == downloadSpeed &&
|
||||
existing.QuickDownload &&
|
||||
(existing.Expire == 0 || existing.Expire >= expire) { // Existing expires later or never
|
||||
|
||||
response := DirectDownloadResponse{
|
||||
Status: "201",
|
||||
Hash: existing.Hash,
|
||||
DownloadURL: getDownloadURL(r, existing.Hash),
|
||||
}
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No matching existing share found, create a new one
|
||||
shareLink := &share.Link{
|
||||
Expire: expire,
|
||||
UserID: d.user.ID,
|
||||
Hash: secureHash,
|
||||
CommonShare: share.CommonShare{
|
||||
Path: scopePath,
|
||||
Source: idx.Path,
|
||||
DownloadsLimit: downloadCount,
|
||||
MaxBandwidth: downloadSpeed,
|
||||
QuickDownload: true, // Enable quick download for direct downloads
|
||||
},
|
||||
}
|
||||
|
||||
// Save the share
|
||||
if err := store.Share.Save(shareLink); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Return response
|
||||
response := DirectDownloadResponse{
|
||||
Status: "200",
|
||||
Hash: secureHash,
|
||||
DownloadURL: getDownloadURL(r, secureHash),
|
||||
}
|
||||
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
func getDownloadURL(r *http.Request, hash string) string {
|
||||
var downloadURL string
|
||||
if config.Server.ExternalUrl != "" {
|
||||
downloadURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
|
||||
} else {
|
||||
// Prefer X-Forwarded-Host for proxy support
|
||||
var host string
|
||||
var scheme string
|
||||
|
||||
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
|
||||
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
|
||||
scheme = forwardedProto
|
||||
} else {
|
||||
scheme = "https"
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple approach
|
||||
host = r.Host
|
||||
scheme = getScheme(r)
|
||||
}
|
||||
downloadURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s", scheme, host, config.Server.BaseURL, hash)
|
||||
}
|
||||
return downloadURL
|
||||
}
|
||||
|
||||
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
|
||||
@@ -239,3 +513,14 @@ func generateShortUUID() (string, error) {
|
||||
// Trim the length to 22 characters for a shorter ID
|
||||
return uuid[:22], nil
|
||||
}
|
||||
|
||||
func redirectToShare(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Remove the base URL and "/share/" prefix to get the full path after share
|
||||
sharePath := strings.TrimPrefix(r.URL.Path, config.Server.BaseURL+"share/")
|
||||
newURL := config.Server.BaseURL + "public/share/" + sharePath
|
||||
if r.URL.RawQuery != "" {
|
||||
newURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
|
||||
return http.StatusMovedPermanently, nil
|
||||
}
|
||||
+126
-40
@@ -17,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")
|
||||
@@ -30,61 +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 != "",
|
||||
"MuPdfAvailable": config.Server.MuPdfAvailable,
|
||||
"UpdateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"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
|
||||
@@ -97,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")
|
||||
}
|
||||
+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
|
||||
}
|
||||
@@ -23,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"`
|
||||
@@ -40,12 +40,16 @@ type Index struct {
|
||||
CurrentSchedule int `json:"-"`
|
||||
settings.Source `json:"-"`
|
||||
Directories map[string]*iteminfo.FileInfo `json:"-"`
|
||||
DirectoriesLedger map[string]bool `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 (
|
||||
@@ -71,7 +75,9 @@ func Initialize(source settings.Source, mock bool) {
|
||||
mock: mock,
|
||||
Source: source,
|
||||
Directories: make(map[string]*iteminfo.FileInfo),
|
||||
DirectoriesLedger: make(map[string]bool),
|
||||
DirectoriesLedger: make(map[string]struct{}),
|
||||
processedInodes: make(map[uint64]struct{}),
|
||||
FoundHardLinks: make(map[string]uint64),
|
||||
}
|
||||
newIndex.ReducedIndex = ReducedIndex{
|
||||
Status: "indexing",
|
||||
@@ -109,8 +115,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
|
||||
|
||||
// check if excluded from indexing
|
||||
hidden := isHidden(dirInfo, idx.Path+adjustedPath)
|
||||
if idx.shouldSkip(dirInfo.IsDir(), hidden, adjustedPath) {
|
||||
logger.Debugf("skipping directory %s due to exclusion rules", adjustedPath)
|
||||
if idx.shouldSkip(dirInfo.IsDir(), hidden, adjustedPath, dirInfo.Name()) {
|
||||
return errors.ErrNotIndexed
|
||||
}
|
||||
|
||||
@@ -118,7 +123,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
|
||||
if recursive {
|
||||
// Prevent race conditions if scanning becomes concurrent in the future.
|
||||
idx.mu.Lock()
|
||||
idx.DirectoriesLedger[adjustedPath] = true
|
||||
idx.DirectoriesLedger[adjustedPath] = struct{}{}
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
combinedPath := adjustedPath + "/"
|
||||
@@ -153,7 +158,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
|
||||
return nil
|
||||
}
|
||||
}
|
||||
dirFileInfo, err2 := idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, quick, recursive)
|
||||
dirFileInfo, err2 := idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, quick, recursive, true)
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
@@ -168,10 +173,7 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
|
||||
return nil, err
|
||||
}
|
||||
originalPath := realPath
|
||||
if !isDir {
|
||||
// get parent directory info
|
||||
realPath = filepath.Dir(realPath)
|
||||
}
|
||||
|
||||
dir, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -182,12 +184,25 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
|
||||
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)
|
||||
response, err = idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, false, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -214,7 +229,7 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
|
||||
|
||||
}
|
||||
|
||||
func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjustedPath, combinedPath string, quick, recursive bool) (*iteminfo.FileInfo, error) {
|
||||
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 := dirInfo.Readdir(-1)
|
||||
if err != nil {
|
||||
@@ -228,8 +243,14 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
for _, file := range files {
|
||||
hidden := isHidden(file, idx.Path+combinedPath)
|
||||
isDir := iteminfo.IsDirectory(file)
|
||||
fullCombined := combinedPath + file.Name()
|
||||
if idx.shouldSkip(isDir, hidden, fullCombined) && recursive {
|
||||
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{
|
||||
@@ -239,12 +260,21 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
}
|
||||
|
||||
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
|
||||
@@ -262,20 +292,33 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
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.Config.IgnoreZeroSizeFolders {
|
||||
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,
|
||||
@@ -339,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)
|
||||
@@ -395,57 +438,88 @@ func isHidden(file os.FileInfo, srcPath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined string) bool {
|
||||
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 len(rules.RootFiles) > 0 {
|
||||
hasRules = true
|
||||
for _, p := range rules.RootFiles {
|
||||
if strings.HasPrefix(fullCombined, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasRules {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined, baseName string) bool {
|
||||
if idx.Config.DisableIndexing {
|
||||
return true
|
||||
}
|
||||
// check inclusions first
|
||||
if isDir && len(idx.Config.Include.Folders) > 0 {
|
||||
if !slices.Contains(idx.Config.Include.Folders, fullCombined) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !isDir && len(idx.Config.Include.Files) > 0 {
|
||||
if !slices.Contains(idx.Config.Include.Files, fullCombined) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if !isDir && len(idx.Config.Include.FileEndsWith) > 0 {
|
||||
shouldSkip := true
|
||||
for _, end := range idx.Config.Include.FileEndsWith {
|
||||
if strings.HasSuffix(fullCombined, end) {
|
||||
shouldSkip = false
|
||||
break
|
||||
rules := idx.Config.Exclude
|
||||
|
||||
if isDir {
|
||||
if len(rules.FolderPaths) > 0 {
|
||||
for _, p := range rules.FolderPaths {
|
||||
if strings.HasPrefix(fullCombined, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if shouldSkip {
|
||||
if len(rules.FolderNames) > 0 && slices.Contains(rules.FolderNames, baseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// check exclusions
|
||||
if isDir && slices.Contains(idx.Config.Exclude.Folders, fullCombined) {
|
||||
return true
|
||||
}
|
||||
if !isDir && slices.Contains(idx.Config.Exclude.Files, fullCombined) {
|
||||
return true
|
||||
}
|
||||
if idx.Config.IgnoreHidden && isHidden {
|
||||
return true
|
||||
}
|
||||
|
||||
if !isDir && len(idx.Config.Exclude.FileEndsWith) > 0 {
|
||||
shouldSkip := false
|
||||
for _, end := range idx.Config.Exclude.FileEndsWith {
|
||||
if strings.HasSuffix(fullCombined, end) {
|
||||
shouldSkip = true
|
||||
break
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
return shouldSkip
|
||||
}
|
||||
|
||||
if rules.Hidden && isHidden {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -454,14 +528,13 @@ 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.DiskUsed = int64(usage.Used)
|
||||
idx.DiskTotal = int64(usage.Total)
|
||||
idx.DiskTotal = totalBytes
|
||||
}
|
||||
|
||||
func (idx *Index) SetStatus(status IndexStatus) {
|
||||
@@ -476,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.
|
||||
}
|
||||
@@ -56,6 +56,7 @@ 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 {
|
||||
@@ -73,7 +74,7 @@ func (idx *Index) garbageCollection() {
|
||||
}
|
||||
}
|
||||
// Reset the ledger for the next scan.
|
||||
idx.DirectoriesLedger = make(map[string]bool)
|
||||
idx.DirectoriesLedger = make(map[string]struct{})
|
||||
}
|
||||
|
||||
func (idx *Index) UpdateSchedule() {
|
||||
@@ -136,8 +137,12 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
|
||||
logger.Debugf("Starting quick scan for [%v]", idx.Name)
|
||||
} else {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -41,7 +42,6 @@ var MuPdfConvertable = []string{
|
||||
".svg", // SVG
|
||||
".txt", // TXT
|
||||
".docx", // DOCX
|
||||
".ppt", // PPT
|
||||
".pptx", // PPTX
|
||||
".xlsx", // XLSX
|
||||
".hwp", // HWP
|
||||
@@ -105,14 +105,27 @@ var documentTypes = []string{
|
||||
}
|
||||
|
||||
var onlyOfficeSupported = []string{
|
||||
// Word Processing Documents
|
||||
".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".epub",
|
||||
".fb2", ".fodt", ".htm", ".html", ".mht", ".mhtml", ".odt",
|
||||
".ott", ".rtf", ".stw", ".sxw", ".txt", ".wps", ".wpt", ".xml",
|
||||
".hwp", ".hwpx", ".md", ".pages", // Added missing Word extensions
|
||||
|
||||
// Spreadsheet Documents
|
||||
".csv", ".et", ".ett", ".fods", ".ods", ".ots", ".sxc", ".xls",
|
||||
".xlsb", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx", ".dps",
|
||||
".dpt", ".fodp", ".odp", ".otp", ".pot", ".potm", ".potx",
|
||||
".xlsb", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx",
|
||||
".numbers", // Added missing Spreadsheet extension
|
||||
|
||||
// Presentation Documents
|
||||
".dps", ".dpt", ".fodp", ".odp", ".otp", ".pot", ".potm", ".potx",
|
||||
".pps", ".ppsm", ".ppsx", ".ppt", ".pptm", ".pptx", ".sxi",
|
||||
".key", ".odg", // Added missing Presentation extensions
|
||||
|
||||
// Other Office-Related Formats
|
||||
".djvu", ".docxf", ".oform", ".oxps", ".pdf", ".xps",
|
||||
|
||||
// Diagram Documents (New category from List 2)
|
||||
".vsdm", ".vsdx", ".vssm", ".vssx", ".vstm", ".vstx",
|
||||
}
|
||||
|
||||
// Text-based file extensions
|
||||
@@ -400,3 +413,12 @@ func HasDocConvertableExtension(name, mimetype string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var ONLYOFFICE_READONLY_FILE_EXTENSIONS = []string{"pages", "numbers", "key"}
|
||||
|
||||
func CanEditOnlyOffice(modify bool, extention string) bool {
|
||||
if !modify {
|
||||
return false
|
||||
}
|
||||
return !slices.Contains(ONLYOFFICE_READONLY_FILE_EXTENSIONS, strings.ToLower(extention))
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package iteminfo
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
)
|
||||
|
||||
type ItemInfo struct {
|
||||
@@ -37,6 +39,8 @@ type ExtendedFileInfo struct {
|
||||
|
||||
// FileOptions are the options when getting a file info.
|
||||
type FileOptions struct {
|
||||
Access *access.Storage
|
||||
Username string // username for access control
|
||||
Path string // realpath
|
||||
Source string
|
||||
IsDir bool
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package iteminfo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -25,7 +27,7 @@ func (i *ExtendedFileInfo) DetectSubtitles(parentInfo *FileInfo) {
|
||||
|
||||
for _, subtitleExt := range SubtitleExts {
|
||||
if strings.HasSuffix(f.Name, subtitleExt) {
|
||||
i.Subtitles = append(i.Subtitles, parentInfo.Path+f.Name)
|
||||
i.Subtitles = append(i.Subtitles, f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,3 +59,31 @@ func (info *FileInfo) SortItems() {
|
||||
return strings.ToLower(info.Files[i].Name) < strings.ToLower(info.Files[j].Name)
|
||||
})
|
||||
}
|
||||
|
||||
// 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 := IsDirectory(info)
|
||||
return path, isDir, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,3 +31,28 @@ func (idx *Index) CreateMockData(numDirs, numFilesPerDir int) {
|
||||
idx.UpdateMetadata(dirInfo)
|
||||
}
|
||||
}
|
||||
|
||||
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-" + utils.GetRandomTerm() + utils.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-" + utils.GetRandomTerm() + utils.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
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
)
|
||||
|
||||
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
|
||||
@@ -100,17 +98,14 @@ func GetIndexInfo(sourceName string) (ReducedIndex, error) {
|
||||
cacheKey := "usageCache-" + sourceName
|
||||
_, ok = utils.DiskUsageCache.Get(cacheKey).(bool)
|
||||
if !ok {
|
||||
usage, err := disk.Usage(sourcePath)
|
||||
totalBytes, err := getPartitionSize(sourcePath)
|
||||
if err != nil {
|
||||
logger.Errorf("error getting disk usage for %s: %v", sourcePath, err)
|
||||
idx.SetStatus(UNAVAILABLE)
|
||||
return ReducedIndex{}, fmt.Errorf("error getting disk usage for %s: %v", sourcePath, err)
|
||||
}
|
||||
latestUsage := DiskUsage{
|
||||
Total: usage.Total,
|
||||
Used: usage.Used,
|
||||
}
|
||||
idx.SetUsage(latestUsage)
|
||||
|
||||
idx.SetUsage(totalBytes)
|
||||
utils.DiskUsageCache.Set(cacheKey, true)
|
||||
}
|
||||
return idx.ReducedIndex, nil
|
||||
|
||||
@@ -74,6 +74,7 @@ func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir strin
|
||||
settings.Config.Integrations.Media.FfmpegPath = filepath.Base(ffmpegMainPath)
|
||||
}
|
||||
settings.Config.Server.MuPdfAvailable = docEnabled()
|
||||
logger.Infof("MuPDF Enabled : %v", settings.Config.Server.MuPdfAvailable)
|
||||
return &Service{
|
||||
sem: make(chan struct{}, concurrencyLimit),
|
||||
ffmpegPath: ffmpegMainPath,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
.card,
|
||||
#listingView.gallery .item,
|
||||
#listingView.normal .item,
|
||||
.button,
|
||||
button,
|
||||
.button-action,
|
||||
.input,
|
||||
input,
|
||||
#listingView.gallery .item img,
|
||||
#listingView .item img,
|
||||
.sizeInputWrapper,
|
||||
.sizeInput,
|
||||
#search-input,
|
||||
.floating-tooltip,
|
||||
.settings-items>.item,
|
||||
.button-group {
|
||||
border-radius: 0.25em !important;
|
||||
}
|
||||
|
||||
#listingView.compact .header {
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
#listingView.list .item:last-child,
|
||||
#results {
|
||||
border-bottom-left-radius: 0.25em !important;
|
||||
border-bottom-right-radius: 0.25em !important;
|
||||
}
|
||||
|
||||
#listingView.list .item:first-child {
|
||||
border-top-left-radius: 0.25em !important;
|
||||
border-top-right-radius: 0.25em !important;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:last-child a {
|
||||
border-top-right-radius: 0.25em !important;
|
||||
border-bottom-right-radius: 0.25em !important;
|
||||
}
|
||||
#breadcrumbs ul li:first-child a {
|
||||
border-top-left-radius: 0.25em !important;
|
||||
border-bottom-left-radius: 0.25em !important;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
border-radius: 0.1em !important;
|
||||
}
|
||||
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user