Compare commits

...
Author SHA1 Message Date
Graham Steffaniak 018be705a6 updated with hotloading (#1153) 2025-08-28 15:00:12 -05:00
Graham Steffaniak ff5010ed4d updated changelog 2025-08-27 13:14:50 -05:00
Graham Steffaniak 226038b11d moved css to search component (#1149) 2025-08-27 13:12:02 -05:00
Graham Steffaniak 206180de0d Beta/v0.8.2 (#1123) 2025-08-25 10:58:31 -05:00
Graham Steffaniak 5b0f388fa5 Beta/v0.8.1 (#1106) 2025-08-22 17:45:44 -05:00
7d0707ebe0 Beta/v0.8.0 (#1080)
Co-authored-by: Kurami <178341547+Kurami32@users.noreply.github.com>
Co-authored-by: Socramk <155040241+socramcaro2@users.noreply.github.com>
2025-08-20 10:04:44 -05:00
Kurami 41cb97a1c5 Updated translation for spanish (#1076) 2025-08-19 16:03:10 -05:00
Socramk 0b212ce1b4 Traduccion es (#1063) 2025-08-16 11:44:49 -05:00
Kurami 7f0a0209af Update translation for spanish (#1055) 2025-08-12 09:25:38 -05:00
Graham Steffaniakandmordillo 642333ec68 Beta/v0.7.18 (#1034)
Co-authored-by: mordillo <121123040+mordilloSan@users.noreply.github.com>
2025-07-30 17:50:07 -05:00
Graham Steffaniakandmordillo ec43926d3c Beta/v0.7.17 (#1017)
Co-authored-by: mordillo <121123040+mordilloSan@users.noreply.github.com>
2025-07-25 17:46:00 -05:00
Graham Steffaniak 9202043000 Update pr.yaml to run against main (#998) 2025-07-22 13:53:21 -05:00
Graham Steffaniak 4d04e02c31 v0.7.16-beta (#991) (#992) 2025-07-21 18:21:31 -05:00
Graham Steffaniak 04d72998b6 Beta/v0.7.15 (#988) 2025-07-20 11:38:03 -05:00
243 changed files with 16954 additions and 5306 deletions

No files matched your search

+93 -59
View File
@@ -3,82 +3,114 @@ name: PR Request
on:
pull_request:
branches:
- "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
@@ -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
View File
@@ -48,3 +48,4 @@ yarn-error.log*
bin/
build/
*__debug*
backend/custom.css
+161 -1
View File
@@ -2,6 +2,167 @@
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**:
@@ -15,7 +176,6 @@ All notable changes to this project will be documented in this file. For commit
- 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**:
+11 -22
View File
@@ -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**
@@ -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.
+9 -4
View File
@@ -22,7 +22,7 @@
## 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]
+7 -5
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps firefox
+11 -1
View File
@@ -1,8 +1,18 @@
FROM gtstef/playwright-base
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/general/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
COPY [ "./_docker/src/general/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+9 -1
View File
@@ -1,8 +1,16 @@
FROM gtstef/playwright-base
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/noauth/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+13
View File
@@ -0,0 +1,13 @@
FROM gtstef/playwright-base
COPY [ "./_docker/src/oidc/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
RUN mkdir -p ./http/dist
COPY [ "./_docker/src/oidc/backend/", "./"]
COPY ["./backend/filebrowser", "./"]
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
WORKDIR /app/frontend
RUN node mock-oidc-server.js & sleep 2 && cd ../backend/ && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+4 -1
View File
@@ -2,10 +2,13 @@ FROM gtstef/playwright-base
WORKDIR /app
COPY [ "./_docker/src/proxy/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+17
View File
@@ -0,0 +1,17 @@
FROM gtstef/playwright-base
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/sharing/", "./" ]
WORKDIR /app/frontend
COPY [ "./_docker/src/sharing/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+1
View File
@@ -0,0 +1 @@
FROM gtstef/playwright-base
+5 -4
View File
@@ -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
+1
View File
@@ -6,6 +6,7 @@ services:
- "80:80"
volumes:
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
filebrowser:
hostname: filebrowser
volumes:
@@ -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. */
@@ -10,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests-playwright/noauth-setup",
timeout: 5000,
testDir: "./tests-playwright/noauth",
globalSetup: "./tests/playwright/noauth-setup",
timeout: 10000,
testDir: "./tests/playwright/noauth",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
+38
View File
@@ -0,0 +1,38 @@
server:
logging:
- levels: "info|error|warning|debug"
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
auth:
methods:
password:
enabled: false
oidc:
enabled: true
createUser: true
clientId: "vyBrzOmcrU3OokiWEZoObdGBscw0eYPNJauBtOJr"
clientSecret: "gIMKbkEcckUhlGrnE2Vxi8eZhEcfxQqfwFgM0ATzk4DciHGrW2YRtQfqcpRTsmzrxEETkKvZ8yeK3sfoXnUV1IQTzqK7wxw3lEMtmeXi5clj0h7nH1rVGFq2Q3HL8zUe"
issuerUrl: "http://localhost:8080"
adminGroup: "Admins"
userDefaults:
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: false
api: false
@@ -0,0 +1,60 @@
import { OAuth2Server } from 'oauth2-mock-server';
// This is a list of pre-defined users. You can add more users here.
// The filebrowser application will use this data when a user logs in.
const users = {
// Default user
johndoe: {
sub: 'johndoe',
preferred_username: 'johndoe',
email: 'johndoe@example.com',
groups: ['Admins', 'Users'],
},
// Another example user
testuser: {
sub: 'testuser',
preferred_username: 'testuser',
email: 'testuser@example.com',
groups: ['Users'],
},
};
const server = new OAuth2Server();
// Generate a new RSA key for the server.
await server.issuer.keys.generate('RS256');
/**
* This hook is called before the ID token is signed.
* We're using it to add claims to the ID token.
* This is the primary way the application will get user information.
*/
server.service.on('beforeIdTokenSigning', (token, req) => {
// By default, the mock server uses 'johndoe' as the subject.
const user = users[token.payload.sub] || users.johndoe;
console.log(`[ID Token] Customizing token for user: ${user.sub}`);
Object.assign(token.payload, user);
});
/**
* This hook is called before the userinfo response is sent.
* We're using it to provide user claims when the application calls the /userinfo endpoint.
*/
server.service.on('beforeUserinfo', (userInfoResponse, req) => {
const user = users.johndoe;
console.log(`[UserInfo] Customizing userinfo for user: ${user.sub}`);
userInfoResponse.body = user;
});
const port = 8080;
const host = '0.0.0.0'; // Bind to 0.0.0.0 to be accessible within Docker
await server.start(port, host);
console.log(`OAuth 2 Mock Server listening on http://${host}:${port}`);
console.log(`Issuer URL: ${server.issuer.url}`);
console.log('---');
console.log('Pre-defined users:');
console.log(JSON.stringify(users, null, 2));
console.log('---');
@@ -0,0 +1,48 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/oidc-setup",
timeout: 5000,
testDir: "./tests/playwright/oidc",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
storageState: "loginAuth.json",
actionTimeout: 5000,
//storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});
+1
View File
@@ -0,0 +1 @@
demo-127.0.0.1:$apr1$j5FRA4Bc$RYdNFuM7YAAo9Qtie8w4b/
+2 -2
View File
@@ -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
+14 -2
View File
@@ -7,13 +7,25 @@ server {
listen 80;
server_name localhost 127.0.0.1;
location / {
location /subpath/public/ {
proxy_pass http://filebrowser:8080/subpath/public/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# requires basic auth demo-127.0.0.1:SecurePass123!
location /subpath {
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
proxy_set_header X-Username $uuid;
add_header X-Username $uuid;
proxy_pass http://filebrowser:8080;
proxy_pass http://filebrowser:8080/subpath;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -10,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
//globalSetup: "./global-setup",
globalSetup: "./tests/playwright/proxy-setup.ts",
timeout: 3000,
testDir: "./tests-playwright/proxy",
testDir: "./tests/playwright/proxy",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -25,8 +25,8 @@ export default defineConfig({
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 3000,
//storageState: "loginAuth.json",
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
+22
View File
@@ -0,0 +1,22 @@
server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
- path: "."
name: "docker"
config:
private: true
defaultEnabled: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
@@ -0,0 +1,47 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/global-setup",
timeout: 10000,
testDir: "./tests/playwright/sharing",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1/",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});
+17
View File
@@ -0,0 +1,17 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=testing'\" ."
bin = "tmp/filebrowser"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs"]
log = "air.log"
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = true
+5
View File
@@ -0,0 +1,5 @@
tmp/
air.log
filebrowser
generated.yaml
test_config.yaml
+69 -17
View File
@@ -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"
@@ -33,6 +34,9 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
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
@@ -47,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 {
@@ -70,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)
}
@@ -156,28 +160,77 @@ func RefreshIndex(source string, path string, isDir bool) error {
return idx.RefreshFileInfo(iteminfo.FileOptions{Path: path, IsDir: isDir})
}
func MoveResource(sourceIndex, destIndex, realsrc, realdst string) error {
// 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
}
err = RefreshIndex(sourceIndex, filepath.Dir(realsrc), true)
if err != nil {
return err
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)
}
return RefreshIndex(destIndex, filepath.Dir(realdst), true)
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
}
err = RefreshIndex(sourceIndex, filepath.Dir(realsrc), true)
if err != nil {
return err
}
return RefreshIndex(destIndex, filepath.Dir(realdst), true)
go RefreshIndex(sourceIndex, realsrc, isSrcDir) //nolint:errcheck
go RefreshIndex(destIndex, realdst, isDestDir) //nolint:errcheck
// update shares
return nil
}
func WriteDirectory(opts iteminfo.FileOptions) error {
@@ -187,7 +240,7 @@ 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
}
@@ -201,12 +254,12 @@ func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
}
dst, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(filepath.Dir(dst), 0775)
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
}
@@ -293,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
+6 -3
View File
@@ -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
}
+3 -3
View File
@@ -8,12 +8,12 @@ import (
)
var (
revokedApiKeyList map[string]bool
revokedApiKeyList map[string]struct{}
revokeMu sync.Mutex
)
func init() {
revokedApiKeyList = make(map[string]bool)
revokedApiKeyList = make(map[string]struct{})
}
// Auther is the authentication interface.
@@ -32,5 +32,5 @@ func IsRevokedApiKey(key string) bool {
func RevokeAPIKey(key string) {
revokeMu.Lock()
defer revokeMu.Unlock()
revokedApiKeyList[key] = true
revokedApiKeyList[key] = struct{}{}
}
+17 -4
View File
@@ -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.
@@ -35,9 +38,19 @@ func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error)
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
}
+6 -7
View File
@@ -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"
)
@@ -85,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{
@@ -113,7 +110,9 @@ 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)
}
@@ -259,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
View File
@@ -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")
}
+3 -7
View File
@@ -21,10 +21,6 @@ func validateUserInfo() {
for _, user := range usersList {
changePass := false
updateUser := false
if user.Username == "publicUser" {
settings.ApplyUserDefaults(user)
updateUser = true
}
if updateUserScopes(user) {
updateUser = true
}
@@ -65,7 +61,7 @@ func validateUserInfo() {
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)
@@ -95,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)
}
}
-1
View File
@@ -82,7 +82,6 @@ func validateOidcAuth() error {
if !oidcCfg.Enabled {
return errors.New("OIDC is not enabled")
}
ctx := context.Background()
// If disableVerifyTLS is true, create a custom HTTP client
+58 -15
View File
@@ -44,10 +44,10 @@ func Initialize(configFile string) {
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),
@@ -59,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 {
@@ -78,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 == "\\" {
@@ -139,8 +168,16 @@ 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, "/")
}
@@ -188,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,
@@ -335,17 +378,17 @@ func setDefaults(generate bool) 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,
+29 -21
View File
@@ -185,7 +185,7 @@ func GenerateYaml() {
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"
@@ -226,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.
+1 -1
View File
@@ -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
+26 -5
View File
@@ -34,8 +34,8 @@ 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
@@ -77,7 +77,10 @@ type Source struct {
}
type SourceConfig struct {
IndexingInterval uint32 `json:"indexingIntervalMinutes"` // optional manual overide interval in seconds to re-index the source
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)
@@ -110,6 +113,20 @@ type Frontend struct {
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 {
@@ -135,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"`
@@ -146,4 +163,8 @@ type UserDefaults struct {
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
}
+109
View File
@@ -0,0 +1,109 @@
package settings
import (
"os"
"regexp"
"strings"
"github.com/gtsteffaniak/go-logger/logger"
)
// Strict hex color regex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
var hexColorRE = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
// All valid CSS named colors (W3C spec, lower-case)
var cssNamedColors = map[string]struct{}{
"aliceblue": {}, "antiquewhite": {}, "aqua": {}, "aquamarine": {}, "azure": {},
"beige": {}, "bisque": {}, "black": {}, "blanchedalmond": {}, "blue": {}, "blueviolet": {},
"brown": {}, "burlywood": {}, "cadetblue": {}, "chartreuse": {}, "chocolate": {},
"coral": {}, "cornflowerblue": {}, "cornsilk": {}, "crimson": {}, "cyan": {},
"darkblue": {}, "darkcyan": {}, "darkgoldenrod": {}, "darkgray": {}, "darkgreen": {},
"darkgrey": {}, "darkkhaki": {}, "darkmagenta": {}, "darkolivegreen": {}, "darkorange": {},
"darkorchid": {}, "darkred": {}, "darksalmon": {}, "darkseagreen": {}, "darkslateblue": {},
"darkslategray": {}, "darkslategrey": {}, "darkturquoise": {}, "darkviolet": {}, "deeppink": {},
"deepskyblue": {}, "dimgray": {}, "dimgrey": {}, "dodgerblue": {}, "firebrick": {},
"floralwhite": {}, "forestgreen": {}, "fuchsia": {}, "gainsboro": {}, "ghostwhite": {},
"gold": {}, "goldenrod": {}, "gray": {}, "green": {}, "greenyellow": {}, "grey": {},
"honeydew": {}, "hotpink": {}, "indianred": {}, "indigo": {}, "ivory": {},
"khaki": {}, "lavender": {}, "lavenderblush": {}, "lawngreen": {}, "lemonchiffon": {},
"lightblue": {}, "lightcoral": {}, "lightcyan": {}, "lightgoldenrodyellow": {}, "lightgray": {},
"lightgreen": {}, "lightgrey": {}, "lightpink": {}, "lightsalmon": {}, "lightseagreen": {},
"lightskyblue": {}, "lightslategray": {}, "lightslategrey": {}, "lightsteelblue": {}, "lightyellow": {},
"lime": {}, "limegreen": {}, "linen": {}, "magenta": {}, "maroon": {}, "mediumaquamarine": {},
"mediumblue": {}, "mediumorchid": {}, "mediumpurple": {}, "mediumseagreen": {}, "mediumslateblue": {},
"mediumspringgreen": {}, "mediumturquoise": {}, "mediumvioletred": {}, "midnightblue": {}, "mintcream": {},
"mistyrose": {}, "moccasin": {}, "navajowhite": {}, "navy": {}, "oldlace": {},
"olive": {}, "olivedrab": {}, "orange": {}, "orangered": {}, "orchid": {},
"palegoldenrod": {}, "palegreen": {}, "paleturquoise": {}, "palevioletred": {}, "papayawhip": {},
"peachpuff": {}, "peru": {}, "pink": {}, "plum": {}, "powderblue": {},
"purple": {}, "rebeccapurple": {}, "red": {}, "rosybrown": {}, "royalblue": {},
"saddlebrown": {}, "salmon": {}, "sandybrown": {}, "seagreen": {}, "seashell": {},
"sienna": {}, "silver": {}, "skyblue": {}, "slateblue": {}, "slategray": {},
"slategrey": {}, "snow": {}, "springgreen": {}, "steelblue": {}, "tan": {},
"teal": {}, "thistle": {}, "tomato": {}, "turquoise": {}, "violet": {},
"wheat": {}, "white": {}, "whitesmoke": {}, "yellow": {}, "yellowgreen": {},
// And the special values:
"transparent": {}, "currentcolor": {},
}
func FallbackColor(val, defaultColor string) string {
val = strings.TrimSpace(val)
if val == "" {
return defaultColor
}
if hexColorRE.MatchString(val) {
return val
}
lc := strings.ToLower(val)
// CSS color functions
if strings.HasPrefix(lc, "rgb(") || strings.HasPrefix(lc, "rgba(") ||
strings.HasPrefix(lc, "hsl(") || strings.HasPrefix(lc, "hsla(") {
return val
}
// CSS variable
if strings.HasPrefix(lc, "var(") {
return val
}
// Named colors and special values
if _, ok := cssNamedColors[lc]; ok {
return val
}
// Log a warning if the color is invalid
logger.Warningf("Invalid CSS color value provided: '%s'. Falling back to default: '%s'", val, defaultColor)
return defaultColor
}
func addCustomTheme(name, description, cssFilePath string) {
cssContent := ""
if cssFilePath != "" {
cssContent = readCustomCSS(cssFilePath)
}
Config.Frontend.Styling.CustomThemes[name] = CustomTheme{
Description: description,
CSS: cssContent,
}
Config.Frontend.Styling.CustomThemeOptions[name] = CustomTheme{
Description: description,
CSS: cssFilePath,
}
}
func readCustomCSS(path string) string {
if path == "" {
return ""
}
content, err := os.ReadFile(path)
if err != nil {
// Only log if the file exists but cannot be read (e.g., permission error)
if !os.IsNotExist(err) {
logger.Warningf("Could not read custom CSS file %s: %v", path, err)
return ""
}
}
if len(content) > 128*1024 {
logger.Warningf("Custom CSS file %s is too large (%d bytes), ignoring", path, len(content))
return ""
}
logger.Infof("Loaded custom CSS from: %s (%d bytes)", path, len(content))
return string(content)
}
+4 -27
View File
@@ -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
}
-27
View File
@@ -4,35 +4,8 @@ import (
"time"
"math/rand"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func CreateMockData(numDirs, numFilesPerDir int) iteminfo.FileInfo {
dir := iteminfo.FileInfo{}
dir.Path = "/here/is/your/mock/dir"
for i := 0; i < numDirs; i++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + GetRandomTerm() + GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Folders = append(dir.Folders, newFile)
}
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + GetRandomTerm() + GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Files = append(dir.Files, newFile)
}
return dir
}
func GenerateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
+990
View File
@@ -0,0 +1,990 @@
package access
import (
"encoding/json"
"fmt"
"maps"
"slices"
"sort"
"sync"
"time"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var accessCache = cache.NewCache(1 * time.Minute)
const accessRulesBucket = "access_rules"
const accessRulesKey = "rules"
const accessChangedKey = "newRule:"
type RuleMap map[string]*AccessRule
type SourceRuleMap map[string]RuleMap
type StringSet map[string]struct{}
type dbStorage struct {
AllRules SourceRuleMap `json:"all_rules"`
Groups GroupMap `json:"groups"`
}
// RuleSet groups users and groups for allow/deny lists.
type RuleSet struct {
Users StringSet
Groups StringSet
}
// AccessRule defines allow/deny lists for a path.
type AccessRule struct {
DenyAll bool `json:"denyAll,omitempty"`
Deny RuleSet
Allow RuleSet
}
type FrontendRuleSet struct {
Users []string `json:"users"`
Groups []string `json:"groups"`
}
type FrontendAccessRule struct {
DenyAll bool `json:"denyAll,omitempty"`
Deny FrontendRuleSet `json:"deny"`
Allow FrontendRuleSet `json:"allow"`
SourceDenyDefault bool `json:"sourceDenyDefault"`
}
// GroupMap maps group names to a set of usernames.
type GroupMap map[string]StringSet
// Storage manages access rules and group membership.
type Storage struct {
mux sync.RWMutex
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
DB *storm.DB // Optional: DB for persistence
Users *users.Storage // Reference to users storage
}
// SaveToDB persists all rules to the DB if DB is set.
func (s *Storage) SaveToDB() error {
if s.DB == nil {
return nil
}
data, err := json.Marshal(&dbStorage{
AllRules: s.AllRules,
Groups: s.Groups,
})
if err != nil {
return err
}
return s.DB.Set(accessRulesBucket, accessRulesKey, data)
}
// Flush persists the current in-memory state to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
return s.SaveToDB()
}
// LoadFromDB loads all rules from the DB if DB is set.
func (s *Storage) LoadFromDB() error {
if s.DB == nil {
return nil
}
var data []byte
err := s.DB.Get(accessRulesBucket, accessRulesKey, &data)
if err != nil {
return err
}
var storage dbStorage
if err := json.Unmarshal(data, &storage); err != nil {
return err
}
s.mux.Lock()
s.AllRules = storage.AllRules
if s.AllRules == nil {
s.AllRules = make(SourceRuleMap)
}
s.Groups = storage.Groups
if s.Groups == nil {
s.Groups = make(GroupMap)
}
s.mux.Unlock()
return nil
}
// NewStorage creates a new Storage instance. Optionally pass a DB for persistence and users storage.
// After creating Storage with a DB, call LoadFromDB() to load rules from the database on startup.
// Example:
//
// store := NewStorage(db, usersStore)
// err := store.LoadFromDB()
// if err != nil { /* handle error */ }
func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
var s = &Storage{
AllRules: make(SourceRuleMap),
Groups: make(GroupMap),
DB: db,
Users: usersStore,
}
return s
}
// ClearCache clears the access cache (useful for testing)
func ClearCache() {
// Recreate the cache to clear it
accessCache = cache.NewCache(1 * time.Minute)
}
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
// The caller must hold the lock.
func (s *Storage) getOrCreateRuleNL(sourcePath, indexPath string) *AccessRule {
if _, ok := s.AllRules[sourcePath]; !ok {
s.AllRules[sourcePath] = make(RuleMap)
}
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
rule = &AccessRule{
Deny: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
Allow: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
}
s.AllRules[sourcePath][indexPath] = rule
}
logger.Debugf("Created rule for source: %s and index: %s", sourcePath, indexPath)
return rule
}
// DenyUser adds a user to the deny list for a given source and index path.
func (s *Storage) DenyUser(sourcePath, indexPath, username string) error {
if s.Users != nil {
_, err := s.Users.Get(username)
if err != nil {
return fmt.Errorf("user '%s' does not exist: %w", username, err)
}
}
s.mux.Lock()
defer s.mux.Unlock()
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Users[username]; ok {
return errors.ErrExist
}
rule.Deny.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// AllowUser adds a user to the allow list for a given source and index path.
func (s *Storage) AllowUser(sourcePath, indexPath, username string) error {
if s.Users != nil {
_, err := s.Users.Get(username)
if err != nil {
return fmt.Errorf("user '%s' does not exist: %w", username, err)
}
}
s.mux.Lock()
defer s.mux.Unlock()
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Users[username]; ok {
return errors.ErrExist
}
rule.Allow.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// DenyGroup adds a group to the deny list for a given source and index path.
func (s *Storage) DenyGroup(sourcePath, indexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
_, ok := s.Groups[groupname]
if !ok {
return fmt.Errorf("group '%s' does not exist", groupname)
}
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Groups[groupname]; ok {
return errors.ErrExist
}
rule.Deny.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// AllowGroup adds a group to the allow list for a given source and index path.
func (s *Storage) AllowGroup(sourcePath, indexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
_, ok := s.Groups[groupname]
if !ok {
return fmt.Errorf("group '%s' does not exist", groupname)
}
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Groups[groupname]; ok {
return errors.ErrExist
}
rule.Allow.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// DenyAll sets a rule to deny all access for a given source and index path.
func (s *Storage) DenyAll(sourcePath, indexPath string) error {
s.mux.Lock()
defer s.mux.Unlock()
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if rule.DenyAll {
return errors.ErrExist
}
rule.DenyAll = true
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
// Get current version for the sourcePath
versionKey := "version:" + sourcePath
version := 0
if v, ok := accessCache.Get(versionKey).(int); ok {
version = v
}
// Check cache with versioned key
permKey := fmt.Sprintf("perm:%s:%d:%s:%s", sourcePath, version, indexPath, username)
if p, ok := accessCache.Get(permKey).(bool); ok {
return p
}
// Not in cache, compute, then cache it.
result := s.computePermitted(sourcePath, indexPath, username)
accessCache.Set(permKey, result)
return result
}
func (s *Storage) computePermitted(sourcePath, indexPath, username string) bool {
for {
permitted, found := s.permittedAtExactPath(sourcePath, indexPath, username)
if found {
return permitted
}
indexPath = utils.GetParentDirectoryPath(indexPath)
if indexPath == "" {
break
}
}
// No rules found anywhere in the path hierarchy
// Check if the source has DenyByDefault configured (acts like a root-level denyAll rule)
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if !ok {
logger.Errorf("source %s not found in config during access check", sourcePath)
return false
}
if sourceInfo.Config.DenyByDefault {
return false
}
return true
}
// permittedAtExactPath checks if a rule exists at the given path and evaluates it if so.
func (s *Storage) permittedAtExactPath(sourcePath, indexPath, username string) (bool, bool) {
s.mux.RLock()
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
s.mux.RUnlock()
return true, false
}
rule, ok := rulesBySource[indexPath]
s.mux.RUnlock()
if !ok {
return true, false
}
return s.permittedRule(rule, username, sourcePath), true
}
// permittedRule contains the old Permitted logic, but operates on a rule and username only.
func (s *Storage) permittedRule(rule *AccessRule, username string, sourcePath string) bool {
// Check user deny first - this always takes precedence
if _, found := rule.Deny.Users[username]; found {
return false
}
// Check group deny
for group := range rule.Deny.Groups {
if s.isUserInGroup(username, group) {
return false
}
}
// Check allow rules - these can override DenyAll
hasUserAllow := len(rule.Allow.Users) > 0
hasGroupAllow := len(rule.Allow.Groups) > 0
if hasUserAllow || hasGroupAllow {
// If user is in any allow list, permit them (overrides DenyAll)
if hasUserAllow {
if _, found := rule.Allow.Users[username]; found {
return true
}
}
if hasGroupAllow {
for group := range rule.Allow.Groups {
if s.isUserInGroup(username, group) {
return true
}
}
}
// User is not in any allow list
// Check DenyByDefault to determine behavior
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if ok && sourceInfo.Config.DenyByDefault {
// DenyByDefault=true: allow lists are exclusive, deny users not on the list
return false
} else {
// DenyByDefault=false: allow lists are additive, allow users not on the list
return true
}
}
// Check DenyAll - only applies if no allow rules exist
if rule.DenyAll {
return false
}
// No specific rules apply - check source DenyByDefault setting
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if ok && sourceInfo.Config.DenyByDefault {
return false
}
return true
}
// isUserInGroup checks if a username is in a group.
func (s *Storage) isUserInGroup(username, group string) bool {
s.mux.RLock()
defer s.mux.RUnlock()
users, ok := s.Groups[group]
if !ok {
return false
}
_, found := users[username]
return found
}
// GetRule retrieves a rule for a sourcePath and indexPath.
func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccessRule, bool) {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
frontendRules := FrontendAccessRule{
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: make([]string, 0),
Groups: make([]string, 0),
},
Allow: FrontendRuleSet{
Users: make([]string, 0),
Groups: make([]string, 0),
},
}
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return frontendRules, false
}
rule, ok := rulesBySource[indexPath]
if !ok || rule == nil {
return frontendRules, false
}
// Convert AccessRule to FrontendAccessRule
frontendRules.DenyAll = rule.DenyAll
frontendRules.Deny.Users = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users)))
frontendRules.Deny.Groups = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups)))
frontendRules.Allow.Users = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users)))
frontendRules.Allow.Groups = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups)))
logger.Debugf("Returning frontend rules for source: %s and index: %s", sourcePath, indexPath)
return frontendRules, ok
}
// GetAllRules returns all access rules as a map.
func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule, error) {
value, ok := accessCache.Get(accessChangedKey + sourcePath).(map[string]FrontendAccessRule)
if ok {
return value, nil
}
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
// Return a copy to avoid external mutation
frontendRules := make(map[string]FrontendAccessRule, len(s.AllRules))
rules, ok := s.AllRules[sourcePath]
if !ok {
return frontendRules, nil
}
for indexPath, rule := range rules {
// Convert AccessRule to FrontendAccessRule
frontendRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
}
// cache responses
accessCache.Set(accessChangedKey+sourcePath, frontendRules)
return frontendRules, nil
}
// AddUserToGroup adds a username to a group.
func (s *Storage) AddUserToGroup(group, username string) error {
s.mux.Lock()
defer s.mux.Unlock()
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
}
if _, ok := s.Groups[group][username]; ok {
return nil
}
s.Groups[group][username] = struct{}{}
return s.SaveToDB()
}
// GetAllGroups returns all group names.
func (s *Storage) GetAllGroups() []string {
s.mux.RLock()
defer s.mux.RUnlock()
groups := make([]string, 0, len(s.Groups))
for group := range s.Groups {
groups = append(groups, group)
}
sort.Strings(groups)
return groups
}
// GetUserGroups returns all groups for a specific user.
func (s *Storage) GetUserGroups(username string) []string {
s.mux.RLock()
defer s.mux.RUnlock()
var groups []string
for group, users := range s.Groups {
if _, ok := users[username]; ok {
groups = append(groups, group)
}
}
return utils.NonNilSlice(groups)
}
// SyncUserGroups updates a user's group memberships.
// It removes the user from groups not in the new list and adds them to new ones.
func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
// Create a set of new groups for efficient lookup
newGroupsSet := make(StringSet, len(newGroups))
for _, g := range newGroups {
newGroupsSet[g] = struct{}{}
}
// Iterate over all existing groups to find the user's current memberships
for group, users := range s.Groups {
_, userIsInGroup := users[username]
_, groupIsInNewSet := newGroupsSet[group]
// If user is in a group that is not in their new set of groups, remove them.
if userIsInGroup && !groupIsInNewSet {
delete(s.Groups[group], username)
changed = true
}
}
// Add user to new groups
for group := range newGroupsSet {
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
}
if _, ok := s.Groups[group][username]; !ok {
s.Groups[group][username] = struct{}{}
changed = true
}
}
if changed {
return s.SaveToDB()
}
return nil
}
// RemoveUserFromGroup removes a username from a group.
func (s *Storage) RemoveUserFromGroup(group, username string) error {
s.mux.Lock()
defer s.mux.Unlock()
users, ok := s.Groups[group]
if !ok {
return nil
}
if _, ok := users[username]; !ok {
return nil
}
delete(users, username)
if len(s.Groups[group]) == 0 {
delete(s.Groups, group)
}
return s.SaveToDB()
}
// RemoveAllowUser removes a user from the allow list for a given source and index path.
func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Allow.Users[username]
if exists {
delete(rule.Allow.Users, username)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return false, nil
}
// RemoveAllowGroup removes a group from the allow list for a given source and index path.
func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Allow.Groups[groupname]
if exists {
delete(rule.Allow.Groups, groupname)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return exists, nil
}
// RemoveDenyUser removes a user from the deny list for a given source and index path.
func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Deny.Users[username]
if exists {
delete(rule.Deny.Users, username)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return false, nil
}
// RemoveDenyGroup removes a group from the deny list for a given source and index path.
func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Deny.Groups[groupname]
if exists {
delete(rule.Deny.Groups, groupname)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return exists, nil
}
// RemoveDenyAll removes the deny all rule for a given source and index path.
func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
removed := false
if rule.DenyAll {
rule.DenyAll = false
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return true, s.SaveToDB()
}
return false, nil
}
// RemoveAllRulesForUser removes a user from all allow and deny lists.
func (s *Storage) RemoveAllRulesForUser(username string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
changedSourcePaths := make(map[string]struct{})
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
if _, exists := rule.Allow.Users[username]; exists {
delete(rule.Allow.Users, username)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if _, exists := rule.Deny.Users[username]; exists {
delete(rule.Deny.Users, username)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
}
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, false)
}
if changed {
return s.SaveToDB()
}
return nil
}
// RemoveAllRulesForGroup removes a group from all allow and deny lists.
func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
changedSourcePaths := make(map[string]struct{})
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
if _, exists := rule.Allow.Groups[groupname]; exists {
delete(rule.Allow.Groups, groupname)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if _, exists := rule.Deny.Groups[groupname]; exists {
delete(rule.Deny.Groups, groupname)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
}
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, false)
}
if changed {
return s.SaveToDB()
}
return nil
}
// GetRulesForUser returns all rules for a specific user for a given sourcePath.
func (s *Storage) GetRulesForUser(sourcePath, username string) map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
userRules := make(map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return userRules
}
for indexPath, rule := range rulesBySource {
userHasRule := false
if _, ok := rule.Allow.Users[username]; ok {
userHasRule = true
}
if !userHasRule {
if _, ok := rule.Deny.Users[username]; ok {
userHasRule = true
}
}
if userHasRule {
userRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
}
}
return userRules
}
// GetRulesForGroup returns all rules for a specific group for a given sourcePath.
func (s *Storage) GetRulesForGroup(sourcePath, groupname string) map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
groupRules := make(map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return groupRules
}
for indexPath, rule := range rulesBySource {
groupHasRule := false
if _, ok := rule.Allow.Groups[groupname]; ok {
groupHasRule = true
}
if !groupHasRule {
if _, ok := rule.Deny.Groups[groupname]; ok {
groupHasRule = true
}
}
if groupHasRule {
groupRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
}
}
return groupRules
}
// GetAllRulesByUsers returns a map of usernames to their rules for a given sourcePath.
func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
allUserRules := make(map[string]map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return allUserRules
}
for indexPath, rule := range rulesBySource {
hasAllowUsers := len(rule.Allow.Users) > 0
hasDenyUsers := len(rule.Deny.Users) > 0
if !hasAllowUsers && !hasDenyUsers {
continue
}
frontendRule := FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
for user := range rule.Allow.Users {
if _, ok := allUserRules[user]; !ok {
allUserRules[user] = make(map[string]FrontendAccessRule)
}
allUserRules[user][indexPath] = frontendRule
}
for user := range rule.Deny.Users {
if _, ok := allUserRules[user]; !ok {
allUserRules[user] = make(map[string]FrontendAccessRule)
}
allUserRules[user][indexPath] = frontendRule
}
}
return allUserRules
}
// GetAllRulesByGroups returns a map of groupnames to their rules for a given sourcePath.
func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
allGroupRules := make(map[string]map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return allGroupRules
}
for indexPath, rule := range rulesBySource {
hasAllowGroups := len(rule.Allow.Groups) > 0
hasDenyGroups := len(rule.Deny.Groups) > 0
if !hasAllowGroups && !hasDenyGroups {
continue
}
frontendRule := FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
for group := range rule.Allow.Groups {
if _, ok := allGroupRules[group]; !ok {
allGroupRules[group] = make(map[string]FrontendAccessRule)
}
allGroupRules[group][indexPath] = frontendRule
}
for group := range rule.Deny.Groups {
if _, ok := allGroupRules[group]; !ok {
allGroupRules[group] = make(map[string]FrontendAccessRule)
}
allGroupRules[group][indexPath] = frontendRule
}
}
return allGroupRules
}
// incrementSourceVersion increments the version of a sourcePath to invalidate caches.
// The caller MUST hold the mutex lock.
func (s *Storage) incrementSourceVersion(sourcePath string) {
key := "version:" + sourcePath
version := 0
if v, ok := accessCache.Get(key).(int); ok {
version = v
}
accessCache.Set(key, version+1)
}
+407
View File
@@ -0,0 +1,407 @@
package access_test
import (
"path/filepath"
"testing"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
boltusers "github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := storm.Open(dbPath)
if err != nil {
t.Fatalf("failed to open storm db: %v", err)
}
userStore := users.NewStorage(boltusers.NewUsersBackend(db))
return access.NewStorage(db, userStore), userStore
}
func createTestUser(t *testing.T, userStore *users.Storage, username string) {
u := &users.User{NonAdminEditable: users.NonAdminEditable{Password: "test"}, Username: username}
err := userStore.Save(u, false, false)
if err != nil {
t.Fatalf("failed to create user %s: %v", username, err)
}
}
func setupTestSources() {
// Setup default test sources with allow-by-default behavior
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
"mnt/open": {
Path: "mnt/open",
Name: "open",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
}
}
func TestPermitted_UserBlacklist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if err := s.DenyUser("mnt/storage", "/secret", "alice"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if s.Permitted("mnt/storage", "/secret", "alice") {
t.Error("alice should not be permitted (denied)")
}
if !s.Permitted("mnt/storage", "/secret", "bob") {
t.Error("bob should be permitted (not denied)")
}
}
func TestPermitted_UserWhitelist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if err := s.AllowUser("mnt/storage", "/vip", "bob"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should be permitted (allowed)")
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (default allow behavior when DenyByDefault=false)")
}
}
func TestPermitted_GroupBlacklist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
_ = s.AddUserToGroup("admins", "alice")
if err := s.DenyGroup("mnt/storage", "/admin", "admins"); err != nil {
t.Errorf("DenyGroup failed: %v", err)
}
if s.Permitted("mnt/storage", "/admin", "bob") == false {
t.Error("bob should be permitted (not in denied group)")
}
if s.Permitted("mnt/storage", "/admin", "alice") {
t.Error("alice should not be permitted (in denied group)")
}
}
func TestPermitted_GroupWhitelist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
_ = s.AddUserToGroup("vip", "bob")
if err := s.AllowGroup("mnt/storage", "/vip", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should be permitted (in allowed group)")
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (default allow behavior when DenyByDefault=false)")
}
}
func TestPermitted_NoRule(t *testing.T) {
setupTestSources()
s, _ := createTestStorage(t)
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if !s.Permitted("mnt/storage", "/public", "anyone") {
t.Error("anyone should be permitted if no rule exists")
}
}
func TestPermitted_CombinedRules(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
createTestUser(t, userStore, "carol")
createTestUser(t, userStore, "eve")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
err = s.AddUserToGroup("vip", "bob")
if err != nil {
t.Errorf("AddUserToGroup failed: %v", err)
}
err = s.AddUserToGroup("admins", "alice")
if err != nil {
t.Errorf("AddUserToGroup failed: %v", err)
}
if err := s.DenyUser("mnt/storage", "/combo", "eve"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if err := s.AllowUser("mnt/storage", "/combo", "carol"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if err := s.DenyGroup("mnt/storage", "/combo", "admins"); err != nil {
t.Errorf("DenyGroup failed: %v", err)
}
if err := s.AllowGroup("mnt/storage", "/combo", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if s.Permitted("mnt/storage", "/combo", "eve") {
t.Error("eve should not be permitted (user denied)")
}
if !s.Permitted("mnt/storage", "/combo", "carol") {
t.Error("carol should be permitted (user allowed)")
}
if s.Permitted("mnt/storage", "/combo", "alice") {
t.Error("alice should not be permitted (in group denied)")
}
if !s.Permitted("mnt/storage", "/combo", "bob") {
t.Error("bob should be permitted (in group allowed)")
}
}
func TestPermitted_DenyAll(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Test DenyAll
if err = s.DenyAll("mnt/storage", "/private"); err != nil {
t.Errorf("DenyAll failed: %v", err)
}
if s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should not be permitted (deny all)")
}
if s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should not be permitted (deny all)")
}
// Test that Allow rule overrides DenyAll
if err = s.AllowUser("mnt/storage", "/private", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should be permitted (allow overrides deny all)")
}
// Test removing DenyAll
removed, err := s.RemoveDenyAll("mnt/storage", "/private")
if err != nil {
t.Errorf("RemoveDenyAll failed: %v", err)
}
if !removed {
t.Error("RemoveDenyAll should have removed the rule")
}
// After removing DenyAll, alice should be permitted due to the Allow rule
if !s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should be permitted after removing deny all")
}
// Bob should be permitted because DenyByDefault=false makes allow lists additive (not exclusive)
if !s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should be permitted after removing deny all (DenyByDefault=false makes allow lists additive)")
}
}
func TestPermitted_DenyByDefault(t *testing.T) {
// Clear access cache to prevent test pollution
access.ClearCache()
// Create isolated storage for this test
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test configuration with DenyByDefault enabled for one source
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: true,
},
},
"mnt/open": {
Path: "mnt/open",
Name: "open",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
}
// Test DenyByDefault: When no rules exist, users should be denied
if s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should not be permitted (DenyByDefault is true and no rules exist)")
}
if s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true and no rules exist)")
}
// Test that explicit allow rules override DenyByDefault
if err := s.AllowUser("mnt/storage", "/allowed", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/allowed", "alice") {
t.Error("alice should be permitted (explicit allow rule overrides DenyByDefault)")
}
if s.Permitted("mnt/storage", "/allowed", "bob") {
t.Error("bob should not be permitted (not in allow list and DenyByDefault is true)")
}
// Test that explicit deny rules work with DenyByDefault
if err := s.DenyUser("mnt/storage", "/denied", "alice"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if s.Permitted("mnt/storage", "/denied", "alice") {
t.Error("alice should not be permitted (explicit deny rule)")
}
if s.Permitted("mnt/storage", "/denied", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true)")
}
// Test that DenyByDefault doesn't affect sources where it's disabled
if !s.Permitted("mnt/open", "/public", "alice") {
t.Error("alice should be permitted in open source (DenyByDefault is false)")
}
if !s.Permitted("mnt/open", "/public", "bob") {
t.Error("bob should be permitted in open source (DenyByDefault is false)")
}
// Test recursive path checking with DenyByDefault
// Allow alice at parent path, should work for child paths too
if err := s.AllowUser("mnt/storage", "/parent", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/parent/child", "alice") {
t.Error("alice should be permitted for child path (inherits from parent allow rule)")
}
if s.Permitted("mnt/storage", "/parent/child", "bob") {
t.Error("bob should not be permitted for child path (DenyByDefault and not in allow list)")
}
// Test that group rules work with DenyByDefault
_ = s.AddUserToGroup("vip", "alice")
if err := s.AllowGroup("mnt/storage", "/vip", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (in allowed group)")
}
if s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should not be permitted (not in allowed group and DenyByDefault is true)")
}
}
func TestPermitted_DenyByDefaultWithDenyAll(t *testing.T) {
// Clear access cache to prevent test pollution
access.ClearCache()
// Create isolated storage for this test
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test configuration with DenyByDefault enabled
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: true,
},
},
}
// Test that explicit DenyAll rule also works when DenyByDefault is enabled
if err = s.DenyAll("mnt/storage", "/restricted"); err != nil {
t.Errorf("DenyAll failed: %v", err)
}
if s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should not be permitted (DenyAll rule)")
}
if s.Permitted("mnt/storage", "/restricted", "bob") {
t.Error("bob should not be permitted (DenyAll rule)")
}
// Test that Allow rule overrides DenyAll even with DenyByDefault
if err = s.AllowUser("mnt/storage", "/restricted", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should be permitted (allow overrides DenyAll)")
}
// Remove DenyAll and test that DenyByDefault takes effect
removed, err := s.RemoveDenyAll("mnt/storage", "/restricted")
if err != nil {
t.Errorf("RemoveDenyAll failed: %v", err)
}
if !removed {
t.Error("RemoveDenyAll should have removed the rule")
}
// After removing DenyAll, alice should be permitted due to Allow rule
if !s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should be permitted after removing DenyAll (has allow rule)")
}
// Bob should not be permitted due to DenyByDefault
if s.Permitted("mnt/storage", "/restricted", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true and no allow rule)")
}
}
+34
View File
@@ -0,0 +1,34 @@
package crud
// CrudBackend defines the basic CRUD operations for a type T.
type CrudBackend[T any] interface {
GetByID(id any) (*T, error)
GetAll() ([]*T, error)
Save(obj *T) error
DeleteByID(id any) error
}
// Storage provides generic CRUD operations for any type T.
type Storage[T any] struct {
backend CrudBackend[T]
}
func NewStorage[T any](backend CrudBackend[T]) *Storage[T] {
return &Storage[T]{backend: backend}
}
func (s *Storage[T]) Get(id any) (*T, error) {
return s.backend.GetByID(id)
}
func (s *Storage[T]) GetAll() ([]*T, error) {
return s.backend.GetAll()
}
func (s *Storage[T]) Save(obj *T) error {
return s.backend.Save(obj)
}
func (s *Storage[T]) Delete(id any) error {
return s.backend.DeleteByID(id)
}
+36 -6
View File
@@ -1,6 +1,35 @@
package share
import "sync"
type CommonShare struct {
//AllowEdit bool `json:"allowEdit,omitempty"`
AllowUpload bool `json:"allowUpload,omitempty"`
DisableFileViewer bool `json:"disableFileViewer,omitempty"`
DownloadsLimit int `json:"downloadsLimit,omitempty"`
ShareTheme string `json:"shareTheme,omitempty"`
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
MaxBandwidth int `json:"maxBandwidth,omitempty"`
DisableThumbnails bool `json:"disableThumbnails,omitempty"`
KeepAfterExpiration bool `json:"keepAfterExpiration,omitempty"`
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
ThemeColor string `json:"themeColor,omitempty"`
Banner string `json:"banner,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Favicon string `json:"favicon,omitempty"`
QuickDownload bool `json:"quickDownload,omitempty"`
HideNavButtons bool `json:"hideNavButtons,omitempty"`
DisableSidebar bool `json:"disableSidebar"`
ViewMode string `json:"viewMode,omitempty"`
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
Path string `json:"path,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
DisableShareCard bool `json:"disableShareCard,omitempty"`
}
type CreateBody struct {
CommonShare
Hash string `json:"hash,omitempty"`
Password string `json:"password"`
Expires string `json:"expires"`
Unit string `json:"unit"`
@@ -8,12 +37,13 @@ type CreateBody struct {
// Link is the information needed to build a shareable link.
type Link struct {
Hash string `json:"hash" storm:"id,index"`
Path string `json:"path" storm:"index"`
Source string `json:"source" storm:"index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
CommonShare
Mu sync.Mutex `json:"-"`
Downloads int `json:"downloads"`
Hash string `json:"hash" storm:"id,index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
// Token is a random value that will only be set when PasswordHash is set. It is
// URL-Safe and is used to download links in password-protected shares via a
// query arg.
+247 -47
View File
@@ -1,9 +1,14 @@
package share
import (
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// StorageBackend is the interface to implement for a share storage.
@@ -12,109 +17,304 @@ type StorageBackend interface {
FindByUserID(id uint) ([]*Link, error)
GetByHash(hash string) (*Link, error)
GetPermanent(path, source string, id uint) (*Link, error)
GetBySourcePath(path, source string) ([]*Link, error)
Gets(path, source string, id uint) ([]*Link, error)
Save(s *Link) error
Delete(hash string) error
}
// Storage is a storage.
type Storage struct {
// crudBackend implements crud.CrudBackend[Link] for share storage.
type crudBackend struct {
back StorageBackend
}
// NewStorage creates a share links storage from a backend.
func NewStorage(back StorageBackend) *Storage {
return &Storage{back: back}
func (c *crudBackend) GetByID(id any) (*Link, error) {
hash, ok := id.(string)
if !ok {
return nil, errors.ErrInvalidDataType
}
return c.back.GetByHash(hash)
}
// All wraps a StorageBackend.All.
func (c *crudBackend) GetAll() ([]*Link, error) {
return c.back.All()
}
func (c *crudBackend) Save(obj *Link) error {
return c.back.Save(obj)
}
func (c *crudBackend) DeleteByID(id any) error {
hash, ok := id.(string)
if !ok {
return errors.ErrInvalidDataType
}
return c.back.Delete(hash)
}
// Storage is a share storage using generics.
type Storage struct {
Generic *crud.Storage[Link]
back StorageBackend
shareCache map[string]*Link
mu sync.RWMutex
users *users.Storage
}
// NewStorage creates a share links storage from a backend.
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
return &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareCache: make(map[string]*Link),
users: usersStore,
}
}
// All wraps StorageBackend.All and handles expiry.
func (s *Storage) All() ([]*Link, error) {
links, err := s.back.All()
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
return links, nil
s.mu.Unlock()
return filtered, nil
}
// FindByUserID wraps a StorageBackend.FindByUserID.
// FindByUserID wraps StorageBackend.FindByUserID and handles expiry.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
links, err := s.back.FindByUserID(id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
return links, nil
s.mu.Unlock()
return filtered, nil
}
// GetByHash wraps a StorageBackend.GetByHash.
// GetByHash wraps StorageBackend.GetByHash and handles expiry.
func (s *Storage) GetByHash(hash string) (*Link, error) {
// return stable in-memory pointer if available
s.mu.RLock()
if link, ok := s.shareCache[hash]; ok && link != nil {
s.mu.RUnlock()
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
return nil, errors.ErrNotExist
}
return link, nil
}
s.mu.RUnlock()
link, err := s.back.GetByHash(hash)
if err != nil {
return nil, err
}
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
_ = s.back.Delete(hash)
return nil, errors.ErrNotExist
}
s.mu.Lock()
s.shareCache[hash] = link
s.mu.Unlock()
return link, nil
}
// GetPermanent wraps a StorageBackend.GetPermanent
// GetPermanent wraps StorageBackend.GetPermanent
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
return s.back.GetPermanent(path, source, id)
l, err := s.back.GetPermanent(path, source, id)
if err == nil && l != nil {
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
}
return l, err
}
// Gets wraps a StorageBackend.Gets
// Gets wraps StorageBackend.Gets and handles expiry.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
return links, nil
s.mu.Unlock()
return filtered, nil
}
// Save wraps a StorageBackend.Save
// GetBySourcePath wraps StorageBackend.GetBySourcePath and handles expiry.
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
links, err := s.back.GetBySourcePath(path, source)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
s.mu.Unlock()
return filtered, nil
}
// UpdateShares updates all shares that exactly match oldSource and oldPath
// to point to newSource and newPath. Returns the number of updated shares.
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
links, err := s.back.All()
if err != nil && err != errors.ErrNotExist {
logger.Error("failed to list shares", "error", err)
return 0, err
}
updated := 0
for _, l := range links {
if l == nil {
continue
}
if l.Source != oldSource {
continue
}
pos := strings.Index(l.Path, oldPath)
if pos < 0 {
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = oldFullPath[:pos] + newPath + oldFullPath[pos+len(oldPath):]
if err := s.back.Save(l); err != nil {
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
return updated, err
}
s.mu.Lock()
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
// update existing cached pointer fields
existing.Source = l.Source
existing.Path = l.Path
} else {
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
// Save wraps StorageBackend.Save
func (s *Storage) Save(l *Link) error {
return s.back.Save(l)
if err := s.back.Save(l); err != nil {
return err
}
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
return nil
}
// Delete wraps a StorageBackend.Delete
// Delete wraps StorageBackend.Delete
func (s *Storage) Delete(hash string) error {
return s.back.Delete(hash)
if err := s.back.Delete(hash); err != nil {
return err
}
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
return nil
}
// Flush persists the current in-memory state of all shares to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
s.mu.RLock()
links := make([]*Link, 0, len(s.shareCache))
for _, l := range s.shareCache {
if l != nil {
links = append(links, l)
}
}
s.mu.RUnlock()
for _, l := range links {
if err := s.back.Save(l); err != nil {
return err
}
}
return nil
}
// filterExpired removes expired links and deletes them from storage.
func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
var filtered []*Link
for _, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
s.mu.Lock()
delete(s.shareCache, link.Hash)
s.mu.Unlock()
continue
}
filtered = append(filtered, link)
}
return filtered, nil
}
+20 -5
View File
@@ -5,18 +5,33 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// Storage is a storage powered by a Backend which makes the necessary
// verifications when fetching and saving data to ensure consistency.
type BoltStore struct {
Users *users.Storage
Share *share.Storage
Auth *auth.Storage
Settings *settings.Storage
Access *access.Storage
}
// NewStorage creates a storage.Storage based on Bolt DB.
func NewStorage(db *storm.DB) (*auth.Storage, *users.Storage, *share.Storage, *settings.Storage, error) {
func NewStorage(db *storm.DB) (*BoltStore, error) {
userStore := users.NewStorage(usersBackend{db: db})
shareStore := share.NewStorage(shareBackend{db: db})
settingsStore := settings.NewStorage(settingsBackend{db: db})
authStore, err := auth.NewStorage(authBackend{db: db}, userStore)
if err != nil {
return nil, nil, nil, nil, err
return nil, err
}
return authStore, userStore, shareStore, settingsStore, nil
return &BoltStore{
Users: userStore,
Share: share.NewStorage(shareBackend{db: db}, userStore),
Auth: authStore,
Settings: settings.NewStorage(settingsBackend{db: db}),
Access: access.NewStorage(db, userStore),
}, nil
}
+11 -1
View File
@@ -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())
}
+10 -1
View File
@@ -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)
}
}
+13
View File
@@ -18,3 +18,16 @@ func get(db *storm.DB, name string, to interface{}) error {
func Save(db *storm.DB, name string, from interface{}) error {
return db.Set("config", name, from)
}
func SaveAccessRules(db *storm.DB, name string, from interface{}) error {
return db.Set("access_rules", name, from)
}
func GetAccessRules(db *storm.DB, name string, to interface{}) error {
err := db.Get("access_rules", name, to)
if err == storm.ErrNotFound {
return errors.ErrNotExist
}
return err
}
+10 -28
View File
@@ -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,7 +67,7 @@ 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)
@@ -113,7 +100,7 @@ func quickSetup(store *Storage) {
}
// 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 == "" {
@@ -126,18 +113,13 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
}
userInfo.Password = hashpass
}
userInfo.Permissions = settings.Config.UserDefaults.Permissions
// 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
}
+38 -1
View File
@@ -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{},
}
+8 -10
View File
@@ -95,10 +95,14 @@ type NonAdminEditable 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
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
}
type FileLoading struct {
@@ -106,14 +110,8 @@ type FileLoading struct {
ChunkSize int `json:"uploadChunkSizeMb"`
}
var PublicUser = User{
NonAdminEditable: NonAdminEditable{
Password: "publicUser", // temp user not registered
ViewMode: "normal",
},
Username: "publicUser", // temp user not registered
LockPassword: true,
Permissions: Permissions{},
var AnonymousUser = User{
Username: "anonymous", // temp user not registered
}
func CleanUsername(s string) string {
+30 -19
View File
@@ -1,10 +1,10 @@
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.15
github.com/go-playground/validator/v10 v10.27.0
@@ -18,18 +18,20 @@ require (
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.40.0
golang.org/x/image v0.29.0
golang.org/x/mod v0.26.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.34.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,14 +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.1 // 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
@@ -108,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-20250707181242-c5087ca84cf4 // 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
@@ -135,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
@@ -163,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
@@ -199,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
@@ -210,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
@@ -229,16 +237,18 @@ require (
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.2 // 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/net v0.42.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.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
@@ -249,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
)
+149 -41
View File
@@ -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.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.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
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,8 +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-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=
@@ -273,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=
@@ -280,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-20250707181242-c5087ca84cf4 h1:vCeHcs8N7MOccOOsOVIy1xcYu+kBkA4J5urTgigww7c=
github.com/golang/geo v0.0.0-20250707181242-c5087ca84cf4/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
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=
@@ -386,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=
@@ -404,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=
@@ -425,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=
@@ -450,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=
@@ -474,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=
@@ -497,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=
@@ -531,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=
@@ -622,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=
@@ -659,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=
@@ -706,6 +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/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=
@@ -717,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.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
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=
@@ -743,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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
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=
@@ -755,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.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
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=
@@ -792,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.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
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=
@@ -838,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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
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=
@@ -908,7 +1009,6 @@ 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=
@@ -920,8 +1020,8 @@ 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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.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=
@@ -942,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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
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=
@@ -1003,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=
@@ -1123,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=
+267
View File
@@ -0,0 +1,267 @@
package http
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-logger/logger"
)
type GroupListResponse struct {
Groups []string `json:"groups"`
}
// accessGetHandler lists all access rules or retrieves a specific rule.
// @Summary List access rules
// @Description Lists access rules. Can be filtered by source, path, user, or group. Can also be grouped by user or group.
// @Tags Access
// @Accept json
// @Produce json
// @Param source query string false "Source name (e.g. 'default')"
// @Param path query string false "Index path (e.g. /secret)"
// @Param user query string false "Username to filter rules for"
// @Param group query string false "Group name to filter rules for"
// @Success 200 {object} object "Varies based on query. Can be access.FrontendAccessRule (when source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access [get]
func accessGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
indexPath := r.URL.Query().Get("path")
user := r.URL.Query().Get("user")
group := r.URL.Query().Get("group")
var sourcePath string
if sourceName != "" {
index := indexing.GetIndex(sourceName)
if index == nil {
return http.StatusBadRequest, fmt.Errorf("source not found: %s", sourceName)
}
sourcePath = index.Path
}
// Return rules based on input parameters
if user != "" {
rules := store.Access.GetRulesForUser(sourcePath, user)
return renderJSON(w, r, rules)
}
if group != "" {
rules := store.Access.GetRulesForGroup(sourcePath, group)
return renderJSON(w, r, rules)
}
if sourceName == "" {
return http.StatusBadRequest, fmt.Errorf("source parameter is required for this query")
}
if indexPath != "" {
rule, _ := store.Access.GetFrontendRules(sourcePath, indexPath)
return renderJSON(w, r, rule)
}
// List all rules for the source by default
all, err := store.Access.GetAllRules(sourcePath)
if err != nil {
logger.Errorf("failed to retrieve access rules: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to retrieve access rules: %w", err)
}
return renderJSON(w, r, all)
}
// accessPostHandler adds or updates an access rule.
// @Summary Add or update access rule
// @Description Add or update an access rule for a sourcePath and indexPath.
// @Tags Access
// @Accept json
// @Produce json
// @Param source query string true "Source path prefix (e.g. mnt/storage)"
// @Param path query string true "Index path (e.g. /secret)"
// @Param body body object{allow=bool,ruleCategory=string,value=string} true "Rule details: allow (true/false), ruleCategory (user/group), value (username or groupname)"
// @Success 200 {object} map[string]string "Rule added or updated"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access [post]
func accessPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
indexPath := r.URL.Query().Get("path")
index := indexing.GetIndex(sourceName)
if index == nil {
return http.StatusBadRequest, fmt.Errorf("source not found: %s", sourceName)
}
var body struct {
Allow bool `json:"allow"`
RuleCategory string `json:"ruleCategory"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err)
}
if indexPath == "" || body.RuleCategory == "" || (body.RuleCategory != "all" && body.Value == "") {
return http.StatusBadRequest, fmt.Errorf("path, ruleCategory, and value are required, unless ruleCategory is 'all'")
}
var err error
if body.Allow {
switch body.RuleCategory {
case "user":
err = store.Access.AllowUser(index.Path, indexPath, body.Value)
case "group":
err = store.Access.AllowGroup(index.Path, indexPath, body.Value)
default:
return http.StatusBadRequest, fmt.Errorf("invalid ruleCategory: must be 'user' or 'group'")
}
} else {
switch body.RuleCategory {
case "user":
err = store.Access.DenyUser(index.Path, indexPath, body.Value)
case "group":
err = store.Access.DenyGroup(index.Path, indexPath, body.Value)
case "all":
err = store.Access.DenyAll(index.Path, indexPath)
default:
return http.StatusBadRequest, fmt.Errorf("invalid ruleCategory: must be 'user', 'group', or 'all'")
}
}
if err != nil {
logger.Errorf("failed to add or update rule: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to add or update rule: %w", err)
}
return renderJSON(w, r, map[string]string{"message": "rule added or updated"})
}
// accessDeleteHandler deletes a single user or group from a rule.
// @Summary Delete access rule entry
// @Description Delete a user or group from an allow or deny list for a sourcePath and indexPath.
// @Tags Access
// @Accept json
// @Produce json
// @Param source query string true "Source path prefix (e.g. mnt/storage)"
// @Param path query string true "Index path (e.g. /secret)"
// @Param ruleType query string true "Rule type (allow or deny)"
// @Param ruleCategory query string true "Rule category (user or group)"
// @Param value query string true "Username or groupname to remove"
// @Success 200 {object} map[string]string "Rule entry deleted"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access [delete]
func accessDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
indexPath := r.URL.Query().Get("path")
index := indexing.GetIndex(sourceName)
if index == nil {
return 500, fmt.Errorf("source not found: %s", sourceName)
}
ruleType := r.URL.Query().Get("ruleType")
ruleCategory := r.URL.Query().Get("ruleCategory")
value := r.URL.Query().Get("value")
allow := ruleType == "allow"
if indexPath == "" || ruleCategory == "" || (ruleCategory != "all" && value == "") {
return http.StatusBadRequest, fmt.Errorf("path, ruleCategory, and value are required, unless ruleCategory is 'all'")
}
var found bool
var err error
if allow {
switch ruleCategory {
case "user":
found, err = store.Access.RemoveAllowUser(index.Path, indexPath, value)
case "group":
found, err = store.Access.RemoveAllowGroup(index.Path, indexPath, value)
}
} else {
switch ruleCategory {
case "user":
found, err = store.Access.RemoveDenyUser(index.Path, indexPath, value)
case "group":
found, err = store.Access.RemoveDenyGroup(index.Path, indexPath, value)
case "all":
found, err = store.Access.RemoveDenyAll(index.Path, indexPath)
}
}
if !found {
if err != nil {
logger.Errorf("failed to remove rule entry: %v", err)
}
return http.StatusNotFound, fmt.Errorf("entry not found in rule")
}
return renderJSON(w, r, map[string]string{"message": "rule entry deleted"})
}
// groupGetHandler retrieves all groups or groups for a specific user.
// @Summary Get all groups or groups for a user
// @Description Returns a list of all groups or the groups for a specific user.
// @Tags Access
// @Accept json
// @Produce json
// @Param user query string false "User name"
// @Success 200 {object} GroupListResponse "Object containing a list of groups"
// @Failure 403 {object} map[string]string "Forbidden"
// @Router /api/access/groups [get]
func groupGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
user := r.URL.Query().Get("user")
if user != "" {
groups := store.Access.GetUserGroups(user)
return renderJSON(w, r, &GroupListResponse{Groups: groups})
}
groups := store.Access.GetAllGroups()
return renderJSON(w, r, &GroupListResponse{Groups: groups})
}
// groupPostHandler adds a user to a group.
// @Summary Add a user to a group
// @Description Adds a user to a group.
// @Tags Access
// @Accept json
// @Produce json
// @Param group query string true "Group name"
// @Param user query string true "User name"
// @Success 200 "User added to group successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/access/group [post]
func groupPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
group := r.URL.Query().Get("group")
user := r.URL.Query().Get("user")
err := store.Access.AddUserToGroup(group, user)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
// groupDeleteHandler removes a user from a group.
// @Summary Remove a user from a group
// @Description Removes a user from a group.
// @Tags Access
// @Accept json
// @Produce json
// @Param group query string true "Group name"
// @Param user query string true "User name"
// @Success 200 "User removed from group successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/access/group [delete]
func groupDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
group := r.URL.Query().Get("group")
user := r.URL.Query().Get("user")
err := store.Access.RemoveUserFromGroup(group, user)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
+6 -3
View File
@@ -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
}
@@ -194,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)
+143 -29
View File
@@ -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,16 +35,26 @@ func (d dirFS) Open(name string) (fs.File, error) {
}
var (
store *storage.Storage
config *settings.Settings
//fileCache diskcache.Interface
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
@@ -58,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))
@@ -98,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", withoutUser(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))
@@ -119,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{
@@ -202,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()
+135 -28
View File
@@ -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,12 +23,13 @@ import (
)
type requestContext struct {
user *users.User
fileInfo iteminfo.ExtendedFileInfo
path string
token string
share *share.Link
ctx context.Context
user *users.User
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
shareValid bool
ctx context.Context
MaxBandwidth int
}
type HttpResponse struct {
@@ -42,15 +45,22 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
// Middleware to handle file requests by hash and pass it to the handler
func withHashFileHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
path := r.URL.Query().Get("path")
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
// Retrieve the user (using the public user by default)
data.user = &users.PublicUser
path := r.URL.Query().Get("path")
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share not found")
data.share = &share.Link{}
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
if link.DisableAnonymous && data.user.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("share is not available to anonymous users")
}
if len(link.AllowedUsernames) > 0 {
if !slices.Contains(link.AllowedUsernames, data.user.Username) {
return http.StatusForbidden, fmt.Errorf("share is not available to this user")
}
}
data.share = link
// Authenticate the share request if needed
@@ -61,32 +71,41 @@ func withHashFileHelper(fn handleFunc) handleFunc {
return status, fmt.Errorf("could not authenticate share request")
}
}
data.path = strings.TrimSuffix(link.Path, "/") + "/" + strings.TrimPrefix(path, "/")
if path == "" || path == "/" {
data.path = link.Path
}
source, ok := config.Server.SourceMap[link.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
if source.Config.Private {
return http.StatusForbidden, fmt.Errorf("the target source is private")
}
// Get file information with options
getContent := r.URL.Query().Get("content") == "true"
if link.DisableFileViewer || link.Downloads >= link.DownloadsLimit {
getContent = false
}
file, err := FileInfoFasterFunc(iteminfo.FileOptions{
Path: data.path,
Source: source.Name,
Modify: false,
Expand: true,
Path: utils.JoinPathAsUnix(link.Path, path),
Source: link.Source,
Modify: false,
Expand: true,
Content: getContent,
})
file.Token = link.Token
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, data.path, err)
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
}
if getContent && file.Content != "" {
link.Mu.Lock()
link.Downloads++
link.Mu.Unlock()
}
file.Path = "/" + strings.TrimPrefix(strings.TrimPrefix(file.Path, link.Path), "/")
// Set the file info in the `data` object
data.fileInfo = file
// Call the next handler with the data
return fn(w, r, data)
}
})
}
// Middleware to ensure the user is an admin
@@ -100,6 +119,81 @@ func withAdminHelper(fn handleFunc) handleFunc {
})
}
// withOrWithoutUserHelper is a middleware that tries to authenticate a user.
// If authentication is successful, the user is added to the request context.
// If authentication fails, the request continues without a user.
func withOrWithoutUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
var link *share.Link
var isShareRequest bool
var shareHash string
hash := r.URL.Query().Get("hash")
if hash != "" {
// Get the file link by hash
isShareRequest = true
shareHash = hash
link, _ = store.Share.GetByHash(hash)
} else {
prefix := config.Server.BaseURL + "public/share/"
reconstructed := config.Server.BaseURL + "public" + r.URL.Path
if strings.HasPrefix(reconstructed, prefix) {
remaining := strings.TrimPrefix(reconstructed, prefix)
if remaining != "" {
if idx := strings.IndexByte(remaining, '/'); idx >= 0 {
remaining = remaining[:idx]
}
if remaining != "" {
isShareRequest = true
shareHash = remaining
var err error
link, err = store.Share.GetByHash(remaining)
if err != nil {
logger.Debugf("error getting share by hash: %v", err)
}
}
}
}
}
// If this is a share request, always create a share context (even if invalid)
if isShareRequest {
if link != nil {
data.share = link
data.shareValid = true
} else {
// Create an empty share with just the hash for invalid shares
data.share = &share.Link{Hash: shareHash}
data.shareValid = false
}
}
// Try to authenticate user first
status, err := withUserHelper(nil)(w, r, data)
if err == nil && status < 400 {
if data.share != nil && data.shareValid {
if data.user != nil {
data.user.CustomTheme = data.share.ShareTheme
}
}
return fn(w, r, data)
}
// Only fall back to anonymous if authentication actually failed
if status == http.StatusUnauthorized || status == http.StatusForbidden {
data.user = &users.AnonymousUser
// If user authentication failed, call the handler without user context
// Clear any user data that might have been partially set
data.token = ""
if data.share != nil && data.shareValid {
data.user.CustomTheme = data.share.ShareTheme
}
// Call the handler function without user context
return fn(w, r, data)
}
return status, fmt.Errorf("could not authenticate request")
}
}
func withoutUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// This middleware is used when no user authentication is required
@@ -158,6 +252,9 @@ func withUserHelper(fn handleFunc) handleFunc {
logger.Errorf("no auth: %v", err)
return http.StatusInternalServerError, err
}
if fn == nil {
return http.StatusOK, nil
}
return fn(w, r, data)
}
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
@@ -168,6 +265,9 @@ func withUserHelper(fn handleFunc) handleFunc {
}
data.user = user
setUserInResponseWriter(w, data.user)
if fn == nil {
return http.StatusOK, nil
}
return fn(w, r, data)
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
@@ -192,17 +292,20 @@ func withUserHelper(fn handleFunc) handleFunc {
// Check if the token is about to expire and send a header to renew it
if tk.Expires < time.Now().Add(time.Hour).Unix() {
w.Header().Add("X-Renew-Token", "true")
}
// Retrieve the user from the store and store it in the context
} // Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(tk.BelongsTo)
if err != nil {
logger.Errorf("Failed to get user with ID %v: %v", tk.BelongsTo, err)
return http.StatusInternalServerError, err
}
setUserInResponseWriter(w, data.user)
// Call the handler function, passing in the context
if data.user.Username == "" {
return http.StatusForbidden, errors.ErrUnauthorized
}
// Call the handler function, passing in the context (or return OK if no handler)
if fn == nil {
return http.StatusOK, nil
}
return fn(w, r, data)
}
}
@@ -285,6 +388,10 @@ func withUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withUserHelper(fn))
}
func withOrWithoutUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withOrWithoutUserHelper(fn))
}
func withoutUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withoutUserHelper(fn))
}
+24 -11
View File
@@ -11,7 +11,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -23,16 +22,10 @@ func setupTestEnv(t *testing.T) {
if err != nil {
t.Fatal(err)
}
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
store, err = bolt.NewStorage(db)
if err != nil {
t.Fatal(err)
}
store = &storage.Storage{
Auth: authStore,
Users: userStore,
Share: shareStore,
Settings: settingsStore,
}
config = &settings.Config // mocked
config.Server.SourceMap = map[string]settings.Source{
"/srv": settings.Source{
@@ -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
+29 -17
View File
@@ -264,22 +264,23 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
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)
@@ -291,10 +292,15 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
if config.Auth.Methods.OidcAuth.AdminGroup == "" {
isAdmin = config.UserDefaults.Permissions.Admin
}
err = storage.CreateUser(users.User{
LoginMethod: users.LoginMethodOidc,
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
}
@@ -307,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 {
@@ -318,11 +324,17 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
if user.LoginMethod != users.LoginMethodOidc {
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)
}
+18 -11
View File
@@ -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,7 +141,7 @@ 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)
+6 -4
View File
@@ -60,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
+32 -31
View File
@@ -7,10 +7,7 @@ import (
"net/url"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"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"
@@ -22,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"
@@ -32,7 +29,14 @@ 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 {
@@ -41,26 +45,42 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
fileList := []string{}
for _, file := range strings.Split(f, "||") {
fileList = append(fileList, d.fileInfo.Source+"::"+d.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) {
d.fileInfo.Path = strings.TrimPrefix(d.fileInfo.Path, d.share.Path)
// disable onlyoffice for public share
d.fileInfo.OnlyOfficeId = ""
return renderJSON(w, r, d.fileInfo)
}
func publicUserGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
return renderJSON(w, r, users.PublicUser)
}
// health godoc
// @Summary Health Check
// @Schemes
@@ -96,27 +116,8 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} map[string]string "Internal server error"
// @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")
var err error
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: d.fileInfo.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
View File
@@ -4,6 +4,7 @@ import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
@@ -13,12 +14,49 @@ import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/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
}
+118 -59
View File
@@ -15,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"
@@ -24,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.
@@ -55,12 +85,18 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
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
@@ -128,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
@@ -188,18 +226,22 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
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)
@@ -214,6 +256,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
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)
}
@@ -221,6 +264,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
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
@@ -229,6 +273,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
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
}
@@ -245,12 +290,14 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
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()
@@ -258,6 +305,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// 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)
}
@@ -265,47 +313,26 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
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 renaming
// close file before moving
outFile.Close()
// Move the completed file from the temp location to the final destination
err = os.Rename(tempFilePath, fileOpts.Path)
err = fileutils.MoveFile(tempFilePath, realPath)
if err != nil {
// If rename fails (e.g., cross-device link), fall back to copy and delete.
var in *os.File
in, err = os.Open(tempFilePath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not open temp file for copying: %v", err)
}
defer in.Close()
var out *os.File
out, err = os.Create(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not create destination file for copying: %v", err)
}
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not copy temp file to destination: %v", err)
}
// Manually close files before removing source
in.Close()
out.Close()
if err = os.Remove(tempFilePath); err != nil {
logger.Errorf("failed to remove temporary upload file: %s", tempFilePath)
}
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 {
@@ -366,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
@@ -442,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
@@ -467,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)
}
@@ -490,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,
})
@@ -513,9 +572,9 @@ 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)
}
}
@@ -542,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
}
+330 -47
View File
@@ -7,8 +7,8 @@ import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
@@ -17,8 +17,35 @@ 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/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.
@@ -29,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.
@@ -67,30 +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")
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)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
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, sourcePath.Path, d.user.ID)
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
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.
@@ -125,25 +149,28 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Tags Shares
// @Accept json
// @Produce json
// @Param path query string true "Source Path of the files to share"
// @Param source query 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
@@ -184,34 +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")
source := config.Server.NameToSource[sourceName]
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source.Name)
// Validate source exists
sourceInfo, ok := config.Server.NameToSource[source]
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
}
// Get user scope for this source
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
scopePath := utils.JoinPathAsUnix(userscope, path)
s = &share.Link{
Path: scopePath,
Hash: secure_hash,
Source: source.Path, // path instead to persist accoss name change
Expire: expire,
UserID: d.user.ID,
PasswordHash: stringHash,
Token: token,
// 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)
}
if err := store.Share.Save(s); err != nil {
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) {
@@ -241,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 -41
View File
@@ -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,62 +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(),
"DisableNavButtons": settings.Config.Frontend.DisableNavButtons,
"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
@@ -98,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")
}
+1 -1
View File
@@ -164,7 +164,7 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, errors.ErrEmptyPassword
}
err = storage.CreateUser(req.User, req.User.Permissions.Admin)
err = storage.CreateUser(req.User)
if err != nil {
return http.StatusInternalServerError, err
}
+4 -1
View File
@@ -3,13 +3,16 @@
package indexing
import "syscall"
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)
+15 -11
View File
@@ -40,16 +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]bool `json:"-"`
totalSize uint64 `json:"-"`
FoundHardLinks map[string]uint64 `json:"-"` // hardlink path -> size
processedInodes map[uint64]struct{} `json:"-"`
totalSize uint64 `json:"-"`
}
var (
@@ -75,8 +75,8 @@ func Initialize(source settings.Source, mock bool) {
mock: mock,
Source: source,
Directories: make(map[string]*iteminfo.FileInfo),
DirectoriesLedger: make(map[string]bool),
processedInodes: make(map[uint64]bool),
DirectoriesLedger: make(map[string]struct{}),
processedInodes: make(map[uint64]struct{}),
FoundHardLinks: make(map[string]uint64),
}
newIndex.ReducedIndex = ReducedIndex{
@@ -123,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 + "/"
@@ -292,7 +292,9 @@ 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)
@@ -301,7 +303,9 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
if shouldCountSize {
totalSize += itemInfo.Size
}
idx.NumFiles++
if recursive {
idx.NumFiles++
}
}
}
@@ -378,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)
@@ -570,7 +574,7 @@ func (idx *Index) handleFile(file os.FileInfo, fullCombined string) (size uint64
return realSize, false
}
// First time seeing this inode.
idx.processedInodes[ino] = true
idx.processedInodes[ino] = struct{}{}
idx.FoundHardLinks[fullCombined] = realSize
idx.totalSize += realSize
return realSize, true // Count size for directory total.
+2 -5
View File
@@ -74,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() {
@@ -140,7 +140,7 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
idx.mu.Lock()
idx.NumDirs = 0
idx.NumFiles = 0
idx.processedInodes = make(map[uint64]bool)
idx.processedInodes = make(map[uint64]struct{})
idx.FoundHardLinks = make(map[string]uint64)
idx.mu.Unlock()
}
@@ -160,9 +160,6 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Name, idx.QuickScanTime)
} else {
idx.FullScanTime = int(time.Since(startTime).Seconds())
idx.mu.Lock()
idx.DiskUsed = idx.totalSize
idx.mu.Unlock()
// update smart indexing
if idx.FullScanTime < 3 || idx.NumDirs < 10000 {
idx.Assessment = "simple"
+10
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
@@ -412,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))
}
+4
View File
@@ -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
+30
View File
@@ -1,6 +1,8 @@
package iteminfo
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
@@ -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
}
}
}
+25
View File
@@ -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
}
+51
View File
@@ -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;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+551 -55
View File
@@ -12,6 +12,22 @@ definitions:
name:
type: string
type: object
http.DirectDownloadResponse:
properties:
hash:
type: string
status:
type: string
url:
type: string
type: object
http.GroupListResponse:
properties:
groups:
items:
type: string
type: array
type: object
http.HttpResponse:
properties:
message:
@@ -142,6 +158,15 @@ definitions:
description: secret used to encrypt TOTP secrets
type: string
type: object
settings.CustomTheme:
properties:
css:
description: The css file path and filename to use for the theme.
type: string
description:
description: The description of the theme to display in the UI.
type: string
type: object
settings.ExcludeIndexFilter:
properties:
fileEndsWith:
@@ -220,6 +245,8 @@ definitions:
name:
description: display name
type: string
styling:
$ref: '#/definitions/settings.StylingConfig'
type: object
settings.IncludeIndexFilter:
properties:
@@ -414,11 +441,11 @@ definitions:
description: disable type detection by header, useful if filesystem is slow.
type: boolean
externalUrl:
description: used by share links if set
description: used by share links if set (eg. http://mydomain.com)
type: string
internalUrl:
description: used by integrations if set, this is the url that an integration
service will use to communicate with filebrowser
description: used by integrations if set, this is the base domain that an
integration service will use to communicate with filebrowser (eg. http://localhost:8080)
type: string
logging:
items:
@@ -488,9 +515,16 @@ definitions:
defaultUserScope:
description: default "/" should match folders under path
type: string
denyByDefault:
description: deny access unless an "allow" access rule was specifically created.
type: boolean
disableIndexing:
description: disable the indexing of this source
type: boolean
disabled:
description: disable the source, this is useful so you don't need to remove
it from the config file
type: boolean
exclude:
allOf:
- $ref: '#/definitions/settings.ExcludeIndexFilter'
@@ -500,7 +534,7 @@ definitions:
- $ref: '#/definitions/settings.IncludeIndexFilter'
description: include files and folders from indexing, if exclude is not set
indexingIntervalMinutes:
description: optional manual overide interval in seconds to re-index the source
description: optional manual overide interval in minutes to re-index the source
type: integer
maxWatchers:
description: number of concurrent watchers to use for this source, currently
@@ -512,9 +546,38 @@ definitions:
items:
type: string
type: array
private:
description: designate as source as private -- currently just means no sharing
permitted.
type: boolean
type: object
settings.StylingConfig:
properties:
customCSS:
description: if a valid path to a css file is provided, it will be applied
for all users. (eg. "reduce-rounded-corners.css")
type: string
customThemes:
additionalProperties:
$ref: '#/definitions/settings.CustomTheme'
description: 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.
type: object
darkBackground:
description: Specify a valid CSS color property value to use as the background
color in dark mode
type: string
lightBackground:
description: specify a valid CSS color property value to use as the background
color in light mode
type: string
type: object
settings.UserDefaults:
properties:
customTheme:
description: Name of theme to use chosen from custom themes config.
type: string
darkMode:
description: should dark mode be enabled
type: boolean
@@ -526,12 +589,13 @@ definitions:
description: delete files without confirmation
type: boolean
disableOfficePreviewExt:
description: comma separated list of file extensions to disable office preview
for
description: 'deprecated: use disablePreviewExt instead'
type: string
disableOnlyOfficeExt:
description: comma separated list of file extensions to disable onlyoffice
preview for
description: list of file extensions to disable onlyoffice editor for
type: string
disablePreviewExt:
description: comma separated list of file extensions to disable preview for
type: string
disableQuickToggles:
description: disable the quick toggles in the sidebar
@@ -545,6 +609,9 @@ definitions:
disableUpdateNotifications:
description: disable update notifications banner for admin users
type: boolean
disableViewingExt:
description: comma separated list of file extensions to disable viewing for
type: string
editorQuickSave:
description: show quick save button in editor
type: boolean
@@ -578,6 +645,9 @@ definitions:
description: show hidden files in the UI. On windows this includes files starting
with a dot and windows hidden files
type: boolean
showSelectMultiple:
description: show select multiple files on desktop
type: boolean
singleClick:
description: open directory on single click, also enables middle click to
open in new tab
@@ -593,17 +663,120 @@ definitions:
description: 'view mode to use: eg. normal, list, grid, or compact'
type: string
type: object
share.Link:
share.CreateBody:
properties:
expire:
allowUpload:
description: AllowEdit bool `json:"allowEdit,omitempty"`
type: boolean
allowedUsernames:
items:
type: string
type: array
banner:
type: string
description:
type: string
disableAnonymous:
type: boolean
disableFileViewer:
type: boolean
disableShareCard:
type: boolean
disableSidebar:
type: boolean
disableThumbnails:
type: boolean
downloadURL:
type: string
downloadsLimit:
type: integer
expires:
type: string
favicon:
type: string
hash:
type: string
hideNavButtons:
type: boolean
keepAfterExpiration:
type: boolean
maxBandwidth:
type: integer
password:
type: string
path:
type: string
quickDownload:
type: boolean
shareTheme:
type: string
source:
description: backend source is path to maintain between name changes
type: string
themeColor:
type: string
title:
type: string
unit:
type: string
viewMode:
type: string
type: object
share.Link:
properties:
allowUpload:
description: AllowEdit bool `json:"allowEdit,omitempty"`
type: boolean
allowedUsernames:
items:
type: string
type: array
banner:
type: string
description:
type: string
disableAnonymous:
type: boolean
disableFileViewer:
type: boolean
disableShareCard:
type: boolean
disableSidebar:
type: boolean
disableThumbnails:
type: boolean
downloadURL:
type: string
downloads:
type: integer
downloadsLimit:
type: integer
expire:
type: integer
favicon:
type: string
hash:
type: string
hideNavButtons:
type: boolean
keepAfterExpiration:
type: boolean
maxBandwidth:
type: integer
password_hash:
type: string
path:
type: string
quickDownload:
type: boolean
shareTheme:
type: string
source:
description: backend source is path to maintain between name changes
type: string
themeColor:
type: string
title:
type: string
token:
description: |-
@@ -613,6 +786,8 @@ definitions:
type: string
userID:
type: integer
viewMode:
type: string
type: object
users.AuthToken:
properties:
@@ -706,6 +881,9 @@ definitions:
additionalProperties:
$ref: '#/definitions/users.AuthToken'
type: object
customTheme:
description: Name of theme to use chosen from custom themes config.
type: string
darkMode:
description: should dark mode be enabled
type: boolean
@@ -717,12 +895,13 @@ definitions:
description: delete files without confirmation
type: boolean
disableOfficePreviewExt:
description: comma separated list of file extensions to disable office preview
for
description: deprecated
type: string
disableOnlyOfficeExt:
description: comma separated list of file extensions to disable onlyoffice
preview for
description: deprecated
type: string
disablePreviewExt:
description: comma separated list of file extensions to disable preview for
type: string
disableQuickToggles:
description: disable the quick toggles in the sidebar
@@ -735,6 +914,9 @@ definitions:
disableUpdateNotifications:
description: disable update notifications
type: boolean
disableViewingExt:
description: comma separated list of file extensions to disable viewing for
type: string
editorQuickSave:
description: show quick save button in editor
type: boolean
@@ -783,6 +965,9 @@ definitions:
description: show hidden files in the UI. On windows this includes files starting
with a dot and windows hidden files
type: boolean
showSelectMultiple:
description: show select multiple files on desktop
type: boolean
singleClick:
description: open directory on single click, also enables middle click to
open in new tab
@@ -809,6 +994,264 @@ definitions:
info:
contact: {}
paths:
/api/access:
delete:
consumes:
- application/json
description: Delete a user or group from an allow or deny list for a sourcePath
and indexPath.
parameters:
- description: Source path prefix (e.g. mnt/storage)
in: query
name: source
required: true
type: string
- description: Index path (e.g. /secret)
in: query
name: path
required: true
type: string
- description: Rule type (allow or deny)
in: query
name: ruleType
required: true
type: string
- description: Rule category (user or group)
in: query
name: ruleCategory
required: true
type: string
- description: Username or groupname to remove
in: query
name: value
required: true
type: string
produces:
- application/json
responses:
"200":
description: Rule entry deleted
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Delete access rule entry
tags:
- Access
get:
consumes:
- application/json
description: Lists access rules. Can be filtered by source, path, user, or group.
Can also be grouped by user or group.
parameters:
- description: Source name (e.g. 'default')
in: query
name: source
type: string
- description: Index path (e.g. /secret)
in: query
name: path
type: string
- description: Username to filter rules for
in: query
name: user
type: string
- description: Group name to filter rules for
in: query
name: group
type: string
produces:
- application/json
responses:
"200":
description: Varies based on query. Can be access.FrontendAccessRule (when
source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule,
or map[string]access.FrontendAccessRule
schema:
type: object
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: List access rules
tags:
- Access
post:
consumes:
- application/json
description: Add or update an access rule for a sourcePath and indexPath.
parameters:
- description: Source path prefix (e.g. mnt/storage)
in: query
name: source
required: true
type: string
- description: Index path (e.g. /secret)
in: query
name: path
required: true
type: string
- description: 'Rule details: allow (true/false), ruleCategory (user/group),
value (username or groupname)'
in: body
name: body
required: true
schema:
properties:
allow:
type: boolean
ruleCategory:
type: string
value:
type: string
type: object
produces:
- application/json
responses:
"200":
description: Rule added or updated
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Add or update access rule
tags:
- Access
/api/access/group:
delete:
consumes:
- application/json
description: Removes a user from a group.
parameters:
- description: Group name
in: query
name: group
required: true
type: string
- description: User name
in: query
name: user
required: true
type: string
produces:
- application/json
responses:
"200":
description: User removed from group successfully
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Remove a user from a group
tags:
- Access
post:
consumes:
- application/json
description: Adds a user to a group.
parameters:
- description: Group name
in: query
name: group
required: true
type: string
- description: User name
in: query
name: user
required: true
type: string
produces:
- application/json
responses:
"200":
description: User added to group successfully
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Add a user to a group
tags:
- Access
/api/access/groups:
get:
consumes:
- application/json
description: Returns a list of all groups or the groups for a specific user.
parameters:
- description: User name
in: query
name: user
type: string
produces:
- application/json
responses:
"200":
description: Object containing a list of groups
schema:
$ref: '#/definitions/http.GroupListResponse'
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
summary: Get all groups or groups for a user
tags:
- Access
/api/auth/logout:
post:
description: Returns a logout URL for the frontend to redirect to.
@@ -1669,6 +2112,40 @@ paths:
summary: Get share links by path
tags:
- Shares
post:
consumes:
- application/json
description: Creates a new share link with an optional expiration time and password
protection.
parameters:
- description: Share creation parameters
in: body
name: body
required: true
schema:
$ref: '#/definitions/share.CreateBody'
produces:
- application/json
responses:
"200":
description: Created share link
schema:
$ref: '#/definitions/share.Link'
"400":
description: Bad request - failed to decode body
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Create a share link
tags:
- Shares
/api/shares:
delete:
consumes:
@@ -1723,44 +2200,6 @@ paths:
summary: List share links
tags:
- Shares
post:
consumes:
- application/json
description: Creates a new share link with an optional expiration time and password
protection.
parameters:
- description: Source Path of the files to share
in: query
name: path
required: true
type: string
- description: Source name of the files to share
in: query
name: source
required: true
type: string
produces:
- application/json
responses:
"200":
description: Created share link
schema:
$ref: '#/definitions/share.Link'
"400":
description: Bad request - failed to decode body
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Create a share link
tags:
- Shares
/api/signup:
post:
consumes:
@@ -1978,11 +2417,10 @@ paths:
description: Returns the raw content of a file, multiple files, or a directory.
Supports downloading files as archives in various formats.
parameters:
- description: a list of files in the following format 'filename' and separated
by '||' with additional items in the list. (required)
- description: if specified, only the files in the list will be downloaded.
eg. files=/file1||/folder/file2
in: query
name: files
required: true
type: string
- description: If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults
to 'attachment'.
@@ -2028,4 +2466,62 @@ paths:
summary: Get raw content of a file, multiple files, or directory
tags:
- Resources
/public/share/direct:
get:
consumes:
- application/json
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.
parameters:
- description: File path to create download link for
in: query
name: path
required: true
type: string
- description: Source name for the file
in: query
name: source
required: true
type: string
- description: 'Duration in minutes for link validity (default: 60)'
in: query
name: duration
type: string
- description: 'Maximum number of downloads allowed (default: unlimited)'
in: query
name: count
type: string
- description: 'Download speed limit in kbps (default: unlimited)'
in: query
name: speed
type: string
produces:
- application/json
responses:
"201":
description: Direct download link created
schema:
$ref: '#/definitions/http.DirectDownloadResponse'
"400":
description: Bad request - invalid parameters or path is not a file
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden - access denied
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Create direct download link
tags:
- Shares
swagger: "2.0"
+740
View File
@@ -0,0 +1,740 @@
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 92478a24..5085e058 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,17 +11,16 @@ All notable changes to this project will be documented in this file. For commit
**New Features**:
- New access control system that works like shares. You can add new allow / deny rules for users/groups for specific paths on specific sources.
- #- The concept of 'groups', either automatically from OIDC groups claim or defined https://github.com/gtsteffaniak/filebrowser/issues/545
+ - The concept of 'groups', either automatically from OIDC groups claim or defined 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, banner image, and icons https://github.com/gtsteffaniak/filebrowser/issues/827 https://github.com/gtsteffaniak/filebrowser/issues/1029
- #- share link info customization https://github.com/gtsteffaniak/filebrowser/issues/792 https://github.com/gtsteffaniak/filebrowser/issues/841 https://github.com/gtsteffaniak/filebrowser/issues/520
- #- "shared with me" and "share history" https://github.com/gtsteffaniak/filebrowser/issues/943
+ - 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, banner image, and icons https://github.com/gtsteffaniak/filebrowser/issues/827 https://github.com/gtsteffaniak/filebrowser/issues/1029
+ - share link info customization https://github.com/gtsteffaniak/filebrowser/issues/792 https://github.com/gtsteffaniak/filebrowser/issues/841 https://github.com/gtsteffaniak/filebrowser/issues/520
+ - "shared with me" and "share history" https://github.com/gtsteffaniak/filebrowser/issues/943
- upload to share links https://github.com/gtsteffaniak/filebrowser/issues/661
- share link public changes https://github.com/gtsteffaniak/filebrowser/issues/473
- -
- public/private folder logic https://github.com/gtsteffaniak/filebrowser/issues/505
**BugFixes**:
diff --git a/backend/http/middleware.go b/backend/http/middleware.go
index 5ba397fe..595f00e1 100644
--- a/backend/http/middleware.go
+++ b/backend/http/middleware.go
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"net/http"
- "net/url"
"runtime"
"strings"
"time"
@@ -44,14 +43,8 @@ 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 withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
+ path := r.URL.Query().Get("path")
hash := r.URL.Query().Get("hash")
- encodedPath := r.URL.Query().Get("path")
- // Decode the URL-encoded path
- path, err := url.QueryUnescape(encodedPath)
- if err != nil {
- return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
- }
- data.path = path
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
if err != nil {
@@ -71,6 +64,11 @@ 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")
diff --git a/backend/http/middleware_test.go b/backend/http/middleware_test.go
index ff7c173e..eb257951 100644
--- a/backend/http/middleware_test.go
+++ b/backend/http/middleware_test.go
@@ -155,20 +155,6 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
},
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",
- Source: "/srv",
- },
- extraHeaders: map[string]string{
- "X-SHARE-PASSWORD": "password",
- },
- expectedStatusCode: 0, // zero means 200 on helpers
- },
{
name: "Private share, no auth provided",
share: &share.Link{
diff --git a/backend/http/public.go b/backend/http/public.go
index ef65dfa6..0e641fbd 100644
--- a/backend/http/public.go
+++ b/backend/http/public.go
@@ -7,6 +7,9 @@ import (
"net/url"
"strings"
+ "github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
+ "github.com/gtsteffaniak/filebrowser/backend/common/utils"
+ "github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -49,7 +52,7 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
- d.fileInfo.Path = "/" + strings.TrimPrefix(d.fileInfo.Path, d.share.Path)
+ d.fileInfo.Path = strings.TrimPrefix(d.fileInfo.Path, d.share.Path)
return renderJSON(w, r, d.fileInfo)
}
@@ -91,5 +94,24 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if config.Server.DisablePreviews {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
+ path := r.URL.Query().Get("path")
+ var err error
+ if path == "" {
+ return http.StatusBadRequest, fmt.Errorf("invalid request path")
+ }
+ fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
+ Path: utils.JoinPathAsUnix(d.share.Path, path),
+ Modify: false, // TODO make this configurable
+ Source: d.fileInfo.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)
}
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 9399a292..2ef5f38e 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -7,3 +7,6 @@ import search from "./search";
// Note: shareApi has been consolidated into publicApi
export { filesApi, publicApi, usersApi, settingsApi, search, accessApi };
+
+// For backward compatibility, export publicApi as shareApi as well
+export { publicApi as shareApi };
diff --git a/frontend/src/api/public.js b/frontend/src/api/public.js
index 1dcb4125..d94713c5 100644
--- a/frontend/src/api/public.js
+++ b/frontend/src/api/public.js
@@ -2,7 +2,6 @@ import { fetchURL, fetchJSON, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath, removePrefix } from "@/utils/url.js";
import { externalUrl, baseURL } from "@/utils/constants";
-import { state } from "@/store";
// ============================================================================
// PUBLIC API ENDPOINTS (hash-based authentication)
@@ -13,8 +12,7 @@ export async function fetchPub(path, hash, password = "", content = false) {
const params = {
path,
hash,
- ...(content && { content: 'true' }),
- ...(state.share.token && { token: state.share.token })
+ ...(content && { content: 'true' })
}
const apiPath = getApiPath("public/api/shared", params);
const response = await fetch(apiPath, {
@@ -24,17 +22,7 @@ export async function fetchPub(path, hash, password = "", content = false) {
});
if (!response.ok) {
- const error = new Error(response.statusText);
- // attempt to marshal json response
- let data = null;
- try {
- data = await response.json()
- } catch (e) {
- // ignore
- }
- if (data) {
- error.message = data.message;
- }
+ const error = new Error("Failed to connect to the server.");
error.status = response.status;
throw error;
}
@@ -57,14 +45,13 @@ export function getDownloadURL(share, files) {
}
// Generate a preview URL for public shares
-export function getPreviewURL(path) {
+export function getPreviewURL(hash, path) {
try {
const params = {
path: encodeURIComponent(path),
size: 'small',
- hash: state.share.hash,
- inline: 'true',
- ...(state.share.token && { token: state.share.token })
+ hash: hash,
+ inline: 'true'
}
const apiPath = getApiPath('public/api/preview', params)
return window.origin + apiPath
diff --git a/frontend/src/api/share.js b/frontend/src/api/share.js
new file mode 100644
index 00000000..729b7599
--- /dev/null
+++ b/frontend/src/api/share.js
@@ -0,0 +1,66 @@
+import { fetchURL, fetchJSON, adjustedData } from "./utils";
+import { notify } from "@/notify";
+import { getApiPath } from "@/utils/url.js";
+import { externalUrl } from "@/utils/constants";
+
+export async function list() {
+ const apiPath = getApiPath("api/shares");
+ return fetchJSON(apiPath);
+}
+
+export async function get(path, source) {
+ try {
+ const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
+ const apiPath = getApiPath("api/share",params);
+ let data = fetchJSON(apiPath);
+ return adjustedData(data, path);
+ } catch (err) {
+ notify.showError(err.message || "Error fetching data");
+ throw err;
+ }
+}
+
+export async function remove(hash) {
+ const params = { hash };
+ const apiPath = getApiPath("api/share",params);
+ await fetchURL(apiPath, {
+ method: "DELETE",
+ });
+}
+
+export async function create(path, source, password = "", expires = "", unit = "hours") {
+ const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
+ const apiPath = getApiPath("api/share",params);
+ let body = "{}";
+ if (password != "" || expires !== "" || unit !== "hours") {
+ body = JSON.stringify({ password: password, expires: expires, unit: unit });
+ }
+ return fetchJSON(apiPath, {
+ method: "POST",
+ body: body,
+ });
+}
+
+export function getShareURL(share) {
+ if (externalUrl) {
+ const apiPath = getApiPath(`share/${share.hash}`)
+ return externalUrl + apiPath
+ }
+ return window.origin+getApiPath(`share/${share.hash}`);
+}
+
+export function getPreviewURL(hash, path) {
+ try {
+ const params = {
+ path: encodeURIComponent(path),
+ size: 'small',
+ hash: hash,
+ inline: 'true'
+ }
+ const apiPath = getApiPath('api/public/preview', params)
+ return window.origin + apiPath
+ } catch (err) {
+ notify.showError(err.message || 'Error getting preview URL')
+ throw err
+ }
+}
diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue
index 29c2749f..93848c1c 100644
--- a/frontend/src/components/files/ListingItem.vue
+++ b/frontend/src/components/files/ListingItem.vue
@@ -67,7 +67,7 @@ import { enableThumbs } from "@/utils/constants";
import downloadFiles from "@/utils/download";
import { getHumanReadableFilesize } from "@/utils/filesizes";
-import { filesApi,publicApi } from "@/api";
+import { filesApi, shareApi } from "@/api";
import * as upload from "@/utils/upload";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { url } from "@/utils";
@@ -162,8 +162,8 @@ export default {
// @ts-ignore
let path = url.removeTrailingSlash(state.req.path) + "/" + this.name;
if (getters.isShare()) {
- let urlPath = getters.getSharePath(this.name) ;
- return publicApi.getPreviewURL(urlPath);
+ let urlPath = getters.getSharePath() + "/" + this.name;
+ return shareApi.getPreviewURL(state.share.hash, urlPath, state.req.modified);
}
// @ts-ignore
return filesApi.getPreviewURL(state.req.source, path, this.modified);
@@ -226,7 +226,7 @@ export default {
},
getUrl() {
if (this.hash) {
- return baseURL + "public/share/" + this.hash + "/" + url.encodedPath(this.path);
+ return baseURL + "share/" + this.hash + url.encodedPath(this.path);
}
if (serverHasMultipleSources) {
return baseURL + "files/" + encodeURIComponent(this.source) + url.encodedPath(this.path);
diff --git a/frontend/src/components/prompts/ActionApi.vue b/frontend/src/components/prompts/ActionApi.vue
index f2ebaa3f..1a381565 100644
--- a/frontend/src/components/prompts/ActionApi.vue
+++ b/frontend/src/components/prompts/ActionApi.vue
@@ -90,3 +90,20 @@ export default {
},
};
</script>
+
+<style>
+/* Basic styling for the prompt */
+.card.floating {
+ padding: 20px;
+}
+.card-content {
+ margin-bottom: 16px;
+}
+.card-action {
+ display: flex;
+ justify-content: flex-end;
+}
+.card-action .button {
+ margin-left: 8px;
+}
+</style>
diff --git a/frontend/src/components/prompts/Share.vue b/frontend/src/components/prompts/Share.vue
index be1ad5ad..8689100d 100644
--- a/frontend/src/components/prompts/Share.vue
+++ b/frontend/src/components/prompts/Share.vue
@@ -2,11 +2,10 @@
<div class="card-title">
<h2>{{ $t("buttons.share") }}</h2>
</div>
- <div class="card-content">
- <div aria-label="share-path" class="searchContext"> {{ $t('search.path') }} {{ subpath }}</div>
- <p> {{ $t('share.notice') }} </p>
-
- <div v-if="listing">
+ <div aria-label="share-path" class="searchContext"> {{ $t('search.path') }} {{ subpath }}</div>
+ <p> {{ $t('share.notice') }} </p>
+ <template v-if="listing">
+ <div class="card-content">
<table>
<tbody>
<tr>
@@ -46,7 +45,20 @@
</table>
</div>
- <div v-else>
+ <div class="card-action">
+ <button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.close')"
+ :title="$t('buttons.close')">
+ {{ $t("buttons.close") }}
+ </button>
+ <button class="button button--flat button--blue" @click="() => switchListing()" :aria-label="$t('buttons.new')"
+ :title="$t('buttons.new')">
+ {{ $t("buttons.new") }}
+ </button>
+ </div>
+ </template>
+
+ <template v-else>
+ <div class="card-content">
<p>{{ $t("settings.shareDuration") }}</p>
<div class="form-flex-group">
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="1" @keyup.enter="submit" v-model.trim="time" />
@@ -58,7 +70,7 @@
</select>
</div>
<p>{{ $t("prompts.optionalPassword") }}</p>
- <input class="input" type="password" autocomplete="off" v-model.trim="password" />
+ <input class="input" type="password" v-model.trim="password" />
<div class="settings-items">
<ToggleSwitch class="item" v-model="readOnly" :name="'Read-only access'" />
@@ -80,32 +92,23 @@
</select>
</div>
</div>
- </div>
- <div class="card-action">
- <button v-if="listing" class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.close')"
- :title="$t('buttons.close')">
- {{ $t("buttons.close") }}
- </button>
- <button v-if="listing" class="button button--flat button--blue" @click="() => switchListing()" :aria-label="$t('buttons.new')"
- :title="$t('buttons.new')">
- {{ $t("buttons.new") }}
- </button>
-
- <button v-if="!listing" class="button button--flat button--grey" @click="() => switchListing()" :aria-label="$t('buttons.cancel')"
- :title="$t('buttons.cancel')">
- {{ $t("buttons.cancel") }}
- </button>
- <button v-if="!listing" class="button button--flat button--blue" @click="submit" aria-label="Share-Confirm"
- :title="$t('buttons.share')">
- {{ $t("buttons.share") }}
- </button>
- </div>
+ <div class="card-action">
+ <button class="button button--flat button--grey" @click="() => switchListing()" :aria-label="$t('buttons.cancel')"
+ :title="$t('buttons.cancel')">
+ {{ $t("buttons.cancel") }}
+ </button>
+ <button class="button button--flat button--blue" @click="submit" aria-label="Share-Confirm"
+ :title="$t('buttons.share')">
+ {{ $t("buttons.share") }}
+ </button>
+ </div>
+ </template>
</template>
<script>
import { notify } from "@/notify";
import { state, getters, mutations } from "@/store";
-import { publicApi } from "@/api";
+import { shareApi, publicApi } from "@/api";
import Clipboard from "clipboard";
import { fromNow } from "@/utils/moment";
import { buildItemUrl } from "@/utils/url";
@@ -188,7 +191,7 @@ export default {
try {
// get last element of the path
- const links = await publicApi.get(this.subpath, this.source);
+ const links = await shareApi.get(this.subpath, this.source);
this.links = links;
} catch (err) {
notify.showError(err);
@@ -215,9 +218,9 @@ export default {
let isPermanent = !this.time || this.time == 0;
let res = null;
if (isPermanent) {
- res = await publicApi.create(this.subpath, this.source, this.password);
+ res = await shareApi.create(this.subpath, this.source, this.password);
} else {
- res = await publicApi.create(
+ res = await shareApi.create(
this.subpath,
this.source,
this.password,
@@ -241,7 +244,7 @@ export default {
async deleteLink(event, link) {
event.preventDefault();
try {
- await publicApi.remove(link.hash);
+ await shareApi.remove(link.hash);
this.links = this.links.filter((item) => item.hash !== link.hash);
if (this.links.length === 0) {
this.listing = false;
@@ -254,7 +257,7 @@ export default {
return fromNow(time, state.user.locale)
},
buildLink(share) {
- return publicApi.getShareURL(share);
+ return shareApi.getShareURL(share);
},
hasDownloadLink() {
if (state.isSearchActive) {
diff --git a/frontend/src/components/sidebar/General.vue b/frontend/src/components/sidebar/General.vue
index 58f02c10..0e030aeb 100644
--- a/frontend/src/components/sidebar/General.vue
+++ b/frontend/src/components/sidebar/General.vue
@@ -159,7 +159,7 @@ export default {
isStickySidebar: () => getters.isStickySidebar(),
isMobile: () => getters.isMobile(),
isListingView: () => getters.currentView() == "listingView",
- user: () => (state.user),
+ user: () => (console.log(state.user), state.user),
isDarkMode: () => getters.isDarkMode(),
showSources: () => !getters.isShare(),
currentPrompt: () => getters.currentPrompt(),
diff --git a/frontend/src/css/dashboard.css b/frontend/src/css/dashboard.css
index 2e1fbc8f..77da5938 100644
--- a/frontend/src/css/dashboard.css
+++ b/frontend/src/css/dashboard.css
@@ -226,10 +226,6 @@ body.rtl .card-title>*:first-child {
overflow: auto;
}
-.card-content h3 {
- text-align: center;
-}
-
.card h2 {
font-weight: 500;
}
diff --git a/frontend/src/store/getters.js b/frontend/src/store/getters.js
index cb062702..d97e7eed 100644
--- a/frontend/src/store/getters.js
+++ b/frontend/src/store/getters.js
@@ -201,9 +201,10 @@ export const getters = {
sharePathBase: () => {
return '/public/share/' + getters.shareHash() + '/'
},
- getSharePath: (subPath = "") => {
+ getSharePath: () => {
let urlPath = getters.routePath('public/share')
- return "/" + removeLeadingSlash(urlPath.split(state.share.hash)[1]) + "/" + subPath
+ let parts = urlPath.split('/')
+ return "/" + removeLeadingSlash(parts.slice(2).join('/'))
},
currentView: () => {
let listingView = ''
@@ -288,6 +289,7 @@ export const getters = {
},
currentPromptName: () => {
+ console.log("currentPromptName", state.prompts);
// Ensure state.prompts is an array
if (!Array.isArray(state.prompts) || state.prompts.length === 0) {
return ""
diff --git a/frontend/src/store/mutations.js b/frontend/src/store/mutations.js
index 8dc0b19f..b2b13461 100644
--- a/frontend/src/store/mutations.js
+++ b/frontend/src/store/mutations.js
@@ -191,6 +191,7 @@ export const mutations = {
emitStateChanged();
},
showHover: (value) => {
+ console.log("showHover", value);
if (typeof value === "object") {
state.prompts.push({
name: value?.name,
@@ -206,6 +207,7 @@ export const mutations = {
props: value?.props,
});
}
+ console.log("showHover", state.prompts);
emitStateChanged();
},
setLoading: (loadType, status) => {
@@ -221,6 +223,7 @@ export const mutations = {
emitStateChanged();
},
setCurrentUser: (value) => {
+ console.log('setCurrentUser', value);
try {
// If value is null or undefined, emit state change and exit early
if (!value) {
@@ -243,6 +246,7 @@ export const mutations = {
} catch (error) {
console.log(error);
}
+ console.log('setCurrentUser', state.user);
emitStateChanged();
},
setJWT: (value) => {
@@ -303,6 +307,7 @@ export const mutations = {
if (!state.user) {
state.user = {};
}
+ console.log('updateCurrentUser', value);
// Store previous state for comparison
const previousUser = { ...state.user };
@@ -315,12 +320,14 @@ export const mutations = {
i18n.default.locale = state.user.locale;
localStorage.setItem("userLocale", state.user.locale);
}
+ console.log('updateCurrentUser2', state.user);
// Update localStorage if stickySidebar exists
if (state.user.stickySidebar && getters.currentView() == "listingView") {
state.multiButtonState = "menu";
} else if (state.showSidebar) {
state.multiButtonState = "back";
}
+ console.log('updateCurrentUser3', state.user);
// Update users if there's any change in state.user
if (JSON.stringify(state.user) !== JSON.stringify(previousUser)) {
// Only update the properties that were actually provided in the input
diff --git a/frontend/src/views/Files.vue b/frontend/src/views/Files.vue
index 4c69ecd7..6f8b892b 100644
--- a/frontend/src/views/Files.vue
+++ b/frontend/src/views/Files.vue
@@ -9,9 +9,8 @@
<div class="card-title">
<h2>{{ $t("general.password") }}</h2>
</div>
- <div class="card-content form-flex-group">
+ <div class="card-content">
<input
- class="input share-password"
v-focus
type="password"
:placeholder="$t('general.password')"
@@ -149,10 +148,8 @@ export default {
});
// Add glow effect
element.classList.add('scroll-glow');
- console.log("scrollToHash", element)
// Remove glow effect after animation completes
setTimeout(() => {
- console.log("scrollToHash remove glow", element)
element.classList.remove('scroll-glow');
}, 1000);
}
@@ -211,15 +208,19 @@ export default {
this.shareHash = parts[1]
this.shareSubPath = "/" + parts.slice(2).join("/");
+ // Store share data in state for use by components
+ mutations.setShareData({
+ hash: this.shareHash,
+ token: this.shareToken,
+ subPath: this.shareSubPath,
+ });
+
// Handle password
- if (this.sharePassword === "") {
+ if (this.sharePassword === "" || this.sharePassword === null) {
this.sharePassword = localStorage.getItem("sharepass:" + this.shareHash);
} else {
localStorage.setItem("sharepass:" + this.shareHash, this.sharePassword);
}
- if (this.sharePassword === null) {
- this.sharePassword = "";
- }
mutations.resetSelected();
mutations.setMultiple(false);
@@ -229,15 +230,11 @@ export default {
let file = await publicApi.fetchPub(this.shareSubPath, this.shareHash, this.sharePassword);
file.hash = this.shareHash;
this.shareToken = file.token;
- // Store share data in state for use by components
- mutations.setShareData({
- hash: this.shareHash,
- token: this.shareToken,
- subPath: this.shareSubPath,
- });
+
// If not a directory, fetch content for preview components
if (file.type != "directory") {
const content = !getters.fileViewingDisabled(file.name);
+
file = await publicApi.fetchPub(this.shareSubPath, this.shareHash, this.sharePassword, content);
file.hash = this.shareHash;
this.shareToken = file.token;
@@ -324,7 +321,7 @@ export default {
};
</script>
-<style>
+<style scoped>
.share__wrong__password {
color: #ff4757;
text-align: center;
@@ -351,8 +348,4 @@ export default {
color: inherit;
}
}
-
-.share-password {
- width: 100%;
-}
</style>
diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue
index 4547e3d2..38d75dc1 100644
--- a/frontend/src/views/Settings.vue
+++ b/frontend/src/views/Settings.vue
@@ -122,4 +122,7 @@ export default {
background-color: var(--surfaceSecondary);
}
+.card-content {
+ padding: 1em;
+}
</style>
diff --git a/frontend/src/views/settings/Profile.vue b/frontend/src/views/settings/Profile.vue
index f65fe30f..e34e405a 100644
--- a/frontend/src/views/settings/Profile.vue
+++ b/frontend/src/views/settings/Profile.vue
@@ -428,6 +428,9 @@ export default {
</script>
<style scoped>
+.card-content h3 {
+ text-align: center;
+}
#disablePreviews,
#disableViewing,
diff --git a/frontend/src/views/settings/Shares.vue b/frontend/src/views/settings/Shares.vue
index cd28af70..6d39166e 100644
--- a/frontend/src/views/settings/Shares.vue
+++ b/frontend/src/views/settings/Shares.vue
@@ -49,7 +49,7 @@
<script>
import { notify } from "@/notify";
-import { publicApi } from "@/api";
+import { shareApi } from "@/api";
import { state, mutations, getters } from "@/store";
import Clipboard from "clipboard";
import Errors from "@/views/Errors.vue";
@@ -70,7 +70,7 @@ export default {
async created() {
mutations.setLoading("shares", true);
try {
- let links = await publicApi.list();
+ let links = await shareApi.list();
if (links.length === 0) {
this.links = [];
return;
@@ -115,7 +115,7 @@ export default {
mutations.closeHovers();
try {
- publicApi.remove(link.hash);
+ shareApi.remove(link.hash);
this.links = this.links.filter((item) => item.hash !== link.hash);
notify.showSuccess(this.$t("settings.shareDeleted"));
} catch (e) {
@@ -128,7 +128,7 @@ export default {
return fromNow(time);
},
buildLink(share) {
- return publicApi.getShareURL(share);
+ return shareApi.getShareURL(share);
},
},
};
-1
View File
@@ -1 +0,0 @@
frontend
+5 -5
View File
@@ -8,11 +8,10 @@
"node": ">=18.0.0"
},
"scripts": {
"dev": "vite dev",
"build": "vite build && cp -r dist/* ../backend/http/embed",
"build-windows": "vite build && robocopy dist ../backend/http/embed /e",
"build": "vite build --outDir ../backend/http/dist && cp -r ../backend/http/dist/* ../backend/http/embed",
"build-windows": "vite build --outDir ../backend/http/dist && robocopy ../backend/http/dist ../backend/http/embed /e",
"build-docker": "vite build",
"watch": "vite build --watch",
"watch": "vite build --watch --outDir ../backend/http/dist",
"typecheck": "vue-tsc -p ./tsconfig.json --noEmit",
"lint": "eslint --ext .js,.vue,ts src",
"lint:fix": "eslint --fix src/",
@@ -44,7 +43,8 @@
"devDependencies": {
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.49.1",
"@playwright/test": "^1.54.1",
"@types/node": "^24.1.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
"deepl-node": "^1.18.0",
+47
View File
@@ -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/local-setup",
timeout: 5000,
testDir: "./tests/playwright/copy",
/* 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://localhost:8080",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});
+23 -5
View File
@@ -21,7 +21,10 @@ server:
- path: "/relative/or/absolute/path" # file system path. (Can be relative) validate:required
name: "backend" # display name
config:
indexingIntervalMinutes: 0 # optional manual overide interval in seconds to re-index the source
denyByDefault: false # deny access unless an "allow" access rule was specifically created.
private: false # designate as source as private -- currently just means no sharing permitted.
disabled: false # disable the source, this is useful so you don't need to remove it from the config file
indexingIntervalMinutes: 0 # optional manual overide interval in minutes to re-index the source
disableIndexing: false # disable the indexing of this source
maxWatchers: 0 # number of concurrent watchers to use for this source, currently not supported
neverWatchPaths: []
@@ -40,8 +43,8 @@ server:
defaultUserScope: "/" # default "/" should match folders under path
defaultEnabled: false # should be added as a default source for new users?
createUserDir: false # create a user directory for each user
externalUrl: "" # used by share links if set
internalUrl: "" # used by integrations if set, this is the url that an integration service will use to communicate with filebrowser
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
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: "tmp" # path to the cache directory, used for thumbnails and other cached files
maxArchiveSize: 50 # max pre-archive combined size of files/folder that are allowed to be archived (in GB)
auth:
@@ -90,6 +93,17 @@ frontend:
title: "" # the title to display on hover
url: "https://github.com/gtsteffaniak/filebrowser/wiki" # the url to link to validate:required
disableNavButtons: false # disable the nav buttons in the sidebar
styling:
customCSS: "" # if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
lightBackground: "#f5f5f5" # specify a valid CSS color property value to use as the background color in light mode
darkBackground: "#141D24" # Specify a valid CSS color property value to use as the background color in dark mode
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.
alternative:
description: Reduce rounded corners
css: reduce-rounded-corners.css
default:
description: The default theme
css: ""
userDefaults:
editorQuickSave: false # show quick save button in editor
hideSidebarFileActions: false # hide the file actions in the sidebar
@@ -105,8 +119,8 @@ userDefaults:
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
quickDownload: false # show icon to download in one click
disableOnlyOfficeExt: ".txt .csv .html .pdf" # comma separated list of file extensions to disable onlyoffice preview for
disableOfficePreviewExt: "" # comma separated list of file extensions to disable office preview for
disablePreviewExt: "" # comma separated list of file extensions to disable preview for
disableViewingExt: "" # comma separated list of file extensions to disable viewing for
lockPassword: false # disable the user from changing their password
disableSettings: false # disable the user from viewing the settings page
preview:
@@ -130,6 +144,10 @@ userDefaults:
fileLoading: # upload and download settings
maxConcurrentUpload: 10
uploadChunkSizeMb: 10
disableOfficePreviewExt: "" # deprecated: use disablePreviewExt instead
disableOnlyOfficeExt: "" # list of file extensions to disable onlyoffice editor for
customTheme: "" # Name of theme to use chosen from custom themes config.
showSelectMultiple: false # show select multiple files on desktop
integrations:
office: # validate:omitempty
url: "" # The URL to the OnlyOffice Document Server, needs to be accessible to the user. validate:required
+38 -16
View File
@@ -5,51 +5,51 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>{{ if .Name }}{{ .Name }}{{ else }}FileBrowser Quantum{{ end }}</title>
<title>{{ .title }}</title>
<link rel="icon" type="image/png" sizes="256x256" href="{{ .StaticURL }}/img/icons/favicon-256x256.png">
<link rel="icon" type="image/png" sizes="256x256" href="{{ .favicon }}">
<!--<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Symbols+Outlined" rel="stylesheet">-->
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
<meta name="theme-color" content="{{ if .Color }}{{ .Color }}{{ else }}#2979ff{{ end }}">
<meta name="theme-color" content="{{ .color }}">
<!-- Add to home screen for Safari on iOS/iPadOS -->
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="assets">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .StaticURL }}/img/icons/android-chrome-256x256.png">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .appIcon }}">
<!-- Add to home screen for Windows -->
<meta name="msapplication-TileImage" content="{{ .StaticURL }}/img/icons/mstile-144x144.png">
<meta name="msapplication-TileColor" content="{{ if .Color }}{{ .Color }}{{ else }}#2979ff{{ end }}">
<meta name="msapplication-TileImage" content="{{ .winIcon }}">
<meta name="msapplication-TileColor" content="{{ .color }}">
<meta name="description" content="FileBrowser Quantum is a file manager for the web which can be used to manage files on your server">
<meta name="description" content="{{ .description }}">
<link rel="canonical" href="https://github.com/gtsteffaniak/filebrowser">
<link rel="canonical" href="https://github.com/gtsteffaniak">
<link rel="canonical" href="https://github.com/quantumx-apps">
<!-- Inject Some Variables and generate the manifest json -->
<script>
window.FileBrowser = JSON.parse('{{ .globalVars }}');
window.filebrowser = JSON.parse('{{ .globalVars }}');
var dynamicManifest = {
"name": window.FileBrowser.Name || 'FileBrowser Quantum',
"name": window.filebrowser.name || 'FileBrowser Quantum',
"icons": [
{
"src": window.location.origin + "{{ .StaticURL }}/img/icons/android-chrome-256x256.png",
"src": window.location.origin + "{{ .staticURL }}/img/icons/android-chrome-256x256.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": window.location.origin + "{{ .StaticURL }}/img/icons/android-chrome-192x192.png",
"src": window.location.origin + "{{ .staticURL }}/img/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
],
"start_url": window.location.origin + "{{ .BaseURL }}",
"start_url": window.location.origin + "{{ .baseURL }}",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": window.FileBrowser.Color || "#455a64"
"theme_color": "{{ .color }}"
}
const stringManifest = JSON.stringify(dynamicManifest);
@@ -59,6 +59,29 @@
</script>
<style>
/* Basic Styles */
:root {
--background: {{ .lightBackground }};
--alt-background: #ddd;
--surfacePrimary: gray;
--surfaceSecondary: lightgray;
--textPrimary: #546e7a;
--textSecondary: gray;
--iconBackground: #dddddd;
--activeWhiteIcon: gray;
}
.dark-mode {
--background: {{ .darkBackground }};
--alt-background: #283136;
--surfacePrimary: #20292F;
--surfaceSecondary: #3A4147;
--divider: rgba(255, 255, 255, 0.12);
--textPrimary: rgba(255, 255, 255, 0.87);
--textSecondary: rgba(255, 255, 255, 0.6);
--iconBackground: #1e1f20;
--activeWhiteIcon: white;
}
#loading {
position: fixed;
top: 0;
@@ -132,6 +155,8 @@
transform: scale(1.0);
}
}
{{ .customCSS }}
{{ .userSelectedTheme }}
</style>
</head>
@@ -157,9 +182,6 @@
{{end}}
<script type="module" src="/src/main.ts"></script>
{{ if .CSS }}
<link rel="stylesheet" href="{{ .StaticURL }}/custom.css">
{{ end }}
</body>
</html>
+20
View File
@@ -76,6 +76,8 @@ async function translateText(text, targetLanguage, keyPath = '') {
// --- Recursive key processor ---
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = []) {
let changesMade = false;
// First pass: Add/update keys from master to target
for (const key in masterObj) {
if (Object.prototype.hasOwnProperty.call(masterObj, key)) {
const currentPathPartsNext = [...currentPathParts, key];
@@ -123,6 +125,24 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
}
}
}
// Second pass: Remove obsolete keys that exist in target but not in master
const keysToRemove = [];
for (const key in targetObj) {
if (Object.prototype.hasOwnProperty.call(targetObj, key)) {
if (!masterObj.hasOwnProperty(key)) {
keysToRemove.push(key);
}
}
}
for (const key of keysToRemove) {
const currentKeyPath = [...currentPathParts, key].join('.');
console.log(`🗑️ Removing obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
delete targetObj[key];
changesMade = true;
}
return changesMade;
}
+55
View File
@@ -0,0 +1,55 @@
import { fetchJSON } from './utils'
import { getApiPath } from '@/utils/url.js'
/**
* @param {string} source
* @param {string} path
* @returns {Promise<any>}
*/
export async function get(source, path) {
const apiPath = getApiPath('api/access', { source, path })
return fetchJSON(apiPath)
}
/**
* @param {string} source
* @returns {Promise<any>}
*/
export async function getAll(source) {
const apiPath = getApiPath('api/access', { source })
return fetchJSON(apiPath)
}
/**
* @returns {Promise<{groups: string[]}>}
*/
export async function getGroups() {
const apiPath = getApiPath('api/access/groups')
return fetchJSON(apiPath)
}
/**
* @param {string} source
* @param {string} path
* @param {object} body
* @returns {Promise<any>}
*/
export async function add(source, path, body) {
const apiPath = getApiPath('api/access', { source, path });
return fetchJSON(apiPath, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' }
});
}
/**
* @param {string} source
* @param {string} path
* @param {{ allow: boolean; ruleCategory: string; value: string; }} body
* @returns {Promise<any>}
*/
export async function del(source, path, body) {
const ruleType = body.allow ? 'allow' : 'deny';
const { ruleCategory, value } = body;
const apiPath = getApiPath('api/access', { source, path, ruleType, ruleCategory, value });
return fetchJSON(apiPath, {
method: 'DELETE'
});
}
+7 -9
View File
@@ -1,8 +1,8 @@
import { fetchURL, adjustedData } from './utils'
import { getApiPath, removePrefix, doubleEncode } from '@/utils/url.js'
import { getApiPath, doubleEncode } from '@/utils/url.js'
import { mutations } from '@/store'
import { notify } from '@/notify'
import { externalUrl,baseURL } from '@/utils/constants'
import { externalUrl } from '@/utils/constants'
// Notify if errors occur
export async function fetchFiles(source, path, content = false) {
@@ -70,7 +70,7 @@ export function download(format, files, shareHash = "") {
}
}
fileargs = fileargs.slice(0, -2) // remove trailing "||"
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'api/public/dl', {
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', {
files: fileargs,
algo: format,
hash: shareHash
@@ -147,14 +147,12 @@ export function post(
}
});
return { xhr: request, promise };
promise.xhr = request;
return promise;
} catch (err) {
notify.showError(err.message || "Error posting resource");
// We are returning a promise, so we should return a rejected promise on error.
return {
xhr: null,
promise: Promise.reject(err),
};
return Promise.reject(err);
}
}
@@ -232,7 +230,7 @@ export function getDownloadURL(source, path, inline, useExternal) {
}
const apiPath = getApiPath('api/raw', params)
if (externalUrl && useExternal) {
return externalUrl + removePrefix(apiPath, baseURL)
return externalUrl + apiPath
}
return window.origin + apiPath
} catch (err) {
+4 -3
View File
@@ -1,8 +1,9 @@
import * as filesApi from "./files";
import * as shareApi from "./share";
import * as publicApi from "./public";
import * as usersApi from "./users";
import * as settingsApi from "./settings";
import * as publicApi from "./public";
import * as accessApi from "./access";
import search from "./search";
export { filesApi, shareApi, usersApi, settingsApi, publicApi, search };
// Note: shareApi has been consolidated into publicApi
export { filesApi, publicApi, usersApi, settingsApi, search, accessApi };
+153 -26
View File
@@ -1,11 +1,29 @@
import { adjustedData } from "./utils";
import { getApiPath } from "@/utils/url.js";
import { fetchURL, fetchJSON, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { externalUrl } from "@/utils/constants";
import { state } from "@/store";
// ============================================================================
// PUBLIC API ENDPOINTS (hash-based authentication)
// ============================================================================
// Fetch public share data
export async function fetchPub(path, hash, password = "") {
const params = { path, hash }
const apiPath = getApiPath("api/public/share", params);
/**
* @param {string} path
* @param {string} hash
* @param {string} password
* @param {boolean} content
* @returns {Promise<any>}
*/
export async function fetchPub(path, hash, password = "", content = false) {
const params = {
path,
hash,
...(content && { content: 'true' }),
...(state.share.token && { token: state.share.token })
}
const apiPath = getPublicApiPath("resources", params);
const response = await fetch(apiPath, {
headers: {
"X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "",
@@ -13,36 +31,145 @@ export async function fetchPub(path, hash, password = "") {
});
if (!response.ok) {
const error = new Error("Failed to connect to the server.");
error.status = response.status;
const error = new Error(response.statusText);
// attempt to marshal json response
let data = null;
try {
data = await response.json()
} catch (e) {
// ignore
}
if (data) {
error.message = data.message;
}
(/** @type {any} */ (error)).status = response.status;
throw error;
}
let data = await response.json()
const adjusted = adjustedData(data, `/share/${hash}${path}`);
const adjusted = adjustedData(data);
return adjusted
}
// Get the public user data
export async function getPublicUser() {
// Generate a download URL
/**
* @param {{ path: string; hash: string; token: string; inline?: boolean }} share
* @param {string[]} files
* @returns {string}
*/
export function getDownloadURL(share, files, inline=false) {
const params = {
files: files,
hash: share.hash,
token: share.token,
...(inline && { inline: 'true' })
}
const apiPath = getPublicApiPath("raw", params);
return window.origin + apiPath
}
// Generate a preview URL for public shares
/**
* @param {string} path
* @returns {string}
*/
export function getPreviewURL(path,size="small") {
try {
const apiPath = getApiPath("api/public/publicUser");
const response = await fetch(apiPath);
return response.json();
} catch (err) {
notify.showError(err.message || "Error fetching public user");
const params = {
path: encodeURIComponent(path),
size: size,
hash: state.share.hash,
inline: 'true',
...(state.share.token && { token: state.share.token })
}
const apiPath = getPublicApiPath('preview', params)
return window.origin + apiPath
} catch (/** @type {any} */ err) {
notify.showError(err.message || 'Error getting preview URL')
throw err
}
}
// ============================================================================
// SHARE MANAGEMENT API (permission-based authentication)
// ============================================================================
// List all shares
export async function list() {
const apiPath = getApiPath("public/shares");
return fetchJSON(apiPath);
}
// Get share information
/**
* @param {string} path
* @param {string} source
* @returns {Promise<any>}
*/
export async function get(path, source) {
try {
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
const apiPath = getApiPath("public/share", params);
let data = await fetchJSON(apiPath);
return adjustedData(data);
} catch (/** @type {any} */ err) {
notify.showError(err.message || "Error fetching data");
throw err;
}
}
// Generate a download URL
export function getDownloadURL(share,files) {
const params = {
path: share.path,
files: files,
hash: share.hash,
token: share.token,
...(share.inline && { inline: 'true' })
}
const apiPath = getApiPath("api/public/dl", params);
return window.origin+apiPath
// Remove/delete a share
/**
* @param {string} hash
* @returns {Promise<void>}
*/
export async function remove(hash) {
const params = { hash };
const apiPath = getApiPath("public/share", params);
await fetchURL(apiPath, {
method: "DELETE",
});
}
// Create a new share
/**
* @param {Record<string, any>} bodyObj
* @returns {Promise<Share>}
*/
export async function create(bodyObj = {}) {
const apiPath = getApiPath("public/share");
return fetchJSON(apiPath, {
method: "POST",
body: JSON.stringify(bodyObj || {}),
});
}
// Get the shareable URL for a share
/**
* @param {{ hash: string }} share
* @returns {string}
*/
export function getShareURL(share) {
if (externalUrl) {
const apiPath = getApiPath(`public/share/${share.hash}`)
return externalUrl + apiPath;
}
return window.origin + getApiPath(`public/share/${share.hash}`);
}
/**
* @typedef {object} Share
* @property {string} hash
* @property {string} path
* @property {string} source
* @property {number} expire
* @property {number} downloadsLimit
* @property {number} maxBandwidth
* @property {string} shareTheme
* @property {boolean} disableAnonymous
* @property {boolean} disableThumbnails
* @property {boolean} keepAfterExpiration
* @property {string[]} allowedUsernames
* @property {string} viewMode
* @property {string} token
* @property {boolean} inline
*/
-66
View File
@@ -1,66 +0,0 @@
import { fetchURL, fetchJSON, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath,removePrefix } from "@/utils/url.js";
import { externalUrl,baseURL } from "@/utils/constants";
export async function list() {
const apiPath = getApiPath("api/shares");
return fetchJSON(apiPath);
}
export async function get(path, source) {
try {
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
const apiPath = getApiPath("api/share",params);
let data = fetchJSON(apiPath);
return adjustedData(data, path);
} catch (err) {
notify.showError(err.message || "Error fetching data");
throw err;
}
}
export async function remove(hash) {
const params = { hash };
const apiPath = getApiPath("api/share",params);
await fetchURL(apiPath, {
method: "DELETE",
});
}
export async function create(path, source, password = "", expires = "", unit = "hours") {
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
const apiPath = getApiPath("api/share",params);
let body = "{}";
if (password != "" || expires !== "" || unit !== "hours") {
body = JSON.stringify({ password: password, expires: expires, unit: unit });
}
return fetchJSON(apiPath, {
method: "POST",
body: body,
});
}
export function getShareURL(share) {
if (externalUrl) {
const apiPath = getApiPath(`share/${share.hash}`)
return externalUrl + removePrefix(apiPath, baseURL);
}
return window.origin+getApiPath(`share/${share.hash}`);
}
export function getPreviewURL(hash, path) {
try {
const params = {
path: encodeURIComponent(path),
size: 'small',
hash: hash,
inline: 'true'
}
const apiPath = getApiPath('api/public/preview', params)
return window.origin + apiPath
} catch (err) {
notify.showError(err.message || 'Error getting preview URL')
throw err
}
}
+12 -8
View File
@@ -1,9 +1,10 @@
import { fetchURL, fetchJSON } from '@/api/utils'
import { getApiPath } from '@/utils/url.js'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { notify } from '@/notify' // Import notify for error handling
import { setNewToken } from '@/utils/auth.js' // Import setNewToken for token management
import { shareInfo } from '@/utils/constants'
export async function getAllUsers () {
export async function getAllUsers() {
try {
const apiPath = getApiPath('api/users')
return await fetchJSON(apiPath)
@@ -13,7 +14,7 @@ export async function getAllUsers () {
}
}
export async function generateOTP (username, password) {
export async function generateOTP(username, password) {
const params = { username }
try {
let apiPath = getApiPath('api/auth/otp/generate', params)
@@ -30,7 +31,7 @@ export async function generateOTP (username, password) {
}
}
export async function verifyOtp (username, password, otp) {
export async function verifyOtp(username, password, otp) {
const params = { username }
try {
let apiPath = getApiPath('api/auth/otp/verify', params)
@@ -91,7 +92,10 @@ export async function login(username, password, recaptcha, otp) {
}
export async function get(id) {
try {
const apiPath = getApiPath('api/users', { id: id })
let apiPath = getApiPath('api/users', { id: id })
if (shareInfo.isShare) {
apiPath = getPublicApiPath('users', { id: id })
}
return await fetchJSON(apiPath)
} catch (err) {
notify.showError(err.message || `Failed to fetch user with ID: ${id}`)
@@ -148,7 +152,7 @@ export function deleteApiKey (params) {
}
}
export async function create (user) {
export async function create(user) {
try {
const apiPath = getApiPath('api/users')
const res = await fetchURL(apiPath, {
@@ -176,7 +180,7 @@ export async function update(user, which = ['all']) {
const excludeKeys = ['id', 'name']
// Filter out the keys from "which"
which = which.filter(item => !excludeKeys.includes(item))
if (user.username === 'publicUser') {
if (user.username === 'anonymous') {
return
}
const apiPath = getApiPath('api/users', { id: user.id })
@@ -190,7 +194,7 @@ export async function update(user, which = ['all']) {
})
}
export async function remove (id) {
export async function remove(id) {
try {
const apiPath = getApiPath('api/users', { id: id })
await fetchURL(apiPath, {
+2 -1
View File
@@ -1,6 +1,7 @@
import { state } from "@/store";
import { renew, logout } from "@/utils/auth";
import { notify } from "@/notify";
import { shareInfo } from "@/utils/constants";
export async function fetchURL(url, opts, auth = true) {
opts = opts || {};
@@ -34,7 +35,7 @@ export async function fetchURL(url, opts, auth = true) {
let error = new Error(await res.text());
error.status = res.status;
if (auth && res.status == 401) {
if (auth && res.status == 401 && !shareInfo.isShare) {
logout();
}
+5 -17
View File
@@ -49,14 +49,14 @@ export default {
return state.req;
},
multistate() {
if (!state.multiButtonState && getters.currentView() == "settings") {
return "close";
}
return state.multiButtonState;
return getters.multibuttonState();
},
stickSidebar() {
return state.stickSidebar;
return state.user?.stickySidebar;
},
currentView() {
return getters.currentView();
}
},
mounted() {
this.reEvalAction();
@@ -77,18 +77,6 @@ export default {
const currentView = getters.currentView();
if (currentView == "settings") {
mutations.setActiveSettingsView(getters.currentHash());
mutations.setMultiButtonState("close");
} else if (
currentView == "editor" ||
currentView == "preview" ||
currentView == "onlyOfficeEditor" ||
currentView == "markdownViewer" ||
currentView == "epubViewer" ||
currentView == "docViewer"
) {
mutations.setMultiButtonState("close");
} else {
mutations.setMultiButtonState("menu");
}
},
actionMultiIcon() {
+15 -11
View File
@@ -18,7 +18,7 @@
</router-link>
</li>
</ul>
<div v-if="isCardView" class="gallery-size">
<div v-if="showGallerySize" class="gallery-size card">
{{ $t("files.size") }}<span class="sr-only">:</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<input
v-model="gallerySize"
@@ -36,7 +36,7 @@
<script>
import { state, mutations, getters } from "@/store";
import { extractSourceFromPath, encodedPath } from "@/utils/url.js";
import { encodedPath } from "@/utils/url.js";
export default {
name: "breadcrumbs",
@@ -55,16 +55,22 @@ export default {
$route() {
this.updatePaths();
},
req() {
this.updatePaths();
},
},
computed: {
req() {
return state.req;
},
hasUpdate() {
return state.req.hasUpdate;
},
addPadding() {
return getters.isStickySidebar() || getters.currentView() == "share";
return getters.isStickySidebar() || getters.isShare();
},
isCardView() {
return getters.isCardView();
showGallerySize() {
return getters.isCardView() && state.req?.items?.length > 0;
},
items() {
const req = state.req;
@@ -106,15 +112,15 @@ export default {
},
methods: {
updatePaths() {
const result = extractSourceFromPath(getters.routePath());
if (getters.currentView() == "share") {
if (getters.isShare()) {
this.base = getters.sharePathBase();
this.path = getters.routePath(this.base);
} else {
this.path = decodeURIComponent(result.path);
this.path = encodedPath(state.req.path);
this.base = "/";
if (state.serverHasMultipleSources) {
this.base = `${this.base}${result.source}/`;
this.base = `/files/${state.req.source}/`;
}
}
},
@@ -220,10 +226,8 @@ export default {
.gallery-size {
display: flex;
background: var(--alt-background);
width: fit-content;
padding: 0.5em;
border-radius: 1em;
margin-bottom: 0.5em;
}
input[type="range"] {
+1 -4
View File
@@ -7,6 +7,7 @@
<button
v-for="(btn, index) in buttons"
:key="index"
class="clickable"
:class="{ active: activeButton === index }"
@click="setActiveButton(index, btn.value)"
>
@@ -114,10 +115,6 @@ button {
border-right: none;
}
button:hover {
background: #e0e0e0;
}
button:disabled {
cursor: not-allowed !important;
}
+153 -56
View File
@@ -10,54 +10,44 @@
ref="contextMenu"
v-if="showContext"
:style="centered ? {} : { top: posY + 'px', left: posX + 'px' }"
class="button no-select"
class="button no-select fb-shadow"
:class="{ 'dark-mode': isDarkMode, 'centered': centered }"
:key="showCreate ? 'create-mode' : 'normal-mode'"
>
<div v-if="selectedCount > 0" class="button selected-count-header">
<span>{{ selectedCount }} {{ $t("prompts.selected") }} </span>
</div>
<action
v-if="!showCreate && !isSearchActive && userPerms.modify"
v-if="!showCreate && !isSearchActive && userPerms.modify && !isShare"
icon="add"
:label="$t('buttons.new')"
@action="startShowCreate"
/>
<action
v-if="showCreate && !isSearchActive && userPerms.modify"
icon="create_new_folder"
:label="$t('sidebar.newFolder')"
@action="showHover('newDir')"
/>
<action
v-if="showCreate && userPerms.modify && !isSearchActive"
icon="note_add"
:label="$t('sidebar.newFile')"
@action="showHover('newFile')"
/>
<action
v-if="showCreate && userPerms.modify && !isSearchActive"
icon="file_upload"
:label="$t('buttons.upload')"
@action="uploadFunc"
/>
<action
v-if="!showCreate && selectedCount == 1"
icon="info"
:label="$t('buttons.info')"
show="info"
/>
<action
v-if="!isMultiple && !isSearchActive && isMobileDevice"
icon="check_circle"
:label="$t('buttons.selectMultiple')"
@action="toggleMultipleSelection"
/>
<action
v-if="(!showCreate && selectedCount > 0)"
icon="file_download"
@@ -69,13 +59,13 @@
v-if="selectedCount <= 1 && showShare"
icon="share"
:label="$t('buttons.share')"
show="share"
@action="showShareHover"
/>
<action
v-if="!showCreate && selectedCount == 1 && userPerms.modify && !isSearchActive"
icon="mode_edit"
:label="$t('buttons.rename')"
show="rename"
@action="showRenameHover"
/>
<action
v-if="!showCreate && selectedCount > 0 && userPerms.modify"
@@ -83,6 +73,12 @@
:label="$t('buttons.copyFile')"
show="copy"
/>
<action
v-if="!showCreate && selectedCount == 1 && isSearchActive"
icon="folder"
:label="$t('buttons.openParentFolder')"
@action="openParentFolder"
/>
<action
v-if="!showCreate && selectedCount > 0 && userPerms.modify"
icon="forward"
@@ -90,17 +86,23 @@
show="move"
/>
<action
v-if="!showCreate && selectedCount > 0 && userPerms.modify"
icon="file_upload"
:label="$t('buttons.upload')"
@action="showUpload"
/>
<action
v-if="!showCreate && selectedCount > 0 && userPerms.modify"
v-if="showDelete"
icon="delete"
:label="$t('buttons.delete')"
show="delete"
/>
<action
v-if="showAccess"
icon="lock"
:label="$t('access.rules')"
@action="showAccessHover"
/>
<action
v-if="showSelectMultiple"
icon="check_circle"
:label="$t('buttons.selectMultiple')"
@action="toggleMultipleSelection"
/>
</div>
</transition>
<transition
@@ -117,10 +119,11 @@
top: '3em',
right: '1em',
}"
class="button no-select"
class="button no-select fb-shadow"
:class="{ 'dark-mode': isDarkMode }"
>
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('buttons.openFile')" @action="goToRaw()" />
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
<action v-if="isPreview" icon="file_download" :label="$t('buttons.download')" @action="startDownload" />
<action v-if="showEdit" icon="edit" :label="$t('buttons.edit')" @action="edit()" />
<action v-if="showSave" icon="save" :label="$t('buttons.save')" @action="save()" />
@@ -137,7 +140,8 @@ import { onlyOfficeUrl } from "@/utils/constants.js";
import buttons from "@/utils/buttons";
import { notify } from "@/notify";
import { eventBus } from "@/store/eventBus";
import { filesApi } from "@/api";
import { filesApi, publicApi } from "@/api";
import { url } from "@/utils";
export default {
name: "ContextMenu",
components: {
@@ -159,21 +163,44 @@ export default {
},
},
computed: {
isMobileDevice() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || getters.isMobile();
isShare() {
return getters.isShare();
},
noItems() {
return !this.showEdit && !this.showSave && !this.showDelete;
showSelectMultiple() {
if (this.isMultiple || this.isSearchActive) {
return false;
}
if (state.user?.showSelectMultiple) {
return true;
}
if (getters.isMobile()) {
return true;
}
return false
},
hasOverflowItems() {
return this.showEdit || this.showDelete || this.showSave || this.showGoToRaw;
},
showGoToRaw() {
return getters.currentView() == "preview" ||
getters.currentView() == "markdownViewer"
const cv = getters.currentView();
return cv == "preview" || cv == "markdownViewer" || cv == "editor";
},
showEdit() {
return getters.currentView() == "markdownViewer" && state.user.permissions.modify;
const cv = getters.currentView();
if (getters.isShare()) {
// TODO: add support for editing shared files
return false;
}
return cv == "markdownViewer" && state.user?.permissions?.modify;
},
showDelete() {
return state.user.permissions.modify && this.isPreview;
const cv = getters.currentView();
if (getters.isShare() || !state.user?.permissions?.modify) {
// TODO: add support for deleting shared files
return false;
}
const showDelete = cv != "settings" && !this.showCreate && this.selectedCount > 0 && !this.isSearchActive;
return showDelete;
},
isPreview() {
const cv = getters.currentView();
@@ -182,23 +209,28 @@ export default {
cv == "onlyOfficeEditor" ||
cv == "markdownViewer" ||
cv == "epubViewer" ||
cv == "docViewer"
cv == "docViewer" ||
cv == "editor"
);
},
showSave() {
return getters.currentView() == "editor" && state.user.permissions.modify;
if (getters.isShare()) {
// TODO: add support for saving shared files
return false;
}
return getters.currentView() == "editor" && state.user?.permissions?.modify;
},
showOverflow() {
return getters.currentPromptName() == "OverflowMenu";
},
showAccess() {
return state.user?.permissions?.admin && this.showCreate;
},
showShare() {
return (
state.user?.permissions &&
state.user?.permissions.share &&
state.user.username != "publicUser" &&
getters.currentView() != "share" &&
!this.isSearchActive
);
if (getters.isShare()) {
return false;
}
return state.user?.permissions?.share;
},
showContext() {
return getters.currentPromptName() == "ContextMenu";
@@ -218,6 +250,9 @@ export default {
centered() {
return this.showCentered || this.isMobileDevice || !this.posX || !this.posY;
},
isMobileDevice() {
return state.isMobile;
},
isDarkMode() {
return getters.isDarkMode();
},
@@ -226,13 +261,30 @@ export default {
},
userPerms() {
return {
upload: state.user.permissions?.modify && state.selected.length > 0,
share: state.user.permissions.share,
modify: state.user.permissions.modify,
upload: state.user?.permissions?.modify && state.selected.length > 0,
share: state.user?.permissions?.share,
modify: state.user?.permissions?.modify,
};
},
currentPrompt() {
return getters.currentPrompt();
},
},
watch: {
currentPrompt: {
handler(prompt) {
if (prompt && prompt.name === "ContextMenu") {
this.setPositions();
}
},
deep: true,
},
hasOverflowItems: {
handler(hasItems) {
mutations.setContextMenuHasItems(hasItems);
},
immediate: true,
},
showContext: {
handler(newVal) {
if (newVal) {
@@ -254,6 +306,18 @@ export default {
}
},
methods: {
shouldShowParentFolder() {
return this.isPreview && state.req.path != "/";
},
showAccessHover() {
mutations.showHover({
name: "access",
props: {
sourceName: state.sources.current,
path: state.req?.path || "",
},
});
},
// Animation methods
beforeEnter(el) {
this.isAnimating = true;
@@ -317,6 +381,9 @@ export default {
}, 300);
},
startShowCreate() {
if (getters.isShare()) {
return;
}
this.showCreate = true;
},
uploadFunc() {
@@ -325,6 +392,22 @@ export default {
showHover(value) {
return mutations.showHover(value);
},
showShareHover() {
mutations.showHover({
name: "share",
props: {
item: getters.selectedCount() == 1 ? getters.getFirstSelected() : state.req
},
});
},
showRenameHover() {
mutations.showHover({
name: "rename",
props: {
item: getters.selectedCount() == 1 ? getters.getFirstSelected() : state.req
},
});
},
setPositions() {
const contextProps = getters.currentPrompt().props;
this.posX = contextProps.posX;
@@ -332,7 +415,7 @@ export default {
},
initializeCreateState() {
// Only set initial showCreate state, don't override user choices
if (state.selected.length > 0) {
if (state.selected.length > 0 || getters.isShare()) {
this.showCreate = false;
} else {
this.showCreate = true;
@@ -343,17 +426,24 @@ export default {
mutations.closeHovers();
},
startDownload() {
downloadFiles(state.selected);
mutations.closeHovers();
const items = state.selected.length > 0 ? state.selected : [state.req];
downloadFiles(items);
},
goToRaw() {
const downloadUrl = filesApi.getDownloadURL(
state.req.source,
state.req.path,
true,
false
);
window.open(downloadUrl, "_blank");
if (getters.isShare()) {
window.open(publicApi.getDownloadURL(state.share, state.req.path, true), "_blank");
mutations.closeHovers();
return;
}
const downloadUrl = filesApi.getDownloadURL(
state.req?.source || "",
state.req?.path || "",
true,
false
);
window.open(downloadUrl, "_blank");
mutations.closeHovers();
},
async edit() {
window.location.hash = "#edit";
@@ -367,7 +457,7 @@ export default {
notify.showSuccess("File Saved!");
} catch (e) {
buttons.done(button);
notify.showError("Error saving file: ", e);
notify.showError(`Error saving file: ${e}`);
}
mutations.closeHovers();
@@ -376,10 +466,18 @@ export default {
mutations.showHover({
name: "upload",
props: {
filesToReplace: state.selected.map((item) => item.name),
filesToReplace: state.selected.map((item) => item.name || ""),
},
});
},
openParentFolder() {
const item = state.selected.length > 0 ? state.selected[0] : state.req;
let parentPath = url.removeLastDir(item.path);
if (parentPath == "") {
parentPath = "/";
}
url.goToItem(state.req.source, parentPath);
},
},
};
</script>
@@ -396,7 +494,6 @@ export default {
display: flex;
flex-direction: column;
justify-content: center;
box-shadow: 0px 0px 15px 0px #404040;
}
#context-menu.centered {
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more