mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-13 20:51:59 -05:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09713b32a5 | ||
|
|
52e3c5c142 | ||
|
|
2af75311bd | ||
|
|
82c0182c22 | ||
|
|
c8f0b5abea | ||
|
|
d3d029d1e9 | ||
|
|
f39002f375 | ||
|
|
a658f100c0 | ||
|
|
c2d255a25e |
No files matched your search
@@ -1,6 +1,4 @@
|
||||
backend/**.db
|
||||
**.crt
|
||||
**.key
|
||||
backend/tmp
|
||||
backend/dist
|
||||
backend/backend*
|
||||
|
||||
@@ -80,12 +80,6 @@ jobs:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-no-config
|
||||
- name: settings
|
||||
dockerfile: ./_docker/Dockerfile.playwright-settings
|
||||
- name: screenshots
|
||||
dockerfile: ./_docker/Dockerfile.playwright-screenshots
|
||||
- name: previews
|
||||
dockerfile: ./_docker/Dockerfile.playwright-previews
|
||||
- name: jwt
|
||||
dockerfile: ./_docker/Dockerfile.playwright-jwt
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go test -race -v ./... -timeout 30s
|
||||
run: go test -race -v ./...
|
||||
lint-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/gtsteffaniak/playwright-base
|
||||
tags: chromium
|
||||
tags: latest
|
||||
- name: Build and push playwright-base
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
|
||||
@@ -18,7 +18,6 @@ frontend/tsconfig.tsbuildinfo
|
||||
/frontend/test-results
|
||||
/frontend/playwright-files
|
||||
/frontend/screenshots
|
||||
/frontend/generated
|
||||
/frontend/tests/playwright-files/demo*
|
||||
/frontend/loginAuth.json
|
||||
/backend/vendor
|
||||
|
||||
+3
-111
@@ -2,127 +2,19 @@
|
||||
|
||||
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).
|
||||
|
||||
## v1.3.2-beta
|
||||
|
||||
**Security**:
|
||||
- Patched Username Enumeration via Authentication Timing Side-Channel GHSA-7789-65hx-f26w
|
||||
|
||||
**New Features**:
|
||||
- Option in settings `userDefaults.preferEditorForMarkdown` to prefer editor first for Markdown files (#2136)
|
||||
- Copy to clipboard button for code blocks in Markdown Viewer (#2160)
|
||||
- Add "Last modified" filter in search dialog (#2157)
|
||||
|
||||
**Notes**:
|
||||
- docs preview for text and pdf has a 2 second timeout. If it hangs for whatever reason, the maximum time would be 2 seconds. (#2105) (#2114)
|
||||
- Downloading multiple files streams the archive creation rather than using cacheDir -- thanks @janakoram (#2125) (#2130)
|
||||
- `server.maxArchiveSizeGB` now defaults to 20 (GB) and only applies to archive/unarchive actions (not downloads).
|
||||
- browser download progress bar will no longer show for archive downloads. this is the main drawback to the streaming approach.
|
||||
- should allow for much higher parallel download support and lower cleanup maintenenance.
|
||||
- [docker] ffmpeg version upgraded to 8.1
|
||||
- remote ip in logs now prefers `X-Forwarded-For` if it exists, then `X-Real-IP`, then lastly the standard RemoteAddr. Useful when running behind a proxy to log the public IP of each request. (#2110)
|
||||
- changed loading spinner style to be more compatible with safari browsers.
|
||||
|
||||
**BugFixes**:
|
||||
- Wrong username in share settings (#2147) (#2148)
|
||||
- [OnlyOffice] Error when saving a file under a user scope #2133
|
||||
- Cannot edit shared file in OnlyOffice #2143
|
||||
- PWA install button disappeared (#2086)
|
||||
- Deleting a root folder was possible #2128
|
||||
- PUT resource api errors if action against a folder (#2153)
|
||||
- LDAP authentication issue if a password caontains @ symbol (#2154)
|
||||
- Share banner seems to be not working for custom urls (#2120)
|
||||
|
||||
|
||||
## v1.3.1-beta
|
||||
|
||||
**Security**:
|
||||
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse) GHSA-r633-fcgp-m532
|
||||
- Patched Incomplete Remediation of CVE-2026-27611: Password-Protected Share Bypass via /public/api/share/info GHSA-525j-95gf-766f
|
||||
|
||||
**New Features**:
|
||||
- More user options for settings (#2072) (#2067):
|
||||
- Option to disable thumbnails `userDefaults.preview.audio` and `userDefaults.preview.models` for Audio and 3D Models.
|
||||
- Option to disable files in the Tree navigation `userDefaults.hideFilesInTree`
|
||||
- Option to disable source files deletion when creating/extracting archives. `userDefaults.deleteAfterArchive`
|
||||
|
||||
**Notes**:
|
||||
- Share icon does not show in share listing or for shares for other users.
|
||||
- File Size Analyzer tool max items increased from 100 to 200.
|
||||
- changed symlink detection logic.
|
||||
|
||||
**BugFixes**:
|
||||
- 405 response code error on Webdav in 1.3.0-beta (#2054)
|
||||
- Motion Preview setting not saving when changed in profile settings.
|
||||
- Context menu on tools not working.
|
||||
|
||||
## v1.3.0-beta
|
||||
|
||||
Note: a potentially breaking change for docker users: \the default user is now "filebrowser" 1000:1000 instead of root.
|
||||
|
||||
**New Features**:
|
||||
- New Sidebar Features
|
||||
- Sidebar tree navigation (#2006) (#350)
|
||||
- Source usage to be customized to show os-reported values rather than calculated. This can be changed per source by editing the source link in the sidebar. (#1266) (#982)
|
||||
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
|
||||
- new api to archive/unarchive files on the server
|
||||
- requires `create` user permissions
|
||||
- archiving actions respect `server.maxArchiveSize`
|
||||
- Added share icon to items that are shared (#1420)
|
||||
- Authentication enhancements
|
||||
- LDAP login support with OIDC feature parity. (#591)
|
||||
- userGroup for OIDC and LDAP, only users in a group will get access. (#1964)
|
||||
- Add JWT token authentication support (#1364)
|
||||
- Enhanced thumbnail and item previews
|
||||
- Added ability to show "motion preview" for folders with multiple child items that have previews. cycles through the first 4 images.
|
||||
- support for reading embedded images from raw image or heic/heif files (#215)
|
||||
- reorganized and simplified thumbnail settings in profile settings (#1968)
|
||||
- removed `highQuality` thumbnail option which only affected gallery view. Now its always enabled.
|
||||
- improved caching for unsupported images, the same file won't be attempted again with the same modtime.
|
||||
- supports 3d model previews.
|
||||
- FileWatcher also supports watching directories
|
||||
- Support previews for 3D model files (STL, OBJ, 3MF, etc.) (#1273)
|
||||
- supported formats via threejs: GLTF, GLB, OBJ, STL, PLY, DAE (Collada), 3MF, 3DS, USDZ, USD, USDA, USDC, AMF, VRML, WRL, VTK, VTP, PCD, XYZ, VOX, KMZ
|
||||
- supports animations (for formats that contain them)
|
||||
- supports embedded textures, external neighboring file textures, or textures in `/textures` subdirectory
|
||||
- Enhanced prompts
|
||||
- All prompts have a taskbar with a close button
|
||||
- Prompts can be freely moved by dragging taskbar
|
||||
- Prompt styling has been updated
|
||||
- Clicking outside of prompts no longer automatically closes them.
|
||||
- add webdav support (#209) -- thanks to @reddac for (#1764)
|
||||
- see docs on how to use
|
||||
- requires api an un-customized api token as password
|
||||
- respects access rules
|
||||
- requires download permission to view and modify/create/delete permission to modify.
|
||||
|
||||
**Notes**:
|
||||
- Docker images default to `filebrowser` user instead of root
|
||||
- reorganized api routes
|
||||
- consolidated tags for swagger to be more accurately grouped
|
||||
- tools are all behind `/api/tools` routes
|
||||
- `/api/raw` is deprecated (but functional). The `/api/resources/download` route will be used instead.
|
||||
- `/api/preview` has been removed and replaced with `/api/resources/preview`
|
||||
- `/api/onlyoffice` have been replaced with `/api/office`
|
||||
- `/api/shares` has been moved to `/api/share/list`
|
||||
- `/api/auth/tokens` has been moved to `/api/auth/token/list` and `/api/auth/token` added to get specific token info
|
||||
- `PUT /api/token` has been moved to `POST /api/token`
|
||||
- `/public/api/shareinfo` has been moved to `/public/api/share/info`
|
||||
- `POST /resources/bulk/delete` api has been moved to `DELETE /resources/bulk` (#1984)
|
||||
|
||||
**BugFixes**:
|
||||
- Long folder names get cut off at top navigation bar (#1934)
|
||||
|
||||
## v1.2.2-stable
|
||||
|
||||
**Security**:
|
||||
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse)
|
||||
- Patched Incomplete Remediation of CVE-2026-27611: Password-Protected Share Bypass via /public/api/share/info GHSA-525j-95gf-766f
|
||||
|
||||
**New Features**:
|
||||
- "Divider" option in sidebar links to add a text or divider between links ()
|
||||
- "Divider" option in sidebar links to add a text or divider between links (#1875)
|
||||
- shares offer a "go to source Location" sidebar link and button when editing a share.
|
||||
|
||||
**Notes**:
|
||||
- Share edit/delete permissions are scoped to the user's shares rather than global (#2050)
|
||||
- OIDC group claims accepted as map (#2084)
|
||||
|
||||
**BugFixes**:
|
||||
- fixed the requirement that the database path needed to be set in the config file, now it loads `FILEBROWSER_DATABASE` value by default, fallback to config file property.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## Pinned
|
||||
|
||||
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
|
||||
:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
|
||||
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has a modern responsive interface that has many advanced features to manage users, access, sharing, and file preview and editing.
|
||||
|
||||
This version is called "Quantum" because it packs tons of advanced features into a tiny and easy-to-run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
|
||||
This version is called "Quantum" because it packs tons of advanced features into a tiny easy to run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
|
||||
|
||||
The goal for this repo is to become the best open-source self-hosted file browsing application that exists -- **all for free**. This repo will always be free and open-source.
|
||||
|
||||
@@ -35,20 +35,20 @@ Ready to try it out? See [Getting Started Docs](https://filebrowserquantum.com/e
|
||||
|
||||
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
|
||||
|
||||
1. ✅ Better source configuration - multiple sources, include/exclude rules, and [more](https://filebrowserquantum.com/en/docs/configuration/sources/)
|
||||
2. ✅ Login support for OIDC, LDAP, JWT, password + 2FA, and proxy.
|
||||
1. ✅ Add and configure multiple sources
|
||||
2. ✅ Login support for OIDC, password + 2FA, and proxy.
|
||||
3. ✅ Beautiful, Responsive, and Customizable user interface.
|
||||
4. ✅ Streamlined configuration via `config.yaml` config file.
|
||||
5. ✅ Ultra-efficient Sqlite [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing)
|
||||
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
|
||||
- Real-time search results as you type.
|
||||
- Real-time monitoring and updates in the UI.
|
||||
- Search supports file and folder sizes, along with various filters.
|
||||
6. ✅ Better listing browsing
|
||||
- Better thumbnail support including **office**, **video**, and **album artwork**, and **3D models**.
|
||||
- Better thumbnail support including **office**, **video**, and **album artwork**
|
||||
- Faster and more responsive views with animations.
|
||||
- Folder sizes are displayed and support thumbnails
|
||||
- Folder sizes are displayed and support for thumbnails
|
||||
- Navigating remembers the last scroll position.
|
||||
7. ✅ Highly configurable and [customizable sharing options](https://filebrowserquantum.com/en/docs/shares/options/)
|
||||
7. ✅ Highly configurable and customizable sharing options
|
||||
- share expiration time
|
||||
- users who can access share (including anonymous)
|
||||
- styling and themes
|
||||
@@ -59,10 +59,20 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
|
||||
- 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.
|
||||
- ❌ shell commands are completely removed and will not be returned.
|
||||
|
||||
FileBrowser Quantum differs significantly from the original version. Many of these changes required a significant overhaul. Creating a fork was a necessary process to make the program better. There have been many growing pains, but a stable release is planned and coming soon.
|
||||
|
||||
## System Requirements
|
||||
|
||||
> [!WARNING]
|
||||
> Every file and directory in the source gets indexed (by default). This enables powerful features such as instant search, but large source filesystems can increase your system requirements. [See indexing wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.
|
||||
|
||||
- **Memory**: depends on configured source complexity. See [how much RAM does it require?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
|
||||
- **GPU**: Not currently used (planned)
|
||||
|
||||
## The UI
|
||||
|
||||
The UI has a simple three-component navigation system:
|
||||
@@ -94,45 +104,47 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
|
||||
has standalone binary | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
price | free | free | free | free tier | free tier | $99+ |
|
||||
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Advanced Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Indexed Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Custom job support | :construction: | ✅ | ❌ | ✅ | ❌ | ✅ |
|
||||
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
LDAP sign-on support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
||||
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
|
||||
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
tags support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
Event-based notifications | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Metrics | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
file space quotas | :construction: | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
activity log | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Starred/pinned files | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
activity log | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
trash support | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can browse archive files | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Can convert documents | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Can convert videos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Can convert photos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Can archive selected files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
*Accessing files from FileBrowser via WebDav is a newly introduced and experimental feature.
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
FROM gtstef/ffmpeg:8.1-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
@@ -43,6 +43,7 @@ RUN [ "filebrowser", "version" ]
|
||||
RUN [ "ffmpeg", "-version" ]
|
||||
RUN [ "ffprobe", "-version" ]
|
||||
RUN [ "exiftool", "-ver" ]
|
||||
USER root
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
FROM node:22-slim
|
||||
WORKDIR /app/frontend
|
||||
|
||||
RUN npm i @playwright/test oauth2-mock-server @types/node
|
||||
RUN npx playwright install --with-deps chromium
|
||||
RUN npx playwright install --with-deps firefox
|
||||
@@ -14,9 +14,6 @@ COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# generate duplicate 1.1MB files
|
||||
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser set -u testuser1,testuser1
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
COPY [ "./_docker/src/jwt/", "./" ]
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/", "./" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -1,17 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/previews/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -1,15 +0,0 @@
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/", "./" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
|
||||
@@ -35,6 +35,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
USER root
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
|
||||
@@ -1,19 +1,4 @@
|
||||
services:
|
||||
local-playwright:
|
||||
container_name: local-playwright
|
||||
ports:
|
||||
- "8080:8080"
|
||||
build:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-local
|
||||
context: ../
|
||||
local-playwright-screenshots:
|
||||
container_name: local-playwright
|
||||
volumes:
|
||||
- ../frontend/generated:/app/frontend/generated
|
||||
entrypoint: ["/bin/sh", "-c", "./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots && npx playwright test --project light-screenshots"]
|
||||
build:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-local
|
||||
context: ../
|
||||
nginx-proxy-auth:
|
||||
image: nginx
|
||||
container_name: nginx-proxy-auth
|
||||
@@ -22,30 +7,11 @@ services:
|
||||
volumes:
|
||||
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
|
||||
nginx-proxy-jwt:
|
||||
image: nginx
|
||||
container_name: nginx-proxy-jwt
|
||||
ports:
|
||||
- "8081:8081" # Admin user
|
||||
- "8082:8082" # Regular user
|
||||
- "8083:8083" # Wrong key
|
||||
- "8084:8084" # No user
|
||||
volumes:
|
||||
- ./src/jwt/backend/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/jwt/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
|
||||
filebrowser-jwt:
|
||||
hostname: filebrowser
|
||||
volumes:
|
||||
- '../frontend:/home/frontend'
|
||||
- "./src/jwt/backend/config.yaml:/home/filebrowser/data/config.yaml"
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./_docker/Dockerfile.slim
|
||||
filebrowser:
|
||||
hostname: filebrowser
|
||||
volumes:
|
||||
- '../frontend:/home/frontend'
|
||||
- "./src/proxy/backend/config.yaml:/home/filebrowser/data/config.yaml"
|
||||
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./_docker/Dockerfile.slim
|
||||
@@ -8,10 +8,8 @@ userDefaults:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
models: true
|
||||
office: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
create: true
|
||||
@@ -1,48 +0,0 @@
|
||||
server:
|
||||
port: 8080
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: false
|
||||
jwt:
|
||||
enabled: true
|
||||
secret: "test-jwt-secret-key-for-development-only-do-not-use-in-production"
|
||||
algorithm: "HS256"
|
||||
header: "X-JWT-Assertion"
|
||||
userIdentifier: "sub"
|
||||
groupsClaim: "mygroup"
|
||||
adminGroup: "admin"
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: false
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: true
|
||||
api: false
|
||||
download: true
|
||||
create: true
|
||||
@@ -1,66 +0,0 @@
|
||||
# Admin user (port 8081)
|
||||
server {
|
||||
listen 8081;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
set $admin_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NTc2NiwiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNTc2NiwibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDU3NjYsInN1YiI6InRlc3RhZG1pbiJ9.pxuKGyMmEe94m0xQ7y1QzcUZgU-lZSvqjoKUVoVd3F4";
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion $admin_token;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
|
||||
# Regular user (port 8082)
|
||||
server {
|
||||
listen 8082;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
set $regular_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJleHAiOjIwODc1NjU2OTIsImdyb3VwcyI6WyJteWdyb3VwIl0sImlhdCI6MTc3MjIwNTY5MiwibmFtZSI6IlRlc3QgVXNlciIsIm5iZiI6MTc3MjIwNTY5Miwic3ViIjoidGVzdHVzZXIifQ.X8fUYbhnZpgvP5CnGdms9eGo8yRLyNhRbj7xUSxEgl8";
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion $regular_token;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
|
||||
# Valid token but signed with different secret (port 8083)
|
||||
server {
|
||||
listen 8083;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
# Valid JWT token structure, but signed with wrong secret - should fail signature verification
|
||||
set $wrong_key "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NjAyMywiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNjAyMywibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDYwMjMsInN1YiI6InRlc3RhZG1pbiJ9.afBobbJ3VQ-fKnUebGr-gxFIqegyICi6E3EgkUSeFNw";
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion $wrong_key;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
|
||||
# No authentication (port 8084)
|
||||
server {
|
||||
listen 8084;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
location / {
|
||||
proxy_set_header X-JWT-Assertion "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Simple JWT generator without external dependencies
|
||||
func main() {
|
||||
// Secret key - must match the one in your config file
|
||||
secret := "wrong-key"
|
||||
|
||||
// Check if custom secret provided via command line
|
||||
if len(os.Args) > 1 {
|
||||
secret = os.Args[1]
|
||||
}
|
||||
|
||||
// Create token claims with 10 year expiration
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(10 * 365 * 24 * time.Hour) // 10 years
|
||||
|
||||
header := map[string]interface{}{
|
||||
"alg": "HS256",
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "testadmin", // username (subject)
|
||||
"email": "testadmin@example.com", // email claim
|
||||
"name": "Test Admin", // full name
|
||||
"groups": []string{"admin"}, // groups - includes admin for testing
|
||||
"iat": now.Unix(), // issued at
|
||||
"exp": expiresAt.Unix(), // expires at
|
||||
"nbf": now.Unix(), // not before
|
||||
}
|
||||
|
||||
// Encode header
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error encoding header: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
|
||||
// Encode claims
|
||||
claimsJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error encoding claims: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
|
||||
// Create signature
|
||||
message := headerB64 + "." + claimsB64
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write([]byte(message))
|
||||
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Combine to form complete JWT
|
||||
tokenString := message + "." + signature
|
||||
|
||||
// Output the token
|
||||
fmt.Println("=== JWT Token Generated ===")
|
||||
fmt.Println()
|
||||
fmt.Println("Token:")
|
||||
fmt.Println(tokenString)
|
||||
fmt.Println()
|
||||
// Verify token can be decoded
|
||||
parts := strings.Split(tokenString, ".")
|
||||
if len(parts) == 3 {
|
||||
fmt.Println("✓ Token structure is valid (3 parts)")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "✗ Invalid token structure\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save to file for easy access
|
||||
filename := "test-jwt-token.txt"
|
||||
if err := os.WriteFile(filename, []byte(tokenString), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Could not write token to file: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✓ Token saved to: %s\n", filename)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Also save to shell script for easy sourcing
|
||||
scriptContent := fmt.Sprintf("#!/bin/bash\n# Source this file to set JWT token as environment variable\n# Usage: source jwt-env.sh\nexport TEST_JWT_TOKEN=\"%s\"\necho \"JWT token loaded into TEST_JWT_TOKEN\"\n", tokenString)
|
||||
if err := os.WriteFile("jwt-env.sh", []byte(scriptContent), 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Could not write shell script: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Shell script saved to: jwt-env.sh")
|
||||
fmt.Println(" Usage: source jwt-env.sh")
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module test
|
||||
|
||||
go 1.25.6
|
||||
@@ -1,66 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/jwt-setup.ts",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/jwt",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 5000,
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for different JWT test scenarios */
|
||||
projects: [
|
||||
{
|
||||
name: "admin-user",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8081", // Admin user JWT
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "regular-user",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8082", // Regular user JWT
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong-key",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8083", // Wrong secret key
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no-user",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
baseURL: "http://127.0.0.1:8084", // No JWT token
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
|
||||
auth:
|
||||
methods:
|
||||
noauth: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
|
||||
userDefaults:
|
||||
showHidden: true
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
office: true
|
||||
models: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
@@ -1,54 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/previews-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/previews",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
workers: 10,
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
|
||||
'--enable-webgl',
|
||||
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
|
||||
'--no-sandbox', // Required for Docker
|
||||
],
|
||||
},
|
||||
storageState: "previews.json",
|
||||
actionTimeout: 5000,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -11,14 +11,14 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/proxy-setup.ts",
|
||||
timeout: 10000,
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/proxy",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 3,
|
||||
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 */
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
@@ -1,69 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/screenshots-setup.ts",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/screenshots",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
|
||||
'--enable-webgl',
|
||||
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
|
||||
'--no-sandbox', // Required for Docker
|
||||
],
|
||||
},
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:8080",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "dark-screenshots",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
name: "light-screenshots",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -411,218 +409,3 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
t.Error("Directory 'Test Object' should have been added to Folders")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFilesRootProtection tests that DeleteFiles refuses to delete
|
||||
// the source root directory itself, preventing catastrophic data loss.
|
||||
// This is a regression test for the safety guard added to prevent accidental
|
||||
// deletion of the entire source directory.
|
||||
func TestDeleteFilesRootProtection(t *testing.T) {
|
||||
// Use a temporary directory for cache
|
||||
tmpDir := t.TempDir()
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = tmpDir
|
||||
defer func() {
|
||||
settings.Config.Server.CacheDir = originalCacheDir
|
||||
}()
|
||||
|
||||
// Ensure fileutils permissions are set
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Initialize the database
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_root_protection", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
// Create a real temporary directory to use as source root
|
||||
realTmpDir := t.TempDir()
|
||||
|
||||
// Initialize the index with a real path
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test_root_protection",
|
||||
Path: realTmpDir,
|
||||
}, false, false) // false for mock mode (real path), false for isNewDb
|
||||
|
||||
// Wait for scanner to be ready
|
||||
idx := indexing.GetIndex("test_root_protection")
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Wait for scanner to finish
|
||||
for i := 0; i < 50; i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
status := idx.GetScannerStatus()
|
||||
if status["status"] == "ready" || status["status"] == "unavailable" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Attempting to delete the root directory should return an error
|
||||
err := DeleteFiles("test_root_protection", realTmpDir, true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error when trying to delete root directory")
|
||||
}
|
||||
|
||||
// Verify the error message contains expected text
|
||||
expectedErrMsg := "refusing to delete source root directory"
|
||||
if err != nil && !strings.Contains(err.Error(), expectedErrMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got: %v", expectedErrMsg, err.Error())
|
||||
}
|
||||
|
||||
// Test with trailing slash - should still be blocked
|
||||
err = DeleteFiles("test_root_protection", realTmpDir+"/", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error for root directory with trailing slash")
|
||||
}
|
||||
|
||||
// Test with cleaned path variations
|
||||
err = DeleteFiles("test_root_protection", realTmpDir+"/.", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error for root directory with /. suffix")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFilesSubfolderWithRootName tests that deleting a subfolder
|
||||
// with the same name as the root directory works correctly and doesn't
|
||||
// accidentally delete the root.
|
||||
// Regression test for path handling bug where /srv/srv could be mistaken for /srv.
|
||||
func TestDeleteFilesSubfolderWithRootName(t *testing.T) {
|
||||
for iter := 1; iter <= raceStressIterations; iter++ {
|
||||
iter := iter
|
||||
t.Run(fmt.Sprintf("stress-iter-%d", iter), func(t *testing.T) {
|
||||
runDeleteFilesSubfolderWithRaceStress(t, iter)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
raceStressIterations = 3
|
||||
raceStatusPollInterval = 5 * time.Millisecond
|
||||
waitStatusMaxTries = 100
|
||||
waitStatusSleep = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
|
||||
t.Helper()
|
||||
start := time.Now()
|
||||
t.Logf("iteration %d: start", iter)
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
originalCache := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
|
||||
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_subfolder_rootname", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
realTmpDir := t.TempDir()
|
||||
subfolderName := filepath.Base(realTmpDir)
|
||||
subfolderPath := filepath.Join(realTmpDir, subfolderName)
|
||||
|
||||
if err := os.Mkdir(subfolderPath, 0755); err != nil {
|
||||
t.Fatalf("Failed to create subfolder: %v", err)
|
||||
}
|
||||
|
||||
testFile := filepath.Join(subfolderPath, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
sourceName := fmt.Sprintf("test_subfolder_rootname-%d", iter)
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: sourceName,
|
||||
Path: realTmpDir,
|
||||
}, false, false)
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatalf("Failed to get test index for %s", sourceName)
|
||||
}
|
||||
|
||||
stopPolling := startScannerStatusPoll(t, idx, iter)
|
||||
defer stopPolling()
|
||||
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
err := DeleteFiles(sourceName, subfolderPath, true)
|
||||
stopPolling()
|
||||
|
||||
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteFiles should succeed for subfolder with root-like name, got error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(subfolderPath); !os.IsNotExist(err) {
|
||||
t.Fatal("Subfolder should have been deleted")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(realTmpDir); os.IsNotExist(err) {
|
||||
t.Fatal("Root directory should NOT have been deleted")
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
// Give scanners time to finish their cleanup (defer blocks in tryAcquireAndScan)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
t.Logf("iteration %d: cleaned scanners and indices", iter)
|
||||
}
|
||||
|
||||
func waitForScannerReady(t *testing.T, idx *indexing.Index) {
|
||||
t.Helper()
|
||||
// Give the scanners a moment to initialize, but don't wait for full "ready" status
|
||||
// The initial scan can take longer than the test timeout, but DeleteFiles will work
|
||||
// as long as the scanners are initialized and running
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify scanners exist
|
||||
status := idx.GetScannerStatus()
|
||||
if totalScanners, ok := status["totalScanners"].(int); !ok || totalScanners == 0 {
|
||||
t.Fatal("No scanners were created")
|
||||
}
|
||||
t.Log("Scanners initialized, proceeding with delete operation")
|
||||
}
|
||||
|
||||
func startScannerStatusPoll(t *testing.T, idx *indexing.Index, iter int) func() {
|
||||
t.Helper()
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(raceStatusPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
close(done)
|
||||
return
|
||||
case <-ticker.C:
|
||||
idx.GetScannerStatus()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
close(stop)
|
||||
<-done
|
||||
t.Logf("iteration %d: poll watcher stopped", iter)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,6 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
|
||||
var CheckPermissionsFunc = checkPermissionsImpl
|
||||
|
||||
// processDirectoryMetadata extracts metadata for audio/video files in directories
|
||||
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
|
||||
metadataCount := 0
|
||||
@@ -119,41 +116,35 @@ func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileIn
|
||||
}
|
||||
}
|
||||
|
||||
// CheckPermissions validates user access and returns the resolved index path and user scope
|
||||
func CheckPermissions(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
return CheckPermissionsFunc(opts, access, user)
|
||||
}
|
||||
|
||||
// checkPermissionsImpl is the actual implementation of CheckPermissions
|
||||
func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
if access == nil {
|
||||
return "", "", fmt.Errorf("access not provided")
|
||||
return response, fmt.Errorf("access not provided")
|
||||
}
|
||||
if user == nil {
|
||||
return "", "", fmt.Errorf("user not provided")
|
||||
return response, fmt.Errorf("user not provided")
|
||||
}
|
||||
if opts.Path == "" {
|
||||
return "", "", fmt.Errorf("path not provided")
|
||||
return response, fmt.Errorf("path not provided")
|
||||
}
|
||||
if opts.Source == "" {
|
||||
return "", "", fmt.Errorf("source not provided")
|
||||
return response, fmt.Errorf("source not provided")
|
||||
}
|
||||
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return "", "", fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
|
||||
// Resolve user scope
|
||||
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
|
||||
if scopeErr != nil || userScope == "" {
|
||||
return "", "", fmt.Errorf("user has no access to source: %v", opts.Source)
|
||||
return response, fmt.Errorf("user has no access to source: %v", opts.Source)
|
||||
}
|
||||
|
||||
safePath, err := utils.SanitizeUserPath(opts.Path)
|
||||
if err != nil {
|
||||
return "", "", errors.ErrAccessDenied
|
||||
return response, errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Combine scope + sanitized path
|
||||
@@ -161,84 +152,17 @@ func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *
|
||||
// Layer 1: USER ACCESS CONTROL
|
||||
// Quick check: Does THIS user have permission?
|
||||
if !access.Permitted(idx.Path, indexPath, user.Username) {
|
||||
return "", "", errors.ErrAccessDenied
|
||||
}
|
||||
return indexPath, userScope, nil
|
||||
}
|
||||
|
||||
type Items struct {
|
||||
Files []string `json:"files,omitempty"`
|
||||
Folders []string `json:"folders,omitempty"`
|
||||
}
|
||||
|
||||
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
|
||||
items := Items{}
|
||||
indexPath, _, err := CheckPermissions(opts, access, user)
|
||||
if err != nil {
|
||||
return items, err
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return items, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
return response, errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
Expand: true,
|
||||
SkipExtendedAttrs: true,
|
||||
})
|
||||
if err != nil {
|
||||
return items, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
if info.Type != "directory" {
|
||||
return items, fmt.Errorf("path is not a directory: %v ", indexPath)
|
||||
}
|
||||
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
|
||||
return items, err
|
||||
}
|
||||
if opts.Only == "files" || opts.Only == "" {
|
||||
for _, file := range info.Files {
|
||||
items.Files = append(items.Files, file.Name)
|
||||
}
|
||||
}
|
||||
if opts.Only == "folders" || opts.Only == "" {
|
||||
for _, folder := range info.Folders {
|
||||
items.Folders = append(items.Folders, folder.Name)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// FileInfoFasterFunc is a variable that can be mocked in tests
|
||||
var FileInfoFasterFunc = fileInfoFasterImpl
|
||||
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return FileInfoFasterFunc(opts, access, user, share)
|
||||
}
|
||||
|
||||
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
|
||||
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
indexPath, userScope, err := CheckPermissions(opts, access, user)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
// Layer 2: INDEX RULES (global)
|
||||
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
|
||||
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
Expand: opts.Expand,
|
||||
SkipExtendedAttrs: opts.SkipExtendedAttrs,
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
Expand: opts.Expand,
|
||||
IsRoutineScan: false, // API call
|
||||
})
|
||||
if err != nil {
|
||||
return response, err // Path excluded by index rules OR doesn't exist
|
||||
@@ -252,34 +176,25 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
|
||||
// Layer 3: FILTER CHILDREN (user access)
|
||||
// Remove child items THIS user can't access
|
||||
if info.Type == "directory" {
|
||||
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
|
||||
if err := access.CheckChildItemAccess(response, idx, user.Username); err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
if share != nil && user.Permissions.Share && opts.ShowSharedAttr {
|
||||
for i := range response.Files {
|
||||
file := &response.Files[i]
|
||||
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path, user.ID)
|
||||
}
|
||||
for i := range response.Folders {
|
||||
folder := &response.Folders[i]
|
||||
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path, user.ID)
|
||||
}
|
||||
response.IsShared = share.IsShared(response.Path, idx.Path, user.ID)
|
||||
}
|
||||
defer finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
if opts.SkipExtendedAttrs {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Process directory metadata if requested
|
||||
if info.Type == "directory" {
|
||||
processDirectoryMetadata(response, idx, opts)
|
||||
}
|
||||
|
||||
// Process single file content/metadata
|
||||
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
|
||||
if opts.Content || opts.Metadata || isAudioVideo {
|
||||
processContent(response, idx, opts)
|
||||
}
|
||||
|
||||
// Finalize response (OnlyOffice ID, scope stripping)
|
||||
finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -457,11 +372,6 @@ func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
if index == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
// Safety guard: never allow deletion of the source root directory itself
|
||||
cleanAbs := filepath.Clean(absPath)
|
||||
if cleanAbs == filepath.Clean(index.Path) {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
|
||||
}
|
||||
|
||||
if !index.Config.DisableIndexing {
|
||||
indexPath := index.MakeIndexPath(absPath, isDir)
|
||||
|
||||
@@ -149,10 +149,7 @@ var types = map[string]string{
|
||||
".es": "application/x-esrehber",
|
||||
".etx": "text/x-setext",
|
||||
".evy": "application/envoy",
|
||||
".exe": "application/x-msdownload",
|
||||
".dll": "application/x-msdownload",
|
||||
".bat": "application/x-msdownload",
|
||||
".msi": "application/x-msdownload",
|
||||
".exe": "application/octet-stream",
|
||||
".f77": "text/x-fortran",
|
||||
".f90": "text/x-fortran",
|
||||
".f": "text/x-fortran",
|
||||
@@ -246,8 +243,8 @@ var types = map[string]string{
|
||||
".la": "audio/nspaudio",
|
||||
".lam": "audio/x-liveaudio",
|
||||
".latex": "application/x-latex",
|
||||
".lha": "application/x-lzh-compressed",
|
||||
".lhx": "application/x-lzh-compressed",
|
||||
".lha": "application/lha",
|
||||
".lhx": "application/octet-stream",
|
||||
".list": ContentTextHeaderValue,
|
||||
".lma": "audio/nspaudio",
|
||||
".log": ContentTextHeaderValue,
|
||||
@@ -260,7 +257,7 @@ var types = map[string]string{
|
||||
".m1v": "video/mpeg",
|
||||
".m2a": "audio/mpeg",
|
||||
".m2v": "video/mpeg",
|
||||
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
|
||||
".m3u": "audio/x-mpegurl",
|
||||
".m": "text/x-m",
|
||||
".man": "application/x-troff-man",
|
||||
".manifest": "text/cache-manifest",
|
||||
@@ -378,7 +375,7 @@ var types = map[string]string{
|
||||
".pre": "application/x-freelance",
|
||||
".prt": "application/pro_eng",
|
||||
".ps": "application/postscript",
|
||||
".psd": "image/vnd.adobe.photoshop",
|
||||
".psd": "application/octet-stream",
|
||||
".pvu": "paleovu/x-pv",
|
||||
".pwz": "application/vndms-powerpoint",
|
||||
".py": "text/x-scriptphyton",
|
||||
@@ -600,63 +597,11 @@ var types = map[string]string{
|
||||
".ogx": "application/ogg",
|
||||
".m4v": "video/mp4",
|
||||
".mkv": "video/x-matroska",
|
||||
".webm": "video/webm",
|
||||
".weba": "audio/webm",
|
||||
".caf": "audio/x-caf",
|
||||
".m3u8": "text/x-mpegURL",
|
||||
".m3u8": "application/x-mpegURL",
|
||||
".mpd": "application/dash+xml",
|
||||
".webp": "image/webp",
|
||||
".epub": "application/epub+zip",
|
||||
// the ones below are some from: https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
|
||||
".adp": "audio/adpcm",
|
||||
".mp4a": "audio/mp4",
|
||||
".mp2a": "audio/mpeg",
|
||||
".m3a": "audio/mpeg",
|
||||
".sil": "audio/silk",
|
||||
".uva": "audio/vnd.dece.audio",
|
||||
".uvva": "audio/vnd.dece.audio",
|
||||
".eol": "audio/vnd.digital-winds",
|
||||
".dra": "audio/vnd.dra",
|
||||
".dts": "audio/vnd.dts",
|
||||
".dtshd": "audio/vnd.dts.hd",
|
||||
".lvp": "audio/vnd.lucent.voice",
|
||||
".pya": "audio/vnd.ms-playready.media.pya",
|
||||
".rip": "audio/vnd.rip",
|
||||
".mka": "audio/x-matroska",
|
||||
".h261": "video/h261",
|
||||
".h263": "video/h263",
|
||||
".h264": "video/h264",
|
||||
".jpgv": "video/jpeg",
|
||||
".jpm": "video/jpm",
|
||||
".jpgm": "video/jpm",
|
||||
".mj2": "video/mj2",
|
||||
".mjp2": "video/mj2",
|
||||
".m2t": "video/mp2t",
|
||||
".m2ts": "video/mp2t",
|
||||
".mts": "video/mp2t",
|
||||
".mp4v": "video/mp4",
|
||||
".mpg4": "video/mp4",
|
||||
".uvh": "video/vnd.dece.hd",
|
||||
".uvvh": "video/vnd.dece.hd",
|
||||
".uvm": "video/vnd.dece.mobile",
|
||||
".uvvm": "video/vnd.dece.mobile",
|
||||
".uvp": "video/vnd.dece.pd",
|
||||
".uvvp": "video/vnd.dece.pd",
|
||||
".uvs": "video/vnd.dece.sd",
|
||||
".uvvs": "video/vnd.dece.sd",
|
||||
".uvv": "video/vnd.dece.video",
|
||||
".uvvv": "video/vnd.dece.video",
|
||||
".dvb": "video/vnd.dvb.file",
|
||||
".fvt": "video/vnd.fvt",
|
||||
".mxu": "video/vnd.mpegurl",
|
||||
".pyv": "video/vnd.ms-playready.media.pyv",
|
||||
".uvu": "video/vnd.uvvu.mp4",
|
||||
".uvvu": "video/vnd.uvvu.mp4",
|
||||
".f4v": "video/x-f4v",
|
||||
".mk3d": "video/x-matroska",
|
||||
".mks": "video/x-matroska",
|
||||
".vob": "video/x-ms-vob",
|
||||
".smv": "video/x-smv",
|
||||
}
|
||||
|
||||
//nolint:gochecknoinits
|
||||
|
||||
@@ -29,16 +29,3 @@ func GetFreeSpace(path string) (uint64, error) {
|
||||
free := uint64(stat.Bavail) * uint64(stat.Bsize)
|
||||
return free, nil
|
||||
}
|
||||
|
||||
// GetPartitionUsed returns the used space for Unix systems (total - free)
|
||||
func GetPartitionUsed(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := uint64(stat.Blocks) * uint64(stat.Bsize)
|
||||
free := uint64(stat.Bavail) * uint64(stat.Bsize)
|
||||
used := total - free
|
||||
return used, nil
|
||||
}
|
||||
@@ -33,18 +33,3 @@ func GetFreeSpace(path string) (uint64, error) {
|
||||
}
|
||||
return freeBytes, nil
|
||||
}
|
||||
|
||||
// GetPartitionUsed returns the used space for Windows systems (total - free)
|
||||
func GetPartitionUsed(path string) (uint64, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var freeBytes, totalBytes, totalFreeBytes uint64
|
||||
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
used := totalBytes - freeBytes
|
||||
return used, nil
|
||||
}
|
||||
+17
-48
@@ -1,65 +1,34 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"sync"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
var (
|
||||
revokedApiKeyList map[string]struct{}
|
||||
revokeMu sync.Mutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
revokedApiKeyList = make(map[string]struct{})
|
||||
}
|
||||
|
||||
// Auther is the authentication interface.
|
||||
type Auther interface {
|
||||
// Auth is called to authenticate a request.
|
||||
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
|
||||
}
|
||||
|
||||
const FB_ISSUER = "FileBrowser Quantum" // don't change
|
||||
|
||||
// IsRevokedApiToken checks if a token is in the revoked list.
|
||||
func IsRevokedApiToken(accessStore *access.Storage, token string) bool {
|
||||
if accessStore == nil {
|
||||
return false
|
||||
}
|
||||
return accessStore.IsTokenRevoked(token)
|
||||
func IsRevokedApiKey(key string) bool {
|
||||
_, exists := revokedApiKeyList[key]
|
||||
return exists
|
||||
}
|
||||
|
||||
// RevokeApiToken adds a token to the revoked list.
|
||||
func RevokeApiToken(accessStore *access.Storage, token string) error {
|
||||
if accessStore == nil {
|
||||
return fmt.Errorf("access storage not available")
|
||||
}
|
||||
return accessStore.RevokeToken(token)
|
||||
}
|
||||
|
||||
func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions, minimal bool) (string, users.AuthToken, error) {
|
||||
if _, ok := user.Tokens[name]; ok {
|
||||
return "", users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
|
||||
}
|
||||
now := time.Now()
|
||||
expires := now.Add(duration)
|
||||
// Create minimal token with only JWT standard claims
|
||||
claim := users.AuthToken{
|
||||
MinimalAuthToken: users.MinimalAuthToken{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expires),
|
||||
Issuer: FB_ISSUER,
|
||||
},
|
||||
},
|
||||
}
|
||||
if !minimal {
|
||||
claim.Permissions = perms
|
||||
claim.BelongsTo = user.ID
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
|
||||
tokenString, err := token.SignedString([]byte(settings.Config.Auth.Key))
|
||||
if err != nil {
|
||||
return "", users.AuthToken{}, err
|
||||
}
|
||||
return tokenString, claim, nil
|
||||
|
||||
func RevokeAPIKey(key string) {
|
||||
revokeMu.Lock()
|
||||
defer revokeMu.Unlock()
|
||||
revokedApiKeyList[key] = struct{}{}
|
||||
}
|
||||
+5
-12
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
@@ -43,21 +42,15 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
|
||||
}
|
||||
}
|
||||
|
||||
user, getErr := userStore.Get(username)
|
||||
var passwordHash string
|
||||
if getErr != nil {
|
||||
passwordHash = utils.InvalidPasswordHash
|
||||
} else {
|
||||
passwordHash = user.Password
|
||||
user, err := userStore.Get(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
}
|
||||
// always run checkPwd to prevent timing attacks
|
||||
err = utils.CheckPwd(password, passwordHash)
|
||||
err = users.CheckPwd(password, user.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
}
|
||||
|
||||
// check for OTP for password
|
||||
if user.TOTPSecret != "" {
|
||||
if totpCode == "" {
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
// Package auth_test contains timing attack tests for authentication.
|
||||
//
|
||||
// These tests verify that the JSON authentication implementation is protected
|
||||
// against timing attacks that could leak information about valid usernames.
|
||||
//
|
||||
// Timing Attack Background:
|
||||
// A timing attack is a side-channel attack where an attacker measures how long
|
||||
// authentication operations take to determine if a username exists in the system.
|
||||
// If authentication fails faster for non-existent users than for valid users
|
||||
// with wrong passwords, an attacker can enumerate valid usernames.
|
||||
//
|
||||
// Protection Mechanism:
|
||||
// The json.go Auth() implementation protects against timing attacks by:
|
||||
// 1. Always running the bcrypt password comparison, even for non-existent users
|
||||
// 2. Using utils.InvalidPasswordHash (a pre-computed bcrypt hash) when the user doesn't exist
|
||||
// 3. Ensuring both valid and invalid user authentication paths take similar time
|
||||
//
|
||||
// Test Coverage:
|
||||
// - TestJSONAuth_NoTimingAttack: Statistical analysis of timing differences
|
||||
// - TestJSONAuth_InvalidPasswordHashInitialized: Verifies protection is in place
|
||||
// - TestJSONAuth_ValidUserVsInvalidUserDirectComparison: Direct comparison test
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// mockUserBackend is a simple in-memory user storage for testing.
|
||||
type mockUserBackend struct {
|
||||
usersByUsername map[string]*users.User
|
||||
usersByID map[uint]*users.User
|
||||
}
|
||||
|
||||
func newMockUserBackend() *mockUserBackend {
|
||||
return &mockUserBackend{
|
||||
usersByUsername: make(map[string]*users.User),
|
||||
usersByID: make(map[uint]*users.User),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) GetBy(id interface{}) (*users.User, error) {
|
||||
switch v := id.(type) {
|
||||
case string:
|
||||
if user, ok := m.usersByUsername[v]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, fmt.Errorf("user not found: %s", v)
|
||||
case uint:
|
||||
if user, ok := m.usersByID[v]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, fmt.Errorf("user not found: %d", v)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid id type")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) Gets() ([]*users.User, error) {
|
||||
result := make([]*users.User, 0, len(m.usersByID))
|
||||
for _, user := range m.usersByID {
|
||||
result = append(result, user)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) Save(u *users.User, changePass bool, disableScopeChange bool) error {
|
||||
m.usersByUsername[u.Username] = u
|
||||
m.usersByID[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) Update(u *users.User, adminActor bool, fields ...string) error {
|
||||
m.usersByUsername[u.Username] = u
|
||||
m.usersByID[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) DeleteByID(id uint) error {
|
||||
if user, ok := m.usersByID[id]; ok {
|
||||
delete(m.usersByUsername, user.Username)
|
||||
delete(m.usersByID, id)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user not found: %d", id)
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) DeleteByUsername(username string) error {
|
||||
if user, ok := m.usersByUsername[username]; ok {
|
||||
delete(m.usersByID, user.ID)
|
||||
delete(m.usersByUsername, username)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
// setupTestUsers creates a user storage with 2 valid test users
|
||||
func setupTestUsers(t *testing.T) *users.Storage {
|
||||
t.Helper()
|
||||
|
||||
// Reduce bcrypt cost for faster tests (default is 10, min is 4)
|
||||
// This significantly speeds up password hashing during tests
|
||||
originalCost := utils.BcryptCost
|
||||
utils.BcryptCost = 4 // Minimum bcrypt cost for testing
|
||||
t.Cleanup(func() {
|
||||
utils.BcryptCost = originalCost // Restore original cost after test
|
||||
})
|
||||
|
||||
// Initialize InvalidPasswordHash to prevent timing attacks
|
||||
// This simulates the application startup initialization
|
||||
err := utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set invalid password hash: %v", err)
|
||||
}
|
||||
|
||||
backend := newMockUserBackend()
|
||||
storage := users.NewStorage(backend)
|
||||
|
||||
// Create two valid users with hashed passwords
|
||||
password1Hash, err := utils.HashPwd("password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
password2Hash, err := utils.HashPwd("securepass456")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
user1 := &users.User{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password1Hash,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
|
||||
user2 := &users.User{
|
||||
ID: 2,
|
||||
Username: "testuser",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password2Hash,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
|
||||
err = storage.Save(user1, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save user1: %v", err)
|
||||
}
|
||||
|
||||
err = storage.Save(user2, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save user2: %v", err)
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// measureAuthTime measures the time taken to authenticate with given credentials
|
||||
func measureAuthTime(t *testing.T, auther auth.JSONAuth, userStore *users.Storage, username, password string) time.Duration {
|
||||
t.Helper()
|
||||
|
||||
// Create a mock HTTP request
|
||||
req, err := http.NewRequest("POST", "/api/auth/login?username="+url.QueryEscape(username), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Password", url.QueryEscape(password))
|
||||
|
||||
start := time.Now()
|
||||
_, _ = auther.Auth(req, userStore)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
return elapsed
|
||||
}
|
||||
|
||||
// TestJSONAuth_NoTimingAttack tests that authentication timing does not leak user existence
|
||||
func TestJSONAuth_NoTimingAttack(t *testing.T) {
|
||||
storage := setupTestUsers(t)
|
||||
auther := auth.JSONAuth{}
|
||||
|
||||
// Test cases: mix of valid and invalid usernames
|
||||
testCases := []struct {
|
||||
username string
|
||||
isValid bool
|
||||
}{
|
||||
{"admin", true},
|
||||
{"testuser", true},
|
||||
{"nonexistent1", false},
|
||||
{"invaliduser", false},
|
||||
{"notauser", false},
|
||||
{"fakeadmin", false},
|
||||
}
|
||||
|
||||
// Number of samples per test case for statistical analysis
|
||||
samplesPerCase := 5
|
||||
|
||||
// Collect timing measurements
|
||||
measurements := make(map[string][]time.Duration)
|
||||
for _, tc := range testCases {
|
||||
measurements[tc.username] = make([]time.Duration, 0, samplesPerCase)
|
||||
}
|
||||
|
||||
// Run multiple iterations to gather statistical data
|
||||
for i := 0; i < samplesPerCase; i++ {
|
||||
// Randomize order to avoid cache effects
|
||||
for _, tc := range testCases {
|
||||
elapsed := measureAuthTime(t, auther, storage, tc.username, "wrongpassword123")
|
||||
measurements[tc.username] = append(measurements[tc.username], elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate statistics for each test case
|
||||
type stats struct {
|
||||
mean time.Duration
|
||||
stddev time.Duration
|
||||
min time.Duration
|
||||
max time.Duration
|
||||
}
|
||||
|
||||
calculateStats := func(durations []time.Duration) stats {
|
||||
if len(durations) == 0 {
|
||||
return stats{}
|
||||
}
|
||||
|
||||
// Calculate mean
|
||||
var sum time.Duration
|
||||
for _, d := range durations {
|
||||
sum += d
|
||||
}
|
||||
mean := sum / time.Duration(len(durations))
|
||||
|
||||
// Calculate standard deviation
|
||||
var variance float64
|
||||
for _, d := range durations {
|
||||
diff := float64(d - mean)
|
||||
variance += diff * diff
|
||||
}
|
||||
variance /= float64(len(durations))
|
||||
stddev := time.Duration(math.Sqrt(variance))
|
||||
|
||||
// Find min and max
|
||||
min := durations[0]
|
||||
max := durations[0]
|
||||
for _, d := range durations {
|
||||
if d < min {
|
||||
min = d
|
||||
}
|
||||
if d > max {
|
||||
max = d
|
||||
}
|
||||
}
|
||||
|
||||
return stats{mean: mean, stddev: stddev, min: min, max: max}
|
||||
}
|
||||
|
||||
// Analyze results
|
||||
validUserStats := make([]stats, 0)
|
||||
invalidUserStats := make([]stats, 0)
|
||||
|
||||
t.Log("\n=== Timing Analysis Results ===")
|
||||
for _, tc := range testCases {
|
||||
s := calculateStats(measurements[tc.username])
|
||||
t.Logf("%s (valid=%v): mean=%.4fms, stddev=%.4fms, min=%.4fms, max=%.4fms",
|
||||
tc.username, tc.isValid,
|
||||
float64(s.mean.Microseconds())/1000.0,
|
||||
float64(s.stddev.Microseconds())/1000.0,
|
||||
float64(s.min.Microseconds())/1000.0,
|
||||
float64(s.max.Microseconds())/1000.0)
|
||||
|
||||
if tc.isValid {
|
||||
validUserStats = append(validUserStats, s)
|
||||
} else {
|
||||
invalidUserStats = append(invalidUserStats, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall statistics for valid vs invalid users
|
||||
var validMeanSum, invalidMeanSum time.Duration
|
||||
for _, s := range validUserStats {
|
||||
validMeanSum += s.mean
|
||||
}
|
||||
for _, s := range invalidUserStats {
|
||||
invalidMeanSum += s.mean
|
||||
}
|
||||
|
||||
validAvgMean := validMeanSum / time.Duration(len(validUserStats))
|
||||
invalidAvgMean := invalidMeanSum / time.Duration(len(invalidUserStats))
|
||||
|
||||
t.Logf("\n=== Summary ===")
|
||||
t.Logf("Valid users average: %.4fms", float64(validAvgMean.Microseconds())/1000.0)
|
||||
t.Logf("Invalid users average: %.4fms", float64(invalidAvgMean.Microseconds())/1000.0)
|
||||
|
||||
// Calculate the percentage difference
|
||||
diff := float64(validAvgMean - invalidAvgMean)
|
||||
percentDiff := math.Abs(diff) / float64(validAvgMean) * 100.0
|
||||
t.Logf("Difference: %.4fms (%.2f%%)", diff/1e6, percentDiff)
|
||||
|
||||
// Assert: The timing difference should be less than 20%
|
||||
// Bcrypt's constant-time comparison should make both cases take similar time
|
||||
// We allow 20% threshold to account for system noise and variability
|
||||
threshold := 20.0 // percent
|
||||
if percentDiff > threshold {
|
||||
t.Errorf("TIMING ATTACK VULNERABILITY DETECTED: Valid vs invalid user timing differs by %.2f%% (threshold: %.2f%%)",
|
||||
percentDiff, threshold)
|
||||
t.Errorf("This difference could allow an attacker to enumerate valid usernames!")
|
||||
} else {
|
||||
t.Logf("✓ No timing attack vulnerability detected (difference: %.2f%% < threshold: %.2f%%)",
|
||||
percentDiff, threshold)
|
||||
}
|
||||
|
||||
// Additional check: Ensure no individual case is an obvious outlier
|
||||
// Check if any single user's timing is significantly faster than bcrypt operations
|
||||
allStats := append(validUserStats, invalidUserStats...)
|
||||
var allMeansSum time.Duration
|
||||
for _, s := range allStats {
|
||||
allMeansSum += s.mean
|
||||
}
|
||||
overallMean := allMeansSum / time.Duration(len(allStats))
|
||||
|
||||
for _, tc := range testCases {
|
||||
s := calculateStats(measurements[tc.username])
|
||||
deviation := math.Abs(float64(s.mean-overallMean)) / float64(overallMean) * 100.0
|
||||
|
||||
// If any user is more than 30% faster/slower than average, it's suspicious
|
||||
if deviation > 30.0 {
|
||||
t.Errorf("OUTLIER DETECTED: %s deviates by %.2f%% from overall mean (%.4fms vs %.4fms)",
|
||||
tc.username, deviation,
|
||||
float64(s.mean.Microseconds())/1000.0,
|
||||
float64(overallMean.Microseconds())/1000.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJSONAuth_InvalidPasswordHashInitialized verifies that InvalidPasswordHash is properly set
|
||||
func TestJSONAuth_InvalidPasswordHashInitialized(t *testing.T) {
|
||||
// This test ensures that the InvalidPasswordHash is initialized
|
||||
// If it's empty, timing attacks would be possible because invalid users
|
||||
// would skip bcrypt comparison entirely
|
||||
|
||||
// Use lower bcrypt cost for faster testing
|
||||
originalCost := utils.BcryptCost
|
||||
utils.BcryptCost = 4
|
||||
t.Cleanup(func() {
|
||||
utils.BcryptCost = originalCost
|
||||
})
|
||||
|
||||
err := utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set invalid password hash: %v", err)
|
||||
}
|
||||
|
||||
if utils.InvalidPasswordHash == "" {
|
||||
t.Fatal("InvalidPasswordHash is not initialized - timing attack vulnerability exists!")
|
||||
}
|
||||
|
||||
// Verify that the invalid hash can be used for password comparison
|
||||
// This should fail (wrong password) but take the same time as a real bcrypt comparison
|
||||
err = utils.CheckPwd("anypassword", utils.InvalidPasswordHash)
|
||||
if err == nil {
|
||||
t.Fatal("CheckPwd should fail with InvalidPasswordHash")
|
||||
}
|
||||
|
||||
t.Log("✓ InvalidPasswordHash is properly initialized")
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// MethodJwtAuth is used to identify JWT external authentication.
|
||||
const MethodJwtAuth = "jwt"
|
||||
|
||||
// JwtAuth is an external JWT implementation of an auther.
|
||||
// It verifies JWT tokens signed with a shared secret and extracts user identity.
|
||||
type JwtAuth struct {
|
||||
Secret string `json:"secret"` // shared secret for verifying JWT signatures
|
||||
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256, etc.)
|
||||
}
|
||||
|
||||
// ExternalJwtClaims represents the expected claims in an external JWT token
|
||||
type ExternalJwtClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Username string `json:"sub"` // subject claim (username)
|
||||
Email string `json:"email,omitempty"` // email claim
|
||||
Name string `json:"name,omitempty"` // full name claim
|
||||
Groups []string `json:"groups,omitempty"` // groups claim
|
||||
Custom map[string]interface{} `json:"-"` // catch-all for custom claims
|
||||
}
|
||||
|
||||
// Auth authenticates the user via an external JWT token.
|
||||
// The token is verified using the configured secret and algorithm.
|
||||
func (a JwtAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||
// This should not be called directly - JWT auth is handled in middleware
|
||||
// because we need access to the full config (header name, user identifier field, etc.)
|
||||
return nil, fmt.Errorf("JWT auth must be handled in middleware")
|
||||
}
|
||||
|
||||
// VerifyExternalJWT verifies an external JWT token and extracts the username claim
|
||||
func VerifyExternalJWT(tokenString string, secret string, algorithm string, userIdentifierField string) (string, map[string]interface{}, error) {
|
||||
// Parse the token with the secret
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify the signing method matches what we expect
|
||||
expectedMethod := jwt.GetSigningMethod(algorithm)
|
||||
if expectedMethod == nil {
|
||||
return nil, fmt.Errorf("unsupported signing algorithm: %s", algorithm)
|
||||
}
|
||||
|
||||
if token.Method.Alg() != expectedMethod.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// Return the secret key for HMAC algorithms
|
||||
// For RSA/ECDSA, this would need to be a public key
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Debugf("JWT token verification failed: %v", err)
|
||||
return "", nil, fmt.Errorf("invalid JWT token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return "", nil, fmt.Errorf("JWT token is invalid")
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("failed to parse JWT claims")
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if err := claims.Valid(); err != nil {
|
||||
logger.Debugf("JWT claims validation failed: %v", err)
|
||||
return "", nil, fmt.Errorf("JWT token is expired or invalid: %w", err)
|
||||
}
|
||||
|
||||
// Extract username from the configured field (default: "sub")
|
||||
var username string
|
||||
if userVal, ok := claims[userIdentifierField]; ok {
|
||||
if userStr, ok := userVal.(string); ok {
|
||||
username = userStr
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return "", nil, fmt.Errorf("JWT token missing required claim: %s", userIdentifierField)
|
||||
}
|
||||
|
||||
// Convert claims to map for additional processing
|
||||
claimsMap := make(map[string]interface{})
|
||||
for k, v := range claims {
|
||||
claimsMap[k] = v
|
||||
}
|
||||
|
||||
logger.Debugf("Successfully verified JWT token for user: %s", username)
|
||||
return username, claimsMap, nil
|
||||
}
|
||||
|
||||
// ExtractGroupsFromClaims extracts groups from JWT claims based on the configured groups claim field
|
||||
func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField string) []string {
|
||||
var groups []string
|
||||
|
||||
if groupsVal, ok := claims[groupsClaimField]; ok {
|
||||
switch v := groupsVal.(type) {
|
||||
case []interface{}:
|
||||
// Groups as array of interfaces
|
||||
for _, g := range v {
|
||||
if groupStr, ok := g.(string); ok {
|
||||
groups = append(groups, groupStr)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
// Groups as array of strings
|
||||
groups = v
|
||||
case string:
|
||||
// Single group as string
|
||||
groups = []string{v}
|
||||
default:
|
||||
// Try to unmarshal as JSON array
|
||||
if jsonBytes, err := json.Marshal(v); err == nil {
|
||||
var groupsArray []string
|
||||
if err := json.Unmarshal(jsonBytes, &groupsArray); err == nil {
|
||||
groups = groupsArray
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
)
|
||||
|
||||
func TestVerifyExternalJWT_ValidToken(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
username := "testuser"
|
||||
|
||||
// Create a valid JWT token
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"email": "test@example.com",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
extractedUsername, extractedClaims, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT() failed: %v", err)
|
||||
}
|
||||
|
||||
if extractedUsername != username {
|
||||
t.Errorf("Expected username %s, got %s", username, extractedUsername)
|
||||
}
|
||||
|
||||
if extractedClaims["email"] != "test@example.com" {
|
||||
t.Errorf("Expected email test@example.com, got %v", extractedClaims["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ExpiredToken(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
|
||||
// Create an expired JWT token
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "testuser",
|
||||
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
|
||||
"iat": time.Now().Add(-2 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token - should fail
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyExternalJWT() should fail for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_InvalidSignature(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
wrongSecret := "wrong-secret-key"
|
||||
algorithm := "HS256"
|
||||
|
||||
// Create a token with one secret
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "testuser",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Try to verify with a different secret - should fail
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, wrongSecret, algorithm, "sub")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyExternalJWT() should fail for invalid signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_MissingUsernameClaim(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
|
||||
// Create a token without the 'sub' claim
|
||||
claims := jwt.MapClaims{
|
||||
"email": "test@example.com",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token - should fail due to missing 'sub' claim
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyExternalJWT() should fail for missing username claim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
algorithm := "HS256"
|
||||
username := "testuser"
|
||||
|
||||
// Create a token with custom username field
|
||||
claims := jwt.MapClaims{
|
||||
"username": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token with custom user identifier field
|
||||
extractedUsername, _, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "username")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT() failed: %v", err)
|
||||
}
|
||||
|
||||
if extractedUsername != username {
|
||||
t.Errorf("Expected username %s, got %s", username, extractedUsername)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_ArrayOfStrings(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []string{"admin", "users"},
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" || groups[1] != "users" {
|
||||
t.Errorf("Expected groups [admin, users], got %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_ArrayOfInterfaces(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []interface{}{"admin", "users"},
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" || groups[1] != "users" {
|
||||
t.Errorf("Expected groups [admin, users], got %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_SingleString(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": "admin",
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("Expected 1 group, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" {
|
||||
t.Errorf("Expected group admin, got %s", groups[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_MissingClaim(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"email": "test@example.com",
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "groups")
|
||||
|
||||
if len(groups) != 0 {
|
||||
t.Fatalf("Expected 0 groups, got %d", len(groups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_CustomClaimName(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"roles": []string{"admin", "editor"},
|
||||
}
|
||||
|
||||
groups := auth.ExtractGroupsFromClaims(claims, "roles")
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if groups[0] != "admin" || groups[1] != "editor" {
|
||||
t.Errorf("Expected groups [admin, editor], got %v", groups)
|
||||
}
|
||||
}
|
||||
@@ -89,11 +89,6 @@ func StartFilebrowser() {
|
||||
settings.Env.EmbeddedFs = os.IsNotExist(err)
|
||||
}
|
||||
|
||||
err = utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to set security hash: %v", err)
|
||||
}
|
||||
|
||||
sourceList := []string{}
|
||||
for path, source := range settings.Config.Server.SourceMap {
|
||||
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
|
||||
@@ -207,16 +202,12 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
}
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
|
||||
logger.Debugf("Exiftool Enabled : %v", settings.Config.Integrations.Media.ExiftoolPath != "")
|
||||
|
||||
// Generate PWA icons after preview service is initialized
|
||||
if err := icons.GeneratePWAIcons(); err != nil {
|
||||
logger.Warningf("Failed to generate PWA icons: %v", err)
|
||||
}
|
||||
|
||||
// Initialize PWA manifest after icons are generated
|
||||
icons.InitializePWAManifest()
|
||||
|
||||
fbhttp.StartHttp(ctx, store, shutdownComplete)
|
||||
return nil
|
||||
}
|
||||
+3
-22
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
@@ -41,9 +40,6 @@ func validateUserInfo(newDB bool) {
|
||||
if updateSidebarLinks(user) {
|
||||
updateUser = true
|
||||
}
|
||||
if updateTokens(user) {
|
||||
updateUser = true
|
||||
}
|
||||
adminUser := settings.Config.Auth.AdminUsername
|
||||
adminPass := settings.Config.Auth.AdminPassword
|
||||
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
|
||||
@@ -169,7 +165,7 @@ func updatePermissions(user *users.User) bool {
|
||||
user.Permissions.Delete = true
|
||||
updateUser = true
|
||||
}
|
||||
user.Version = 2
|
||||
user.Version = 1
|
||||
if updateUser {
|
||||
createBackup = true
|
||||
}
|
||||
@@ -201,7 +197,7 @@ func updateSidebarLinks(user *users.User) bool {
|
||||
validSourceLinksCount := 0
|
||||
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
if link.Category == "source" {
|
||||
sourceLinksCount++
|
||||
// Check if this source still exists
|
||||
if link.SourceName != "" {
|
||||
@@ -224,7 +220,7 @@ func updateSidebarLinks(user *users.User) bool {
|
||||
// Remove all existing source links
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if !strings.HasPrefix(link.Category, "source") {
|
||||
if link.Category != "source" {
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
}
|
||||
@@ -250,18 +246,3 @@ func updateSidebarLinks(user *users.User) bool {
|
||||
// User has at least one valid source link, no update needed
|
||||
return false
|
||||
}
|
||||
|
||||
func updateTokens(user *users.User) bool {
|
||||
if user.Version >= 2 {
|
||||
return false
|
||||
}
|
||||
if user.ApiKeys != nil {
|
||||
user.Tokens = make(map[string]users.AuthToken)
|
||||
for name, token := range user.ApiKeys {
|
||||
token.Token = token.Key
|
||||
user.Tokens[name] = token
|
||||
}
|
||||
}
|
||||
user.Version = 2
|
||||
return true
|
||||
}
|
||||
@@ -22,7 +22,6 @@ var (
|
||||
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
|
||||
ErrUnauthorized = errors.New("user unauthorized")
|
||||
ErrNotIndexed = errors.New("directory or item excluded from indexing")
|
||||
ErrNotViewable = errors.New("directory or item is not viewable")
|
||||
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
|
||||
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
|
||||
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
|
||||
|
||||
+17
-180
@@ -6,11 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
ldap "github.com/go-ldap/ldap/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -32,18 +29,6 @@ func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
return t.base.RoundTrip(reqCopy)
|
||||
}
|
||||
|
||||
// AuthCommon contains fields shared across multiple authentication methods
|
||||
type AuthCommon struct {
|
||||
Enabled bool `json:"enabled"` // whether to enable this authentication method.
|
||||
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges
|
||||
UserGroups []string `json:"userGroups"` // if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
|
||||
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
|
||||
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
|
||||
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification (insecure, for testing only)
|
||||
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
CreateUser bool `json:"createUser"` // deprecated: always true for supported authentication methods
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
|
||||
Methods LoginMethods `json:"methods"`
|
||||
@@ -59,8 +44,6 @@ type LoginMethods struct {
|
||||
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
|
||||
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
|
||||
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
|
||||
LdapAuth LdapConfig `json:"ldap" validate:"omitempty"`
|
||||
JwtAuth JwtAuthConfig `json:"jwt" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type PasswordAuthConfig struct {
|
||||
@@ -72,8 +55,10 @@ type PasswordAuthConfig struct {
|
||||
}
|
||||
|
||||
type ProxyAuthConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
|
||||
Enabled bool `json:"enabled"`
|
||||
CreateUser bool `json:"createUser"` // create user if not exists
|
||||
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
|
||||
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
}
|
||||
|
||||
type Recaptcha struct {
|
||||
@@ -84,65 +69,19 @@ type Recaptcha struct {
|
||||
|
||||
// OpenID OAuth2.0
|
||||
type OidcConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
ClientID string `json:"clientId"` // secret: client id of the OIDC application
|
||||
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
|
||||
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
|
||||
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
|
||||
Provider *oidc.Provider `json:"-"` // OIDC provider
|
||||
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
|
||||
}
|
||||
|
||||
type LdapConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Server string `json:"server"` // scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
|
||||
BaseDN string `json:"baseDN"` // LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
|
||||
UserDN string `json:"userDN"` // Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
|
||||
UserPassword string `json:"userPassword"` // Bind password for service account
|
||||
UserFilter string `json:"userFilter"` // Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
|
||||
Port int `json:"-"` // derived from server
|
||||
Scheme string `json:"-"` // derived from server
|
||||
Host string `json:"-"` // derived from server
|
||||
}
|
||||
|
||||
// JwtAuthConfig configures external JWT token authentication
|
||||
// Similar to Grafana's JWT auth: accepts external JWT tokens signed with a shared secret
|
||||
// The query parameter is hardcoded to "jwt" (e.g. ?jwt=<token>)
|
||||
type JwtAuthConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
|
||||
Secret string `json:"secret"` // secret: shared secret key for verifying JWT token signatures (required)
|
||||
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
|
||||
}
|
||||
|
||||
// ValidateLdapAuth checks LDAP config and sets defaults. Call when LDAP is enabled.
|
||||
func ValidateLdapAuth() error {
|
||||
ldapCfg := &Config.Auth.Methods.LdapAuth
|
||||
if ldapCfg.Server == "" {
|
||||
return fmt.Errorf("LDAP server is required when LDAP is enabled")
|
||||
}
|
||||
scheme, host, port, err := parseLdapServer(ldapCfg.Server)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP server is invalid: %w", err)
|
||||
}
|
||||
ldapCfg.Server = fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
ldapCfg.Scheme = scheme
|
||||
ldapCfg.Host = host
|
||||
ldapCfg.Port = port
|
||||
|
||||
if ldapCfg.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP baseDN is required when LDAP is enabled")
|
||||
}
|
||||
if ldapCfg.UserDN == "" {
|
||||
return fmt.Errorf("LDAP userDN (bind DN) is required when LDAP is enabled")
|
||||
}
|
||||
if ldapCfg.UserFilter == "" {
|
||||
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
|
||||
}
|
||||
if err := verifyLdapConnection(); err != nil {
|
||||
logger.Fatalf("LDAP connection check failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
|
||||
ClientID string `json:"clientId"` // secret: client id of the OIDC application
|
||||
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
|
||||
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
|
||||
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
|
||||
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
|
||||
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
|
||||
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
CreateUser bool `json:"createUser"` // create user if not exists
|
||||
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges.
|
||||
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
|
||||
Provider *oidc.Provider `json:"-"` // OIDC provider
|
||||
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
|
||||
}
|
||||
|
||||
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
|
||||
@@ -194,105 +133,3 @@ func validateOidcAuth() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLdapServer(server string) (scheme, host string, port int, err error) {
|
||||
parts := strings.Split(server, "://")
|
||||
if len(parts) != 2 {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP server: %s", server)
|
||||
}
|
||||
scheme = strings.ToLower(parts[0])
|
||||
if scheme != "ldap" && scheme != "ldaps" {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP scheme: %s", scheme)
|
||||
}
|
||||
hostParts := strings.Split(parts[1], ":")
|
||||
host = strings.TrimSuffix(hostParts[0], "/")
|
||||
if len(hostParts) > 1 && hostParts[1] != "" {
|
||||
port, err = strconv.Atoi(strings.TrimSuffix(hostParts[1], "/"))
|
||||
if err != nil || port == 0 {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP port: %s", hostParts[1])
|
||||
}
|
||||
} else {
|
||||
if scheme == "ldaps" {
|
||||
port = 636
|
||||
} else {
|
||||
port = 389
|
||||
}
|
||||
}
|
||||
return scheme, host, port, nil
|
||||
}
|
||||
|
||||
// VerifyLdapConnection tests that the LDAP server is reachable, can bind (if credentials set), and responds to a search.
|
||||
func verifyLdapConnection() error {
|
||||
if Config.Auth.Methods.LdapAuth.Server == "" {
|
||||
return fmt.Errorf("LDAP server is required when LDAP is enabled")
|
||||
}
|
||||
if Config.Auth.Methods.LdapAuth.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP baseDN is required for connection verification")
|
||||
}
|
||||
var opts []ldap.DialOpt
|
||||
scheme := Config.Auth.Methods.LdapAuth.Scheme
|
||||
host := Config.Auth.Methods.LdapAuth.Host
|
||||
port := Config.Auth.Methods.LdapAuth.Port
|
||||
if Config.Auth.Methods.LdapAuth.DisableVerifyTLS && scheme == "ldaps" {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
|
||||
}
|
||||
fullURL := fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
conn, err := ldap.DialURL(fullURL, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP connection failed (is the LDAP outpost running at %s? use port 389 for LDAP, 636 for LDAPS; this is not the HTTP port): %w", fullURL, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if Config.Auth.Methods.LdapAuth.UserDN != "" && Config.Auth.Methods.LdapAuth.UserPassword != "" {
|
||||
if err = conn.Bind(Config.Auth.Methods.LdapAuth.UserDN, Config.Auth.Methods.LdapAuth.UserPassword); err != nil {
|
||||
return fmt.Errorf("LDAP bind failed (check userDN and userPassword): %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Unbind() }()
|
||||
}
|
||||
// Dummy search to verify the server responds to LDAP search (same path that fails at login if misconfigured).
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
Config.Auth.Methods.LdapAuth.BaseDN,
|
||||
ldap.ScopeBaseObject,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
"(objectClass=*)",
|
||||
[]string{"1.1"}, // no attributes
|
||||
nil,
|
||||
)
|
||||
_, err = conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP search test failed (server may require StartTLS or bind before search): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJwtAuth checks JWT config and sets defaults. Call when JWT auth is enabled.
|
||||
func ValidateJwtAuth() error {
|
||||
jwtCfg := &Config.Auth.Methods.JwtAuth
|
||||
if jwtCfg.Secret == "" {
|
||||
return fmt.Errorf("JWT secret is required when JWT auth is enabled")
|
||||
}
|
||||
if jwtCfg.Header == "" {
|
||||
jwtCfg.Header = "X-JWT-Assertion"
|
||||
}
|
||||
if jwtCfg.Algorithm == "" {
|
||||
jwtCfg.Algorithm = "HS256"
|
||||
}
|
||||
if jwtCfg.GroupsClaim == "" {
|
||||
jwtCfg.GroupsClaim = "groups"
|
||||
}
|
||||
if jwtCfg.UserIdentifier == "" {
|
||||
jwtCfg.UserIdentifier = "sub"
|
||||
}
|
||||
// Validate algorithm
|
||||
validAlgos := map[string]bool{
|
||||
"HS256": true, "HS384": true, "HS512": true,
|
||||
"RS256": true, "RS384": true, "RS512": true,
|
||||
"ES256": true, "ES384": true, "ES512": true,
|
||||
}
|
||||
if !validAlgos[jwtCfg.Algorithm] {
|
||||
return fmt.Errorf("unsupported JWT algorithm: %s. Supported: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512", jwtCfg.Algorithm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLdapServer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
server string
|
||||
wantScheme string
|
||||
wantHost string
|
||||
wantPort int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ldap with host and port",
|
||||
server: "ldap://localhost:389",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "localhost",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldaps with host and port",
|
||||
server: "ldaps://ldap.example.com:636",
|
||||
wantScheme: "ldaps",
|
||||
wantHost: "ldap.example.com",
|
||||
wantPort: 636,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldap with trailing slash",
|
||||
server: "ldap://host:389/",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "host",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldap port not provided defaults to 389",
|
||||
server: "ldap://localhost",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "localhost",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldaps port not provided defaults to 636",
|
||||
server: "ldaps://ldap.example.com",
|
||||
wantScheme: "ldaps",
|
||||
wantHost: "ldap.example.com",
|
||||
wantPort: 636,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "port provided but invalid returns error",
|
||||
server: "ldap://host:bad",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "port provided but invalid (ldaps) returns error",
|
||||
server: "ldaps://host:xyz",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "port zero returns error",
|
||||
server: "ldap://host:0",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty server",
|
||||
server: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no scheme",
|
||||
server: "localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme",
|
||||
server: "http://localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme ftp",
|
||||
server: "ftp://localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotScheme, gotHost, gotPort, err := parseLdapServer(tt.server)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseLdapServer() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr {
|
||||
if gotScheme != tt.wantScheme {
|
||||
t.Errorf("parseLdapServer() scheme = %v, want %v", gotScheme, tt.wantScheme)
|
||||
}
|
||||
if gotHost != tt.wantHost {
|
||||
t.Errorf("parseLdapServer() host = %v, want %v", gotHost, tt.wantHost)
|
||||
}
|
||||
if gotPort != tt.wantPort {
|
||||
t.Errorf("parseLdapServer() port = %v, want %v", gotPort, tt.wantPort)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLdapConnection_ValidationOnly(t *testing.T) {
|
||||
// Save and restore global config so we don't affect other tests
|
||||
saved := Config.Auth.Methods.LdapAuth
|
||||
defer func() { Config.Auth.Methods.LdapAuth = saved }()
|
||||
|
||||
t.Run("empty server returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
}
|
||||
err := verifyLdapConnection()
|
||||
if err == nil {
|
||||
t.Fatal("verifyLdapConnection() expected error for empty server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "LDAP server is required") {
|
||||
t.Errorf("verifyLdapConnection() error = %v, want message containing 'LDAP server is required'", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty baseDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
Scheme: "ldap",
|
||||
Host: "localhost",
|
||||
Port: 389,
|
||||
BaseDN: "",
|
||||
}
|
||||
err := verifyLdapConnection()
|
||||
if err == nil {
|
||||
t.Fatal("verifyLdapConnection() expected error for empty baseDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "baseDN is required") {
|
||||
t.Errorf("verifyLdapConnection() error = %v, want message containing 'baseDN is required'", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateLdapAuth_ConfigValidation(t *testing.T) {
|
||||
saved := Config.Auth.Methods.LdapAuth
|
||||
defer func() { Config.Auth.Methods.LdapAuth = saved }()
|
||||
|
||||
t.Run("empty server returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "server is required") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid server URL returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "not-a-valid-ldap-url",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for invalid server URL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty baseDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
BaseDN: "",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty baseDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "baseDN is required") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty userDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty userDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "userDN") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
@@ -56,7 +55,7 @@ func Initialize(configFile string) {
|
||||
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
|
||||
setupUrls()
|
||||
setupFrontend(false)
|
||||
setupMedia(false)
|
||||
setupMedia()
|
||||
}
|
||||
|
||||
func setupServer() {
|
||||
@@ -281,7 +280,7 @@ func setupFrontend(generate bool) {
|
||||
loadCustomFavicon()
|
||||
}
|
||||
|
||||
func setupMedia(generate bool) {
|
||||
func setupMedia() {
|
||||
// Save user's explicit config before applying defaults
|
||||
userImageConfig := make(map[ImagePreviewType]*bool)
|
||||
for k, v := range Config.Integrations.Media.Convert.ImagePreview {
|
||||
@@ -315,19 +314,6 @@ func setupMedia(generate bool) {
|
||||
for k, v := range userVideoConfig {
|
||||
Config.Integrations.Media.Convert.VideoPreview[k] = v
|
||||
}
|
||||
|
||||
// Resolve exiftool path once at startup: validate user path or discover via PATH
|
||||
if Config.Integrations.Media.ExiftoolPath != "" && !generate {
|
||||
if err := exec.Command(Config.Integrations.Media.ExiftoolPath, "-ver").Run(); err != nil {
|
||||
logger.Warningf("exiftool path is invalid or not executable: %q (%v); disabling exiftool", Config.Integrations.Media.ExiftoolPath, err)
|
||||
Config.Integrations.Media.ExiftoolPath = ""
|
||||
}
|
||||
}
|
||||
if Config.Integrations.Media.ExiftoolPath == "" && !generate {
|
||||
if path, err := exec.LookPath("exiftool"); err == nil && path != "" {
|
||||
Config.Integrations.Media.ExiftoolPath = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupSources(generate bool) {
|
||||
@@ -350,7 +336,11 @@ func setupSources(generate bool) {
|
||||
if name == "\\" {
|
||||
name = strings.Split(realPath, ":")[0]
|
||||
}
|
||||
source.Path = realPath // use absolute path
|
||||
if generate {
|
||||
source.Path = generatorPath // use placeholder path
|
||||
} else {
|
||||
source.Path = realPath // use absolute path
|
||||
}
|
||||
if source.Name == "" {
|
||||
_, ok := Config.Server.SourceMap[source.Path]
|
||||
if ok {
|
||||
@@ -359,10 +349,6 @@ func setupSources(generate bool) {
|
||||
source.Name = name
|
||||
}
|
||||
}
|
||||
if generate {
|
||||
source.Path = generatorPath // use placeholder path
|
||||
source.Name = "Source Name"
|
||||
}
|
||||
modifyExcludeInclude(source)
|
||||
setConditionals(source)
|
||||
if source.Config.DefaultUserScope == "" {
|
||||
@@ -434,34 +420,20 @@ func setupAuth(generate bool) {
|
||||
if Config.Auth.Methods.ProxyAuth.Enabled {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
|
||||
}
|
||||
if Config.Auth.Methods.OidcAuth.Enabled {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
|
||||
}
|
||||
if Config.Auth.Methods.NoAuth {
|
||||
logger.Warning("Configured with no authentication, this is not recommended.")
|
||||
Config.Auth.AuthMethods = []string{"disabled"}
|
||||
}
|
||||
if Config.Auth.Methods.OidcAuth.Enabled || generate {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
|
||||
err := validateOidcAuth()
|
||||
if err != nil && !generate {
|
||||
logger.Fatalf("Error validating OIDC auth: %v", err)
|
||||
}
|
||||
logger.Info("OIDC Auth configured successfully")
|
||||
}
|
||||
if Config.Auth.Methods.LdapAuth.Enabled || generate {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "ldap")
|
||||
err := ValidateLdapAuth()
|
||||
if err != nil && !generate {
|
||||
logger.Fatalf("Error validating LDAP auth: %v", err)
|
||||
}
|
||||
logger.Info("LDAP Auth configured successfully")
|
||||
}
|
||||
if Config.Auth.Methods.JwtAuth.Enabled || generate {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "jwt")
|
||||
err := ValidateJwtAuth()
|
||||
if err != nil && !generate {
|
||||
logger.Fatalf("Error validating JWT auth: %v", err)
|
||||
}
|
||||
logger.Info("JWT Auth configured successfully")
|
||||
}
|
||||
|
||||
// use password auth as default if no auth methods are set
|
||||
if len(Config.Auth.AuthMethods) == 0 {
|
||||
@@ -650,11 +622,6 @@ func loadEnvConfig() {
|
||||
logger.Info("Using ReCaptcha Secret from FILEBROWSER_RECAPTCHA_SECRET environment variable")
|
||||
}
|
||||
|
||||
ldapUserPassword := os.Getenv("FILEBROWSER_LDAP_USER_PASSWORD")
|
||||
if ldapUserPassword != "" {
|
||||
Config.Auth.Methods.LdapAuth.UserPassword = ldapUserPassword
|
||||
logger.Info("Using LDAP bind password from FILEBROWSER_LDAP_USER_PASSWORD environment variable")
|
||||
}
|
||||
}
|
||||
|
||||
func setDefaults(generate bool) Settings {
|
||||
@@ -679,8 +646,8 @@ func setDefaults(generate bool) Settings {
|
||||
Database: database,
|
||||
SourceMap: map[string]*Source{},
|
||||
NameToSource: map[string]*Source{},
|
||||
MaxArchiveSizeGB: 50,
|
||||
CacheDir: "tmp",
|
||||
MaxArchiveSizeGB: 20,
|
||||
IndexSqlConfig: IndexSqlConfig{
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
@@ -707,11 +674,8 @@ func setDefaults(generate bool) Settings {
|
||||
Name: "FileBrowser Quantum",
|
||||
},
|
||||
UserDefaults: UserDefaults{
|
||||
DeleteAfterArchive: true,
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
HideFilesInTree: false,
|
||||
PreferEditorForMarkdown: false,
|
||||
LockPassword: false,
|
||||
ShowHidden: false,
|
||||
DarkMode: boolPtr(true),
|
||||
@@ -728,15 +692,14 @@ func setDefaults(generate bool) Settings {
|
||||
Download: boolPtr(true), // defaults to true
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
HighQuality: boolPtr(true),
|
||||
Image: boolPtr(true),
|
||||
Video: boolPtr(true),
|
||||
Audio: boolPtr(true),
|
||||
MotionVideoPreview: boolPtr(true),
|
||||
Office: boolPtr(true),
|
||||
PopUp: boolPtr(true),
|
||||
AutoplayMedia: boolPtr(true),
|
||||
Folder: boolPtr(true),
|
||||
Models: boolPtr(true),
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
@@ -869,7 +832,26 @@ func setConditionals(config *Source) {
|
||||
IgnoreAllZeroSizeFolders: false,
|
||||
IgnoreAllSymlinks: false,
|
||||
IndexingDisabled: false,
|
||||
NoRules: len(rules) == 0,
|
||||
}
|
||||
|
||||
// backwards compatibility
|
||||
if config.Config.Conditionals.Hidden {
|
||||
logger.Warning("source.conditionals.hidden is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllHidden = true
|
||||
}
|
||||
// Backwards compatibility: if old format fields are set, treat as global rules
|
||||
if config.Config.Conditionals.IgnoreHidden {
|
||||
logger.Warning("source.conditionals.ignoreHidden is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllHidden = true
|
||||
}
|
||||
if config.Config.DisableIndexing {
|
||||
logger.Warning("source.disableIndexing is deprecated, use source.rules instead")
|
||||
resolved.IndexingDisabled = true
|
||||
}
|
||||
|
||||
if config.Config.Conditionals.ZeroSizeFolders {
|
||||
logger.Warning("source.conditionals.zeroSizeFolders is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllZeroSizeFolders = true
|
||||
}
|
||||
|
||||
// Process all rules and infer global flags from root-level rules
|
||||
|
||||
@@ -561,130 +561,6 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
|
||||
|
||||
currentField := v.Field(i)
|
||||
|
||||
// Check if this is an inline/embedded field
|
||||
yamlTag := sf.Tag.Get("yaml")
|
||||
jsonTag := sf.Tag.Get("json")
|
||||
isInline := strings.Contains(yamlTag, ",inline") || strings.Contains(jsonTag, ",inline") || sf.Anonymous
|
||||
|
||||
// If field is inline, merge its content directly into parent
|
||||
if isInline && sf.Type.Kind() == reflect.Struct {
|
||||
// Get the embedded struct's fields and add them directly to this level
|
||||
embeddedType := sf.Type
|
||||
embeddedValue := currentField
|
||||
embeddedTypeName := embeddedType.Name()
|
||||
|
||||
for j := 0; j < embeddedType.NumField(); j++ {
|
||||
embeddedField := embeddedType.Field(j)
|
||||
|
||||
// Skip unexported fields
|
||||
if embeddedField.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
if embeddedYamlTag := embeddedField.Tag.Get("yaml"); embeddedYamlTag == "-" {
|
||||
continue
|
||||
}
|
||||
if embeddedJsonTag := embeddedField.Tag.Get("json"); embeddedJsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip deprecated fields
|
||||
if len(deprecated) > 0 && deprecated[embeddedTypeName][embeddedField.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
embeddedFieldValue := embeddedValue.Field(j)
|
||||
|
||||
// If we have defaults, compare and skip if values match
|
||||
if defaults.IsValid() && i < defaults.NumField() {
|
||||
defaultInlineField := defaults.Field(i)
|
||||
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
|
||||
defaultEmbeddedField := defaultInlineField.Field(j)
|
||||
embeddedCurrentValue := embeddedFieldValue.Interface()
|
||||
embeddedDefaultValue := defaultEmbeddedField.Interface()
|
||||
|
||||
if reflect.DeepEqual(embeddedCurrentValue, embeddedDefaultValue) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine key name for embedded field
|
||||
embeddedYamlTag := embeddedField.Tag.Get("yaml")
|
||||
embeddedJsonTag := embeddedField.Tag.Get("json")
|
||||
var embeddedKeyName string
|
||||
if embeddedYamlTag != "" && embeddedYamlTag != "-" {
|
||||
embeddedKeyName = strings.Split(embeddedYamlTag, ",")[0]
|
||||
} else if embeddedJsonTag != "" && embeddedJsonTag != "-" {
|
||||
embeddedKeyName = strings.Split(embeddedJsonTag, ",")[0]
|
||||
} else {
|
||||
embeddedKeyName = embeddedField.Name
|
||||
}
|
||||
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: embeddedKeyName}
|
||||
|
||||
// Attach comments for embedded field
|
||||
var parts []string
|
||||
var comment string
|
||||
if cm := comm[embeddedTypeName][embeddedField.Name]; cm != "" {
|
||||
comment = cm
|
||||
}
|
||||
|
||||
if comment != "" {
|
||||
parts = append(parts, comment)
|
||||
}
|
||||
if len(comm) > 0 {
|
||||
if vt := embeddedField.Tag.Get("validate"); vt != "" {
|
||||
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
keyNode.LineComment = strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// Build value node for embedded field
|
||||
var valNode *yaml.Node
|
||||
var err error
|
||||
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
|
||||
if secrets[embeddedTypeName][embeddedField.Name] && !generateConfig {
|
||||
fieldValue := embeddedFieldValue.Interface()
|
||||
if str, ok := fieldValue.(string); ok && str == "" {
|
||||
valNode = &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Tag: "!!str",
|
||||
Value: "",
|
||||
Style: yaml.DoubleQuotedStyle,
|
||||
}
|
||||
} else {
|
||||
valNode = &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Tag: "!!str",
|
||||
Value: "**hidden**",
|
||||
Style: yaml.DoubleQuotedStyle,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var defaultEmbeddedField reflect.Value
|
||||
if defaults.IsValid() && i < defaults.NumField() {
|
||||
defaultInlineField := defaults.Field(i)
|
||||
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
|
||||
defaultEmbeddedField = defaultInlineField.Field(j)
|
||||
}
|
||||
}
|
||||
|
||||
valNode, err = buildNodeWithDefaults(embeddedFieldValue, comm, defaultEmbeddedField, secrets, deprecated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
mapNode.Content = append(mapNode.Content, keyNode, valNode)
|
||||
}
|
||||
|
||||
// Skip adding the embedded struct itself as a field
|
||||
continue
|
||||
}
|
||||
|
||||
// If we have defaults, compare and skip if values match
|
||||
if defaults.IsValid() && i < defaults.NumField() {
|
||||
defaultField := defaults.Field(i)
|
||||
@@ -699,6 +575,8 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
|
||||
}
|
||||
|
||||
// determine key: yaml tag > json tag > field name
|
||||
yamlTag := sf.Tag.Get("yaml")
|
||||
jsonTag := sf.Tag.Get("json")
|
||||
var keyName string
|
||||
if yamlTag != "" && yamlTag != "-" {
|
||||
keyName = strings.Split(yamlTag, ",")[0]
|
||||
@@ -877,11 +755,25 @@ func GenerateYaml() {
|
||||
setupLogging()
|
||||
setupFs()
|
||||
setupAuth(true)
|
||||
|
||||
// Save original paths before setupSources modifies them (for YAML generation)
|
||||
originalPaths := make(map[*Source]string)
|
||||
for _, source := range Config.Server.Sources {
|
||||
originalPaths[source] = source.Path
|
||||
}
|
||||
|
||||
setupSources(true)
|
||||
setupUrls()
|
||||
setupMedia(true)
|
||||
setupMedia()
|
||||
setupFrontend(true)
|
||||
|
||||
// Restore original paths so the YAML output has the correct paths, not the placeholder
|
||||
for _, source := range Config.Server.Sources {
|
||||
if originalPath, ok := originalPaths[source]; ok {
|
||||
source.Path = originalPath
|
||||
}
|
||||
}
|
||||
|
||||
output := "../frontend/public/config.generated.yaml" // "output YAML file"
|
||||
|
||||
// Generate YAML with comments enabled, full config, and deprecated fields filtered
|
||||
|
||||
@@ -68,8 +68,6 @@ func AdminPerms() users.Permissions {
|
||||
func ApplyUserDefaults(u *users.User) {
|
||||
u.StickySidebar = Config.UserDefaults.StickySidebar
|
||||
u.DisableSettings = Config.UserDefaults.DisableSettings
|
||||
u.HideFilesInTree = Config.UserDefaults.HideFilesInTree
|
||||
u.DeleteAfterArchive = Config.UserDefaults.DeleteAfterArchive
|
||||
|
||||
// Handle DarkMode with default - dereference pointer from config
|
||||
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
|
||||
@@ -90,16 +88,15 @@ func ApplyUserDefaults(u *users.User) {
|
||||
|
||||
// Handle Preview - convert from pointer-based defaults to regular bools
|
||||
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
|
||||
u.Preview.HighQuality = boolValueOrDefault(Config.UserDefaults.Preview.HighQuality, true)
|
||||
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
|
||||
u.Preview.Audio = boolValueOrDefault(Config.UserDefaults.Preview.Audio, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
|
||||
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
|
||||
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
|
||||
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
|
||||
u.Preview.Models = boolValueOrDefault(Config.UserDefaults.Preview.Models, true)
|
||||
|
||||
u.ShowHidden = Config.UserDefaults.ShowHidden
|
||||
u.DateFormat = Config.UserDefaults.DateFormat
|
||||
@@ -111,8 +108,7 @@ func ApplyUserDefaults(u *users.User) {
|
||||
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
|
||||
u.FileLoading = Config.UserDefaults.FileLoading
|
||||
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
|
||||
u.PreferEditorForMarkdown = Config.UserDefaults.PreferEditorForMarkdown
|
||||
if len(u.Scopes) == 0 && u.Username != "anonymous" {
|
||||
if len(u.Scopes) == 0 {
|
||||
for _, source := range Config.Server.Sources {
|
||||
if source.Config.DefaultEnabled {
|
||||
u.Scopes = append(u.Scopes, users.SourceScope{
|
||||
|
||||
@@ -62,10 +62,10 @@ type Server struct {
|
||||
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
|
||||
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
|
||||
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
|
||||
|
||||
// not exposed to config
|
||||
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
|
||||
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
|
||||
@@ -102,7 +102,6 @@ type Media struct {
|
||||
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
|
||||
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // extract embedded subtitles from media files
|
||||
ExiftoolPath string `json:"exiftoolPath"` // path to exiftool executable
|
||||
}
|
||||
|
||||
type FfmpegConvert struct {
|
||||
@@ -261,7 +260,6 @@ type ResolvedRulesConfig struct {
|
||||
IgnoreAllZeroSizeFolders bool // Excludes all folders with 0 size
|
||||
IgnoreAllSymlinks bool // Excludes all symbolic links
|
||||
IndexingDisabled bool // Excludes all files and folders from indexing
|
||||
NoRules bool // No rules are configured
|
||||
}
|
||||
|
||||
type Frontend struct {
|
||||
@@ -274,7 +272,6 @@ type Frontend struct {
|
||||
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
|
||||
Description string `json:"description"` // description that shows up in html head meta description
|
||||
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
|
||||
LoginButtonText string `json:"loginButtonText"` // text to display on the login button
|
||||
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
|
||||
}
|
||||
|
||||
@@ -304,17 +301,15 @@ type ExternalLink struct {
|
||||
// UserDefaultsPreview holds preview settings with pointer types for defaults
|
||||
type UserDefaultsPreview struct {
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
|
||||
HighQuality *bool `json:"highQuality"` // deprecated: always true in v1.3.0+
|
||||
HighQuality *bool `json:"highQuality"` // use high quality thumbnails
|
||||
Image *bool `json:"image"` // show thumbnails for image files
|
||||
Video *bool `json:"video"` // show thumbnails for video files
|
||||
Audio *bool `json:"audio"` // show thumbnails for audio files
|
||||
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
Office *bool `json:"office"` // show thumbnails for office files
|
||||
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
|
||||
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
|
||||
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
|
||||
Models *bool `json:"models"` // show live thumbnails for 3D models files
|
||||
}
|
||||
|
||||
// UserDefaultsPermissions holds permission settings with pointer types for defaults
|
||||
@@ -337,7 +332,6 @@ type UserDefaults struct {
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
@@ -357,12 +351,10 @@ type UserDefaults struct {
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
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
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer.
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var BcryptCost = bcrypt.DefaultCost
|
||||
var InvalidPasswordHash = ""
|
||||
|
||||
// HashPwd hashes a password.
|
||||
func HashPwd(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPwd checks if a password is correct.
|
||||
func CheckPwd(password, hash string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
|
||||
func SetInvalidPasswordHash() error {
|
||||
passwordHash, err := HashPwd(InsecureRandomIdentifier(16))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
InvalidPasswordHash = passwordHash
|
||||
return nil
|
||||
}
|
||||
|
||||
func HashSHA256(data string) string {
|
||||
bytes := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(bytes[:])
|
||||
}
|
||||
|
||||
func GenerateKey() string {
|
||||
b := make([]byte, 64)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -52,15 +52,12 @@ type FileOptions struct {
|
||||
IsDir bool
|
||||
Expand bool
|
||||
Content bool
|
||||
Recursive bool // whether to recursively index directories
|
||||
Metadata bool // whether to get metadata
|
||||
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
|
||||
AlbumArt bool // whether to get album art from media files
|
||||
ShowHidden bool // whether to show hidden files (true = show, false = hide)
|
||||
FollowSymlinks bool // whether to follow symlinks
|
||||
Only string // whether to only get files or folders
|
||||
SkipExtendedAttrs bool // whether to skip extended attributes
|
||||
ShowSharedAttr bool // whether to show share attributes
|
||||
Recursive bool // whether to recursively index directories
|
||||
Metadata bool // whether to get metadata
|
||||
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
|
||||
AlbumArt bool // whether to get album art from media files
|
||||
ShowHidden bool // whether to show hidden files (true = show, false = hide)
|
||||
FollowSymlinks bool // whether to follow symlinks
|
||||
}
|
||||
|
||||
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
|
||||
@@ -80,9 +77,5 @@ func SanitizeUserPath(userPath string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if clean == "." {
|
||||
return "", fmt.Errorf("invalid path: path must standard index path")
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package utils
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
math "math/rand"
|
||||
"os"
|
||||
@@ -20,6 +23,15 @@ func CheckErr(source string, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateKey() string {
|
||||
b := make([]byte, 64)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// CapitalizeFirst returns the input string with the first letter capitalized.
|
||||
func CapitalizeFirst(s string) string {
|
||||
if len(s) == 0 {
|
||||
@@ -80,6 +92,11 @@ func GetParentDirectoryPath(path string) string {
|
||||
return path[:lastSlash]
|
||||
}
|
||||
|
||||
func HashSHA256(data string) string {
|
||||
bytes := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(bytes[:])
|
||||
}
|
||||
|
||||
func GetLastComponent(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
|
||||
@@ -8,4 +8,4 @@ userDefaults:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
highQuality: true
|
||||
+2
-6
@@ -11,7 +11,6 @@ userDefaults:
|
||||
disableQuickToggles: false # disable the quick toggles in the sidebar
|
||||
disableSearchOptions: false # disable the search options in the search bar
|
||||
stickySidebar: true # keep sidebar open when navigating
|
||||
hideFilesInTree: false # hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
darkMode: true # should dark mode be enabled (defaults to true if not set)
|
||||
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
|
||||
singleClick: false # open directory on single click, also enables middle click to open in new tab
|
||||
@@ -26,16 +25,15 @@ userDefaults:
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
preview:
|
||||
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
|
||||
highQuality: true # generate high quality thumbnail preview images (defaults to true if not set)
|
||||
image: true # show thumbnail preview image for image files (defaults to true if not set)
|
||||
video: true # show thumbnail preview image for video files (defaults to true if not set)
|
||||
audio: true # show thumbnail preview image for audio files (defaults to true if not set)
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
|
||||
office: true # show thumbnail preview image for office files (defaults to true if not set)
|
||||
popup: true # show larger popup preview when hovering over thumbnail (defaults to true if not set)
|
||||
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
|
||||
defaultMediaPlayer: false # disable html5 media player and use the default media player
|
||||
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
|
||||
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
admin: false # allow admin access
|
||||
@@ -47,9 +45,7 @@ userDefaults:
|
||||
download: true # allow downloading files (defaults to true if not set)
|
||||
disableUpdateNotifications: false # disable update notifications banner for admin users
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
|
||||
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # 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
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
@@ -37,10 +37,8 @@ type SourceRuleMap map[string]RuleMap
|
||||
type StringSet map[string]struct{}
|
||||
|
||||
type dbStorage struct {
|
||||
AllRules SourceRuleMap `json:"all_rules"`
|
||||
Groups GroupMap `json:"groups"`
|
||||
RevokedTokens map[string]struct{} `json:"revoked_tokens"` // set of revoked token hashes
|
||||
HashedTokens map[string]uint `json:"hashed_tokens"` // maps token hash → user ID
|
||||
AllRules SourceRuleMap `json:"all_rules"`
|
||||
Groups GroupMap `json:"groups"`
|
||||
}
|
||||
|
||||
// RuleSet groups users and groups for allow/deny lists.
|
||||
@@ -74,26 +72,21 @@ 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
|
||||
RevokedTokens map[string]struct{} // set of revoked token hashes - in-memory authoritative state
|
||||
HashedTokens map[string]uint // maps token hash → user ID - in-memory authoritative state
|
||||
DB *storm.DB // Optional: DB for persistence
|
||||
Users *users.Storage // Reference to users storage
|
||||
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.
|
||||
// IMPORTANT: Caller must hold s.mux lock (either read or write).
|
||||
func (s *Storage) SaveToDB() error {
|
||||
if s.DB == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(&dbStorage{
|
||||
AllRules: s.AllRules,
|
||||
Groups: s.Groups,
|
||||
RevokedTokens: s.RevokedTokens,
|
||||
HashedTokens: s.HashedTokens,
|
||||
AllRules: s.AllRules,
|
||||
Groups: s.Groups,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -104,8 +97,6 @@ func (s *Storage) SaveToDB() error {
|
||||
// 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 {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -132,18 +123,6 @@ func (s *Storage) LoadFromDB() error {
|
||||
if s.Groups == nil {
|
||||
s.Groups = make(GroupMap)
|
||||
}
|
||||
s.RevokedTokens = storage.RevokedTokens
|
||||
if s.RevokedTokens == nil {
|
||||
s.RevokedTokens = make(map[string]struct{})
|
||||
}
|
||||
s.HashedTokens = storage.HashedTokens
|
||||
if s.HashedTokens == nil {
|
||||
s.HashedTokens = make(map[string]uint)
|
||||
}
|
||||
s.HashedTokens = storage.HashedTokens
|
||||
if s.HashedTokens == nil {
|
||||
s.HashedTokens = make(map[string]uint)
|
||||
}
|
||||
s.mux.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -157,12 +136,10 @@ func (s *Storage) LoadFromDB() error {
|
||||
// if err != nil { /* handle error */ }
|
||||
func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
|
||||
var s = &Storage{
|
||||
AllRules: make(SourceRuleMap),
|
||||
Groups: make(GroupMap),
|
||||
RevokedTokens: make(map[string]struct{}),
|
||||
HashedTokens: make(map[string]uint),
|
||||
DB: db,
|
||||
Users: usersStore,
|
||||
AllRules: make(SourceRuleMap),
|
||||
Groups: make(GroupMap),
|
||||
DB: db,
|
||||
Users: usersStore,
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1259,63 +1236,3 @@ func (s *Storage) UpdateRulePath(sourcePath, oldPath, newPath string) error {
|
||||
logger.Debugf("access rule path updated: source=%s, fromPath=%s, toPath=%s", sourcePath, oldPath, newPath)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
// RevokeToken adds a token hash to the revoked list and persists to DB.
|
||||
func (s *Storage) RevokeToken(tokenHash string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
tokenHash = utils.HashSHA256(tokenHash)
|
||||
s.RevokedTokens[tokenHash] = struct{}{}
|
||||
// Also remove from HashedTokens to prevent future lookups
|
||||
delete(s.HashedTokens, tokenHash)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
// IsTokenRevoked checks if a token hash is in the revoked list (memory-only read).
|
||||
func (s *Storage) IsTokenRevoked(tokenHash string) bool {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
tokenHash = utils.HashSHA256(tokenHash)
|
||||
_, exists := s.RevokedTokens[tokenHash]
|
||||
return exists
|
||||
}
|
||||
|
||||
// AddHashedToken adds a token hash to user ID mapping.
|
||||
func (s *Storage) AddApiToken(tokenString string, userID uint) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
s.HashedTokens[tokenHash] = userID
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
// GetUserIDFromToken retrieves the user ID for a given token string (memory-only read).
|
||||
func (s *Storage) GetUserIDFromToken(tokenString string) (uint, bool) {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
userID, exists := s.HashedTokens[tokenHash]
|
||||
return userID, exists
|
||||
}
|
||||
|
||||
// GetRevokedTokens returns a copy of all revoked token hashes.
|
||||
func (s *Storage) GetRevokedTokens() map[string]struct{} {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
// Return a copy to prevent external modification
|
||||
result := make(map[string]struct{}, len(s.RevokedTokens))
|
||||
for k := range s.RevokedTokens {
|
||||
result[k] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RemoveApiToken removes a token hash mapping (used when deleting API keys).
|
||||
func (s *Storage) RemoveApiToken(tokenString string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
delete(s.HashedTokens, tokenHash)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func (s *Storage) CheckChildItemAccess(response *iteminfo.FileInfo, index *indexing.Index, username string) error {
|
||||
func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, index *indexing.Index, username string) error {
|
||||
|
||||
// Collect all item names to check
|
||||
allItemNames := make([]string, 0, len(response.Folders)+len(response.Files))
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -172,44 +170,3 @@ func (l *Link) SourceURL(user *users.User) string {
|
||||
scopedPath := strings.TrimPrefix(l.Path, userScope)
|
||||
return "/" + filepath.Join("files", sourceName, scopedPath)
|
||||
}
|
||||
|
||||
func (l *Link) BannerURL() string {
|
||||
_, _, err := l.GetShareImagePartsHelper(true)
|
||||
if err == nil {
|
||||
return fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", settings.Config.Server.ExternalUrl, settings.Config.Server.BaseURL, l.Hash)
|
||||
}
|
||||
return l.Banner
|
||||
}
|
||||
|
||||
func (l *Link) FaviconURL() string {
|
||||
_, _, err := l.GetShareImagePartsHelper(false)
|
||||
if err == nil {
|
||||
return fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", settings.Config.Server.ExternalUrl, settings.Config.Server.BaseURL, l.Hash)
|
||||
}
|
||||
return l.Favicon
|
||||
}
|
||||
|
||||
func (l *Link) GetShareImagePartsHelper(isBanner bool) (string, string, error) {
|
||||
// Get the asset query string from share
|
||||
var assetQueryString string
|
||||
if isBanner {
|
||||
assetQueryString = l.Banner
|
||||
} else {
|
||||
assetQueryString = l.Favicon
|
||||
}
|
||||
|
||||
if assetQueryString == "" {
|
||||
return "", "", fmt.Errorf("asset not configured for this share")
|
||||
}
|
||||
|
||||
// Parse the query string to extract source and path
|
||||
assetParams, err := url.ParseQuery(assetQueryString)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
|
||||
}
|
||||
|
||||
sourceName := assetParams.Get("source")
|
||||
assetPath := assetParams.Get("path")
|
||||
|
||||
return sourceName, assetPath, nil
|
||||
}
|
||||
@@ -46,7 +46,7 @@ type CommonShare struct {
|
||||
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
|
||||
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
|
||||
SourceURL string `json:"sourceURL,omitempty"`
|
||||
CanEditShare bool `json:"canEditShare,omitempty"`
|
||||
CanEdit bool `json:"canEdit,omitempty"`
|
||||
}
|
||||
type CreateBody struct {
|
||||
CommonShare
|
||||
|
||||
+141
-194
@@ -56,179 +56,86 @@ func (c *crudBackend) DeleteByID(id any) error {
|
||||
|
||||
// Storage is a share storage using generics.
|
||||
type Storage struct {
|
||||
Generic *crud.Storage[Link]
|
||||
back StorageBackend
|
||||
shareByHash map[string]*Link // key: link hash
|
||||
shareByPath map[string]map[string]string // key: pathKey(Source, Path), value: set of hashes (hash -> "")
|
||||
mu sync.RWMutex
|
||||
users *users.Storage
|
||||
Generic *crud.Storage[Link]
|
||||
back StorageBackend
|
||||
shareCache map[string]*Link
|
||||
mu sync.RWMutex
|
||||
users *users.Storage
|
||||
}
|
||||
|
||||
// pathKey returns the cache key for shareByPath (source + path).
|
||||
func pathKey(source, path string) string {
|
||||
return source + ":" + path
|
||||
}
|
||||
|
||||
// setCacheLocked updates both shareByHash and shareByPath for the given link.
|
||||
// Caller must hold s.mu.
|
||||
func (s *Storage) setCacheLocked(link *Link) {
|
||||
if link == nil {
|
||||
return
|
||||
}
|
||||
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
|
||||
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
|
||||
s.shareByHash[link.Hash] = link
|
||||
key := pathKey(adjustedSource, adjustedPath)
|
||||
if s.shareByPath[key] == nil {
|
||||
s.shareByPath[key] = make(map[string]string)
|
||||
}
|
||||
s.shareByPath[key][link.Hash] = ""
|
||||
}
|
||||
|
||||
// setCache updates both caches for the given link. It acquires s.mu.
|
||||
func (s *Storage) setCache(link *Link) {
|
||||
s.mu.Lock()
|
||||
s.setCacheLocked(link)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// deleteFromCacheLocked removes the link (by hash) from both caches.
|
||||
// Caller must hold s.mu.
|
||||
func (s *Storage) deleteFromCacheLocked(hash string) {
|
||||
link, ok := s.shareByHash[hash]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
|
||||
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
|
||||
key := pathKey(adjustedSource, adjustedPath)
|
||||
if inner, ok := s.shareByPath[key]; ok {
|
||||
delete(inner, hash)
|
||||
if len(inner) == 0 {
|
||||
delete(s.shareByPath, key)
|
||||
}
|
||||
}
|
||||
delete(s.shareByHash, hash)
|
||||
}
|
||||
|
||||
// deleteFromCache removes the link (by hash) from both caches. It acquires s.mu.
|
||||
func (s *Storage) deleteFromCache(hash string) {
|
||||
s.mu.Lock()
|
||||
s.deleteFromCacheLocked(hash)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// setCacheAfterMove updates both caches when a link's Source or Path changed. It acquires s.mu.
|
||||
func (s *Storage) setCacheAfterMove(link *Link, oldSource, oldPath string) {
|
||||
s.mu.Lock()
|
||||
if link == nil {
|
||||
return
|
||||
}
|
||||
adjustedOldPath := utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
adjustedOldSource := utils.AddTrailingSlashIfNotExists(oldSource)
|
||||
oldKey := pathKey(adjustedOldSource, adjustedOldPath)
|
||||
if inner, ok := s.shareByPath[oldKey]; ok {
|
||||
delete(inner, link.Hash)
|
||||
if len(inner) == 0 {
|
||||
delete(s.shareByPath, oldKey)
|
||||
}
|
||||
}
|
||||
s.setCacheLocked(link)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// NewStorage creates a share links storage from a backend and populates the
|
||||
// in-memory cache from the database so all reads can be served from cache.
|
||||
// NewStorage creates a share links storage from a backend.
|
||||
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
|
||||
s := &Storage{
|
||||
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
|
||||
back: back,
|
||||
shareByHash: make(map[string]*Link),
|
||||
shareByPath: make(map[string]map[string]string),
|
||||
users: usersStore,
|
||||
return &Storage{
|
||||
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
|
||||
back: back,
|
||||
shareCache: make(map[string]*Link),
|
||||
users: usersStore,
|
||||
}
|
||||
s.loadShareCache()
|
||||
return s
|
||||
}
|
||||
|
||||
// loadShareCache fills shareByHash and shareByPath from the backend and removes expired links from the DB.
|
||||
// Call once at startup so all reads can be served from cache.
|
||||
func (s *Storage) loadShareCache() {
|
||||
links, err := s.back.All()
|
||||
if err != nil && err != errors.ErrNotExist {
|
||||
return
|
||||
}
|
||||
if links == nil {
|
||||
links = []*Link{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
for _, link := range links {
|
||||
if link == nil {
|
||||
continue
|
||||
}
|
||||
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
|
||||
_ = s.back.Delete(link.Hash)
|
||||
continue
|
||||
}
|
||||
link.InitUserDownloads()
|
||||
s.setCacheLocked(link)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// LoadShareCacheFromDB repopulates the in-memory cache from the database.
|
||||
// Call at startup (e.g. from InitializeDb) to ensure shares are loaded after the store is ready.
|
||||
func (s *Storage) LoadShareCacheFromDB() {
|
||||
s.loadShareCache()
|
||||
}
|
||||
|
||||
// All returns all non-expired shares from the cache (populated at startup and by writes).
|
||||
// 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
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
result := make([]*Link, 0, len(s.shareByHash))
|
||||
for _, l := range s.shareByHash {
|
||||
for i, l := range filtered {
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
|
||||
_ = s.back.Delete(l.Hash)
|
||||
s.deleteFromCacheLocked(l.Hash)
|
||||
continue
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
filtered[i] = existing
|
||||
} else {
|
||||
s.shareCache[l.Hash] = l
|
||||
}
|
||||
result = append(result, l)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return result, nil
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// FindByUserID returns non-expired shares for the user from the cache.
|
||||
// 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
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
result := make([]*Link, 0)
|
||||
for _, l := range s.shareByHash {
|
||||
if l == nil || l.UserID != id {
|
||||
for i, l := range filtered {
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
|
||||
_ = s.back.Delete(l.Hash)
|
||||
s.deleteFromCacheLocked(l.Hash)
|
||||
continue
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
filtered[i] = existing
|
||||
} else {
|
||||
s.shareCache[l.Hash] = l
|
||||
}
|
||||
result = append(result, l)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return result, nil
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// 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.shareByHash[hash]; ok && link != nil {
|
||||
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.deleteFromCache(hash)
|
||||
s.mu.Lock()
|
||||
delete(s.shareCache, hash)
|
||||
s.mu.Unlock()
|
||||
return nil, errors.ErrNotExist
|
||||
}
|
||||
return link, nil
|
||||
@@ -247,7 +154,9 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
|
||||
// Initialize UserDownloads map
|
||||
link.InitUserDownloads()
|
||||
|
||||
s.setCache(link)
|
||||
s.mu.Lock()
|
||||
s.shareCache[hash] = link
|
||||
s.mu.Unlock()
|
||||
return link, nil
|
||||
}
|
||||
|
||||
@@ -255,74 +164,68 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
|
||||
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
|
||||
l, err := s.back.GetPermanent(path, source, id)
|
||||
if err == nil && l != nil {
|
||||
s.setCache(l)
|
||||
s.mu.Lock()
|
||||
|
||||
s.shareCache[l.Hash] = l
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return l, err
|
||||
}
|
||||
|
||||
// Gets returns shares for the given path, source, and user from the cache.
|
||||
// Gets wraps StorageBackend.Gets and handles expiry.
|
||||
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
|
||||
s.mu.Lock()
|
||||
adjustedPath := utils.AddTrailingSlashIfNotExists(sourcePath)
|
||||
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
|
||||
key := pathKey(adjustedSource, adjustedPath)
|
||||
hashes := s.shareByPath[key]
|
||||
result := make([]*Link, 0, len(hashes))
|
||||
for h := range hashes {
|
||||
l := s.shareByHash[h]
|
||||
if l == nil || l.UserID != id {
|
||||
continue
|
||||
}
|
||||
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
|
||||
_ = s.back.Delete(l.Hash)
|
||||
s.deleteFromCacheLocked(l.Hash)
|
||||
continue
|
||||
}
|
||||
result = append(result, l)
|
||||
links, err := s.back.Gets(sourcePath, source, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered, err := s.filterExpired(links)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetBySourcePath returns shares for the given path and source from the cache.
|
||||
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
|
||||
s.mu.Lock()
|
||||
adjustedPath := utils.AddTrailingSlashIfNotExists(path)
|
||||
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
|
||||
key := pathKey(adjustedSource, adjustedPath)
|
||||
hashes := s.shareByPath[key]
|
||||
result := make([]*Link, 0, len(hashes))
|
||||
for h := range hashes {
|
||||
l := s.shareByHash[h]
|
||||
for i, l := range filtered {
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
|
||||
_ = s.back.Delete(l.Hash)
|
||||
s.deleteFromCacheLocked(l.Hash)
|
||||
continue
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
filtered[i] = existing
|
||||
} else {
|
||||
s.shareCache[l.Hash] = l
|
||||
}
|
||||
result = append(result, l)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return result, nil
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// IsShared returns whether the given path and source have any shares in the cache.
|
||||
func (s *Storage) IsShared(path, source string, id uint) bool {
|
||||
links, _ := s.GetBySourcePath(path, source)
|
||||
for _, l := range links {
|
||||
if l.UserID == id {
|
||||
return true
|
||||
// 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
|
||||
}
|
||||
}
|
||||
return len(links) > 0
|
||||
s.mu.Unlock()
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// UpdateShares updates all shares that match oldSource and oldPath to point to newSource and newPath.
|
||||
// Handles both exact matches and subdirectories, regardless of trailing slashes.
|
||||
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
|
||||
links, err := s.All()
|
||||
links, err := s.back.All()
|
||||
if err != nil && err != errors.ErrNotExist {
|
||||
logger.Error("failed to list shares", "error", err)
|
||||
return 0, err
|
||||
@@ -352,7 +255,14 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
|
||||
return updated, err
|
||||
}
|
||||
|
||||
s.setCacheAfterMove(l, oldSource, oldPath)
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
existing.Source = l.Source
|
||||
existing.Path = l.Path
|
||||
} else {
|
||||
s.shareCache[l.Hash] = l
|
||||
}
|
||||
s.mu.Unlock()
|
||||
updated++
|
||||
}
|
||||
return updated, nil
|
||||
@@ -373,7 +283,11 @@ func (s *Storage) UpdateSharePath(hash, newPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.setCacheAfterMove(link, link.Source, oldPath)
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.shareCache[hash]; ok && existing != nil {
|
||||
existing.Path = newPath
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
|
||||
return nil
|
||||
@@ -384,7 +298,9 @@ func (s *Storage) Save(l *Link) error {
|
||||
if err := s.back.Save(l); err != nil {
|
||||
return err
|
||||
}
|
||||
s.setCache(l)
|
||||
s.mu.Lock()
|
||||
s.shareCache[l.Hash] = l
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -393,16 +309,47 @@ func (s *Storage) Delete(hash string) error {
|
||||
if err := s.back.Delete(hash); err != nil {
|
||||
return err
|
||||
}
|
||||
s.deleteFromCache(hash)
|
||||
s.mu.Lock()
|
||||
delete(s.shareCache, hash)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush is a no-op: every share write (Save, Delete, UpdateShares, UpdateSharePath) already
|
||||
// updates the database, so the cache and DB stay in sync without flushing.
|
||||
// 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
|
||||
}
|
||||
|
||||
// GetCommonShareByHash retrieves only the CommonShare attributes for a given hash
|
||||
func (s *Storage) GetCommonShareByHash(hash string) (*CommonShare, error) {
|
||||
return s.back.GetCommonShareByHash(hash)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
storm "github.com/asdine/storm/v3"
|
||||
|
||||
"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/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
@@ -157,6 +158,12 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
}
|
||||
}
|
||||
|
||||
// last revoke api keys if needed.
|
||||
if existingUser.Permissions.Api && !user.Permissions.Api && slices.Contains(fields, "Permissions") {
|
||||
for _, key := range existingUser.ApiKeys {
|
||||
auth.RevokeAPIKey(key.Key) // add to blacklist
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -173,7 +180,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pass, err := utils.HashPwd(user.Password)
|
||||
pass, err := users.HashPwd(user.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -250,7 +257,7 @@ 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", "password", "tokens", "totpsecret", "totpnonce":
|
||||
case "id", "username", "password", "apikeys", "totpsecret", "totpnonce":
|
||||
// Skip these fields
|
||||
continue
|
||||
}
|
||||
@@ -292,7 +299,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("password does not meet complexity requirements")
|
||||
}
|
||||
value, err := utils.HashPwd(user.Password)
|
||||
value, err := users.HashPwd(user.Password)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ import (
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
func init() {
|
||||
utils.BcryptCost = 4 // bcrypt.MinCost for faster tests
|
||||
users.BcryptCost = 4 // bcrypt.MinCost for faster tests
|
||||
|
||||
// Initialize test source configuration
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
|
||||
@@ -37,8 +37,6 @@ func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
|
||||
// Load access rules from DB on startup
|
||||
// ignoring errors because
|
||||
_ = store.Access.LoadFromDB()
|
||||
// Load share cache from DB so shareByHash/shareByPath are populated at startup
|
||||
store.Share.LoadShareCacheFromDB()
|
||||
userStore = store.Users
|
||||
err = bolt.Save(db, "version", 2)
|
||||
if err != nil {
|
||||
@@ -118,7 +116,7 @@ func CreateUser(userInfo users.User, permissions users.Permissions) error {
|
||||
return fmt.Errorf("password is required to create a password login user")
|
||||
}
|
||||
} else {
|
||||
hashpass, err := utils.HashPwd(userInfo.Username)
|
||||
hashpass, err := users.HashPwd(userInfo.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
|
||||
newLinks := []SidebarLink{}
|
||||
for _, link := range u.SidebarLinks {
|
||||
// For source links, validate that the source exists using SourceName
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func (u *User) GetFrontendSidebarLinks() []SidebarLink {
|
||||
newLinks := []SidebarLink{}
|
||||
for _, link := range u.SidebarLinks {
|
||||
// For source links, validate that the source still exists
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var BcryptCost = bcrypt.DefaultCost
|
||||
|
||||
// HashPwd hashes a password.
|
||||
func HashPwd(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPwd checks if a password is correct.
|
||||
func CheckPwd(password, hash string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
@@ -26,8 +26,8 @@ type Store interface {
|
||||
Save(user *User, changePass bool, disableScopeChange bool) error
|
||||
Delete(id interface{}) error
|
||||
LastUpdate(id uint) int64
|
||||
AddApiToken(userID uint, name string, tokenString string, metadata AuthToken) error
|
||||
DeleteApiToken(userID uint, name string) error
|
||||
AddApiKey(username uint, name string, key AuthToken) error
|
||||
DeleteApiKey(username uint, name string) error
|
||||
}
|
||||
|
||||
// crudBackend implements crud.CrudBackend[User] for users storage.
|
||||
@@ -114,18 +114,17 @@ func (s *Storage) Update(user *User, adminIActor bool, fields ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) AddApiToken(userID uint, name string, tokenString string, metadata AuthToken) error {
|
||||
func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
|
||||
user, err := s.Get(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Initialize the TokenHashes map if it is nil
|
||||
if user.Tokens == nil {
|
||||
user.Tokens = make(map[string]AuthToken)
|
||||
// Initialize the ApiKeys map if it is nil
|
||||
if user.ApiKeys == nil {
|
||||
user.ApiKeys = make(map[string]AuthToken)
|
||||
}
|
||||
metadata.Token = tokenString
|
||||
user.Tokens[name] = metadata
|
||||
err = s.Update(user, true, "Tokens")
|
||||
user.ApiKeys[name] = key
|
||||
err = s.Update(user, true, "ApiKeys")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -133,17 +132,17 @@ func (s *Storage) AddApiToken(userID uint, name string, tokenString string, meta
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteApiToken(userID uint, name string) error {
|
||||
func (s *Storage) DeleteApiKey(userID uint, name string) error {
|
||||
user, err := s.Get(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Initialize the Tokens map if it is nil
|
||||
if user.Tokens == nil {
|
||||
user.Tokens = make(map[string]AuthToken)
|
||||
// Initialize the ApiKeys map if it is nil
|
||||
if user.ApiKeys == nil {
|
||||
user.ApiKeys = make(map[string]AuthToken)
|
||||
}
|
||||
delete(user.Tokens, name)
|
||||
err = s.Update(user, true, "Tokens")
|
||||
delete(user.ApiKeys, name)
|
||||
err = s.Update(user, true, "ApiKeys")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,18 +15,13 @@ const (
|
||||
LoginMethodPassword LoginMethod = "password"
|
||||
LoginMethodProxy LoginMethod = "proxy"
|
||||
LoginMethodOidc LoginMethod = "oidc"
|
||||
LoginMethodLdap LoginMethod = "ldap"
|
||||
LoginMethodJwt LoginMethod = "jwt"
|
||||
)
|
||||
|
||||
type AuthToken struct {
|
||||
MinimalAuthToken
|
||||
Key string `json:"key,omitempty"` // for backward compatibility
|
||||
Token string `json:"token,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
BelongsTo uint `json:"belongsTo,omitempty"`
|
||||
IssuedAt int64 `json:"issuedAt,omitempty"`
|
||||
ExpiresAt int64 `json:"expiresAt,omitempty"`
|
||||
Permissions Permissions `json:"Permissions,omitempty"`
|
||||
}
|
||||
|
||||
@@ -54,16 +49,15 @@ type Sorting struct {
|
||||
|
||||
type Preview struct {
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // disable the hide sidebar preview for previews and editors
|
||||
HighQuality bool `json:"highQuality"` // generate high quality thumbnail preview images
|
||||
Image bool `json:"image"` // show thumbnail preview image for image files
|
||||
Video bool `json:"video"` // show thumbnail preview image for video files
|
||||
Audio bool `json:"audio"` // show thumbnail preview image for audio files
|
||||
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
Office bool `json:"office"` // show thumbnail preview image for office files
|
||||
PopUp bool `json:"popup"` // show larger popup preview when hovering over thumbnail
|
||||
AutoplayMedia bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable html5 media player and use the default media player
|
||||
Folder bool `json:"folder"` // show thumbnail preview image for folder files
|
||||
Models bool `json:"models"` // show live thumbnails for 3D models files
|
||||
}
|
||||
|
||||
// User describes a user.
|
||||
@@ -76,16 +70,15 @@ type User struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
Permissions Permissions `json:"permissions"`
|
||||
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
|
||||
Tokens map[string]AuthToken `json:"tokens,omitempty"`
|
||||
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
|
||||
TOTPSecret string `json:"totpSecret,omitempty"`
|
||||
TOTPNonce string `json:"totpNonce,omitempty"`
|
||||
LoginMethod LoginMethod `json:"loginMethod"`
|
||||
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
|
||||
Version int `json:"version"`
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
// legacy for migration purposes... og filebrowser has perm attribute
|
||||
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
|
||||
Perm Permissions `json:"perm,omitzero"`
|
||||
Version int `json:"version"`
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
}
|
||||
|
||||
type SourceScope struct {
|
||||
@@ -124,9 +117,6 @@ type NonAdminEditable struct {
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
@@ -139,7 +129,7 @@ type FileLoading struct {
|
||||
// SidebarLink represents a customizable link in the sidebar.
|
||||
type SidebarLink struct {
|
||||
Name string `json:"name"` // Display name of the link
|
||||
Category string `json:"category"` // Category type: "source", "source-link", "share", "tool", "custom", etc.
|
||||
Category string `json:"category"` // Category type: "source", "share", "tool", "custom", etc.
|
||||
Target string `json:"target"` // Target path/URL for the link (relative for source/share)
|
||||
Icon string `json:"icon"` // Material icon name
|
||||
SourceName string `json:"sourceName,omitempty"` // Source identifier for source-type links
|
||||
|
||||
@@ -11,16 +11,12 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
|
||||
// GetImageOrientation extracts the EXIF orientation from an image file using exiftool
|
||||
func (s *FFmpegService) GetImageOrientation(imagePath string) (string, error) {
|
||||
exiftoolPath := settings.Config.Integrations.Media.ExiftoolPath
|
||||
if exiftoolPath == "" {
|
||||
return "Horizontal (normal)", nil
|
||||
}
|
||||
cmd := exec.Command(exiftoolPath, "-Orientation", "-s3", imagePath)
|
||||
// Use exiftool to get orientation information
|
||||
cmd := exec.Command("exiftool", "-Orientation", "-s3", imagePath)
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
+13
-16
@@ -9,7 +9,6 @@ require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/gen2brain/go-fitz v1.24.15
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
@@ -17,22 +16,22 @@ require (
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-logger v1.0.2
|
||||
github.com/kovidgoyal/imaging v1.8.20
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/mod v0.34.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.43.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/mod v0.33.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/tools v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.47.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -50,7 +49,6 @@ require (
|
||||
github.com/Antonboom/errname v1.1.1 // indirect
|
||||
github.com/Antonboom/nilnil v1.1.1 // indirect
|
||||
github.com/Antonboom/testifylint v1.6.4 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/Djarvur/go-err113 v0.1.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
@@ -110,7 +108,6 @@ require (
|
||||
github.com/fzipp/gocyclo v0.6.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.20 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-critic/go-critic v0.14.3 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
@@ -275,13 +272,13 @@ require (
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/libc v1.69.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
|
||||
+28
-48
@@ -59,8 +59,6 @@ github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksuf
|
||||
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
|
||||
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
|
||||
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
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=
|
||||
@@ -96,8 +94,6 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
|
||||
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
|
||||
@@ -281,8 +277,6 @@ 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.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
|
||||
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
|
||||
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
@@ -299,8 +293,6 @@ github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
|
||||
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=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
@@ -528,18 +520,6 @@ 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/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
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=
|
||||
@@ -644,8 +624,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
|
||||
@@ -928,8 +908,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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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=
|
||||
@@ -948,8 +928,8 @@ golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjO
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
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.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
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=
|
||||
@@ -975,8 +955,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
||||
golang.org/x/mod v0.8.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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
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=
|
||||
@@ -1019,16 +999,16 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1044,8 +1024,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1098,8 +1078,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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -1116,13 +1096,13 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
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=
|
||||
@@ -1173,8 +1153,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
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=
|
||||
@@ -1293,8 +1273,8 @@ honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
|
||||
modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
@@ -1303,8 +1283,8 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
|
||||
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -1313,8 +1293,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
+161
-111
@@ -3,52 +3,90 @@ package http
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
// createApiTokenHandler creates an API token for the user.
|
||||
// @Summary Create API Token
|
||||
// @Description Create an API token with specified name, duration, and permissions.
|
||||
// @Tags Auth
|
||||
var (
|
||||
ApiTokenUserCache = cache.NewCache[uint](24 * time.Hour) // API token to user ID mapping gets cached for 24 hours
|
||||
)
|
||||
|
||||
// getUserFromApiToken finds the user ID associated with a given API token
|
||||
func getUserFromApiToken(token string) (uint, error) {
|
||||
// Check cache first
|
||||
if cached, ok := ApiTokenUserCache.Get(token); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Get all users
|
||||
allUsers, err := store.Users.Gets()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get all users: %w", err)
|
||||
}
|
||||
|
||||
// Iterate through all users and their API keys
|
||||
for _, user := range allUsers {
|
||||
if user.ApiKeys == nil {
|
||||
continue
|
||||
}
|
||||
for _, apiKey := range user.ApiKeys {
|
||||
if apiKey.Key == token {
|
||||
// Cache the result
|
||||
ApiTokenUserCache.Set(token, user.ID)
|
||||
return user.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Token not found
|
||||
return 0, fmt.Errorf("token not found")
|
||||
}
|
||||
|
||||
// createApiKeyHandler creates an API key for the user.
|
||||
// @Summary Create API key
|
||||
// @Description Create an API key with specified name, duration, and permissions.
|
||||
// @Tags API Keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name query string true "Name of the API token"
|
||||
// @Param days query string true "Duration of the API token in days"
|
||||
// @Param permissions query string true "Permissions for the API token (comma-separated)"
|
||||
// @Success 200 {object} HttpResponse "Token created successfully, response contains json object with token"
|
||||
// @Param name query string true "Name of the API key"
|
||||
// @Param days query string true "Duration of the API key in days"
|
||||
// @Param permissions query string true "Permissions for the API key (comma-separated)"
|
||||
// @Success 200 {object} HttpResponse "Token created successfully, response contains json object with token key"
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Failure 409 {object} map[string]string "Conflict"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/token [post]
|
||||
func createApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// @Router /api/auth/token [put]
|
||||
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
name := r.URL.Query().Get("name")
|
||||
durationStr := r.URL.Query().Get("days")
|
||||
permissionsStr := r.URL.Query().Get("permissions")
|
||||
minimal := permissionsStr == ""
|
||||
minimal := r.URL.Query().Get("minimal") == "true"
|
||||
|
||||
if !d.user.Permissions.Api {
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api tokens")
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api keys")
|
||||
}
|
||||
|
||||
if name == "" || strings.HasPrefix(name, "WEB_TOKEN") {
|
||||
return http.StatusBadRequest, fmt.Errorf("api token name must be valid")
|
||||
if name == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("api name must be valid")
|
||||
}
|
||||
if durationStr == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("api token duration must be valid")
|
||||
return http.StatusBadRequest, fmt.Errorf("api duration must be valid")
|
||||
}
|
||||
|
||||
// For full tokens (minimal=false), permissions are required in the claim
|
||||
// For minimal tokens (minimal=true), permissions are not in the token
|
||||
var permissions users.Permissions
|
||||
if !minimal {
|
||||
if permissionsStr == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("api permissions must be valid for full tokens")
|
||||
}
|
||||
// Parse permissions from the query parameter
|
||||
permissions = users.Permissions{
|
||||
Api: strings.Contains(permissionsStr, "api") && d.user.Permissions.Api,
|
||||
@@ -70,144 +108,156 @@ func createApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
|
||||
// Here we assume the duration is in seconds; convert to time.Duration
|
||||
duration := time.Duration(durationInt) * time.Hour * 24
|
||||
tokenString, authToken, err := auth.MakeSignedTokenAPI(d.user, name, duration, permissions, minimal)
|
||||
// get request body like:
|
||||
token, err := makeSignedTokenAPI(d.user, name, duration, permissions, minimal)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "key already exists with same name") {
|
||||
return http.StatusConflict, err
|
||||
}
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Store API token metadata in user's Tokens map
|
||||
err = store.Users.AddApiToken(d.user.ID, name, tokenString, authToken)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Store token hash → user ID mapping in access storage for fast lookups
|
||||
err = store.Access.AddApiToken(tokenString, d.user.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
response := HttpResponse{
|
||||
Message: "here is your token!",
|
||||
Token: tokenString,
|
||||
Token: token.Key,
|
||||
}
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
// deleteApiTokenHandler deletes an API token for the user.
|
||||
// @Summary Delete API token
|
||||
// @Description Delete an API token with specified name.
|
||||
// @Tags Auth
|
||||
// deleteApiKeyHandler deletes an API key for the user.
|
||||
// @Summary Delete API key
|
||||
// @Description Delete an API key with specified name.
|
||||
// @Tags API Keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name query string true "Name of the API token to delete"
|
||||
// @Success 200 {object} HttpResponse "API token deleted successfully"
|
||||
// @Param name query string true "Name of the API key to delete"
|
||||
// @Success 200 {object} HttpResponse "API key deleted successfully"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/token [delete]
|
||||
func deleteApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if !d.user.Permissions.Api {
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api tokens")
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api keys")
|
||||
}
|
||||
|
||||
tokenInfo, ok := d.user.Tokens[name]
|
||||
keyInfo, ok := d.user.ApiKeys[name]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("api token not found")
|
||||
return http.StatusNotFound, fmt.Errorf("api key not found")
|
||||
}
|
||||
|
||||
// Perform the user update
|
||||
err := store.Users.DeleteApiToken(d.user.ID, name)
|
||||
err := store.Users.DeleteApiKey(d.user.ID, name)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
|
||||
// Revoke the token (adds to RevokedTokens set)
|
||||
if err := auth.RevokeApiToken(store.Access, tokenInfo.Token); err != nil {
|
||||
logger.Errorf("Failed to revoke token: %v", err)
|
||||
}
|
||||
if err := store.Access.RemoveApiToken(tokenInfo.Token); err != nil {
|
||||
logger.Errorf("Failed to remove api token: %v", err)
|
||||
}
|
||||
|
||||
auth.RevokeAPIKey(keyInfo.Key) // add to blacklist
|
||||
response := HttpResponse{
|
||||
Message: "successfully deleted api token from user",
|
||||
Message: "successfully deleted api key from user",
|
||||
}
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
type AuthTokenFrontend struct {
|
||||
Token string `json:"token"`
|
||||
type AuthTokenMin struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
IssuedAt int64 `json:"issuedAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Created int64 `json:"created"`
|
||||
Expires int64 `json:"expires"`
|
||||
Permissions users.Permissions `json:"Permissions,omitempty"`
|
||||
Minimal bool `json:"minimal"`
|
||||
}
|
||||
|
||||
// listApiTokensHandler lists all API tokens or retrieves details for a specific token.
|
||||
// @Summary List API tokens
|
||||
// @Description List all API tokens or retrieve details for a specific token.
|
||||
// @Tags Auth
|
||||
// listApiKeysHandler lists all API keys or retrieves details for a specific key.
|
||||
// @Summary List API keys
|
||||
// @Description List all API keys or retrieve details for a specific key.
|
||||
// @Tags API Keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} AuthTokenFrontend "List of API tokens"
|
||||
// @Param name query string false "Name of the API to retrieve details"
|
||||
// @Success 200 {object} AuthTokenMin "List of API keys or specific key details"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/token/list [get]
|
||||
func listApiTokensHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// @Router /api/auth/tokens [get]
|
||||
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
key := r.URL.Query().Get("name")
|
||||
if !d.user.Permissions.Api {
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api tokens")
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api keys")
|
||||
}
|
||||
if len(d.user.Tokens) == 0 {
|
||||
return http.StatusNotFound, fmt.Errorf("no api tokens found")
|
||||
}
|
||||
AuthTokensFrontend := make([]AuthTokenFrontend, 0, len(d.user.Tokens))
|
||||
for name, token := range d.user.Tokens {
|
||||
AuthTokensFrontend = append(AuthTokensFrontend, AuthTokenFrontend{
|
||||
Token: token.Token,
|
||||
Name: name,
|
||||
IssuedAt: token.RegisteredClaims.IssuedAt.Unix(),
|
||||
ExpiresAt: token.RegisteredClaims.ExpiresAt.Unix(),
|
||||
Permissions: token.Permissions,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(AuthTokensFrontend, func(i, j int) bool {
|
||||
return AuthTokensFrontend[i].Name < AuthTokensFrontend[j].Name
|
||||
})
|
||||
|
||||
return renderJSON(w, r, AuthTokensFrontend)
|
||||
}
|
||||
if key != "" {
|
||||
keyInfo, ok := d.user.ApiKeys[key]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("api key not found")
|
||||
}
|
||||
// Determine if token is minimal by parsing the JWT claim
|
||||
// For minimal tokens, the JWT claim doesn't have BelongsTo even though the database record does
|
||||
minimal := false
|
||||
if keyInfo.Key != "" {
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.Auth.Key), nil
|
||||
}
|
||||
var claim users.AuthToken
|
||||
token, err := jwt.ParseWithClaims(keyInfo.Key, &claim, keyFunc)
|
||||
if err == nil && token.Valid {
|
||||
// If the parsed claim doesn't have BelongsTo, it's a minimal token
|
||||
minimal = claim.BelongsTo == 0
|
||||
}
|
||||
}
|
||||
modifiedKey := AuthTokenMin{
|
||||
Key: keyInfo.Key,
|
||||
Name: key,
|
||||
Created: 0,
|
||||
Expires: 0,
|
||||
Minimal: minimal,
|
||||
}
|
||||
// Safely access IssuedAt and ExpiresAt (they're pointers)
|
||||
if keyInfo.IssuedAt != nil {
|
||||
modifiedKey.Created = keyInfo.IssuedAt.Unix()
|
||||
}
|
||||
if keyInfo.ExpiresAt != nil {
|
||||
modifiedKey.Expires = keyInfo.ExpiresAt.Unix()
|
||||
}
|
||||
// Only include Permissions for full tokens (not minimal tokens)
|
||||
if !minimal {
|
||||
modifiedKey.Permissions = keyInfo.Permissions
|
||||
}
|
||||
return renderJSON(w, r, modifiedKey)
|
||||
}
|
||||
|
||||
// getApiTokenHandler gets a specific API token.
|
||||
// @Summary Get API token
|
||||
// @Description Get a specific API token.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name query string true "Name of the API token to retrieve"
|
||||
// @Success 200 {object} AuthTokenFrontend "API token details"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/token [get]
|
||||
func getApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if !d.user.Permissions.Api {
|
||||
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api tokens")
|
||||
modifiedList := map[string]AuthTokenMin{}
|
||||
for key, value := range d.user.ApiKeys {
|
||||
// Determine if token is minimal by parsing the JWT claim
|
||||
// For minimal tokens, the JWT claim doesn't have BelongsTo even though the database record does
|
||||
minimal := false
|
||||
if value.Key != "" {
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.Auth.Key), nil
|
||||
}
|
||||
var claim users.AuthToken
|
||||
token, err := jwt.ParseWithClaims(value.Key, &claim, keyFunc)
|
||||
if err == nil && token.Valid {
|
||||
// If the parsed claim doesn't have BelongsTo, it's a minimal token
|
||||
minimal = claim.BelongsTo == 0
|
||||
}
|
||||
}
|
||||
modifiedKey := AuthTokenMin{
|
||||
Key: value.Key,
|
||||
Created: 0,
|
||||
Expires: 0,
|
||||
Minimal: minimal,
|
||||
}
|
||||
// Safely access IssuedAt and ExpiresAt (they're pointers)
|
||||
if value.IssuedAt != nil {
|
||||
modifiedKey.Created = value.IssuedAt.Unix()
|
||||
}
|
||||
if value.ExpiresAt != nil {
|
||||
modifiedKey.Expires = value.ExpiresAt.Unix()
|
||||
}
|
||||
// Only include Permissions for full tokens (not minimal tokens)
|
||||
if !minimal {
|
||||
modifiedKey.Permissions = value.Permissions
|
||||
}
|
||||
modifiedList[key] = modifiedKey
|
||||
}
|
||||
tokenInfo, ok := d.user.Tokens[name]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("api token not found")
|
||||
}
|
||||
AuthTokenFrontendResponse := AuthTokenFrontend{
|
||||
Token: tokenInfo.Token,
|
||||
Name: name,
|
||||
IssuedAt: tokenInfo.IssuedAt,
|
||||
ExpiresAt: tokenInfo.ExpiresAt,
|
||||
Permissions: tokenInfo.Permissions,
|
||||
}
|
||||
return renderJSON(w, r, AuthTokenFrontendResponse)
|
||||
|
||||
return renderJSON(w, r, modifiedList)
|
||||
}
|
||||
@@ -1,785 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// archiveCreateHandler creates an archive on the server at the given destination.
|
||||
// POST /resources/archive — server-side only; does not return archive data.
|
||||
//
|
||||
// @Summary Create an archive on the server
|
||||
// @Description Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
|
||||
// @Description
|
||||
// @Description **Request body parameters:**
|
||||
// @Description - **fromSource** (string, required): Source name where the paths to archive live. Example: `"default"`
|
||||
// @Description - **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: `"backups"`
|
||||
// @Description - **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: `["/docs/file.txt", "/photos"]`
|
||||
// @Description - **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: `"/backups/my-archive.zip"`
|
||||
// @Description - **format** (string, optional): Archive format. One of: `"zip"`, `"tar.gz"`. Default inferred from destination extension. Example: `"zip"`
|
||||
// @Description - **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: `6`
|
||||
// @Description - **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: `true`
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body archiveCreateRequest true "Request body: fromSource, toSource (optional), paths, destination, format (optional), compression (optional)"
|
||||
// @Success 200 {object} map[string]string "Created; returns {\"path\": \"<destination path>\"}"
|
||||
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, invalid path)"
|
||||
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
|
||||
// @Failure 404 {object} map[string]string "Source not found"
|
||||
// @Failure 413 {object} map[string]string "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources/archive [post]
|
||||
func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share != nil {
|
||||
return http.StatusForbidden, fmt.Errorf("archive create not allowed for shares")
|
||||
}
|
||||
if !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create resources")
|
||||
}
|
||||
|
||||
var req archiveCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
|
||||
}
|
||||
if req.FromSource == "" || len(req.Paths) == 0 || req.Destination == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("fromSource, paths, and destination are required")
|
||||
}
|
||||
|
||||
destClean, err := utils.SanitizeUserPath(req.Destination)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination path: %v", err)
|
||||
}
|
||||
req.Destination = destClean
|
||||
pathsClean := make([]string, 0, len(req.Paths))
|
||||
for _, p := range req.Paths {
|
||||
var clean string
|
||||
clean, err = utils.SanitizeUserPath(p)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path %q: %v", p, err)
|
||||
}
|
||||
pathsClean = append(pathsClean, clean)
|
||||
}
|
||||
req.Paths = pathsClean
|
||||
|
||||
destSource := req.ToSource
|
||||
if destSource == "" {
|
||||
destSource = req.FromSource
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(req.FromSource)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", req.FromSource)
|
||||
}
|
||||
userScope, err := d.user.GetScopeForSourceName(req.FromSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
// Resolve destination on ToSource (or Source if not set)
|
||||
idxTo := indexing.GetIndex(destSource)
|
||||
if idxTo == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", destSource)
|
||||
}
|
||||
userScopeTo, err := d.user.GetScopeForSourceName(destSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullDest := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDest, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
// Destination is a file we are about to create; resolve parent dir only (parent must exist or be creatable).
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
}
|
||||
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
|
||||
}
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
}
|
||||
destRealPath := filepath.Join(destParentReal, filepath.Base(req.Destination))
|
||||
if err = os.MkdirAll(destParentReal, fileutils.PermDir); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
|
||||
}
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(req.Format))
|
||||
if format == "" {
|
||||
ext := strings.ToLower(filepath.Ext(destRealPath))
|
||||
if ext == ".gz" && len(destRealPath) > 3 && strings.HasSuffix(strings.ToLower(destRealPath), ".tar.gz") {
|
||||
format = "tar.gz"
|
||||
} else if ext == ".zip" {
|
||||
format = "zip"
|
||||
} else {
|
||||
format = "zip"
|
||||
}
|
||||
}
|
||||
if format != "zip" && format != "tar.gz" {
|
||||
return http.StatusBadRequest, fmt.Errorf("format must be zip or tar.gz")
|
||||
}
|
||||
|
||||
compression := req.Compression
|
||||
if compression < 0 || compression > 9 {
|
||||
compression = 0
|
||||
}
|
||||
|
||||
// Build full paths for items (same source)
|
||||
itemPaths := make([]string, 0, len(req.Paths))
|
||||
for _, it := range req.Paths {
|
||||
full := utils.JoinPathAsUnix(userScope, it)
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, full, d.user.Username) {
|
||||
continue // silently skip
|
||||
}
|
||||
itemPaths = append(itemPaths, full)
|
||||
}
|
||||
if len(itemPaths) == 0 {
|
||||
return http.StatusBadRequest, fmt.Errorf("no paths accessible; add at least one path you have access to")
|
||||
}
|
||||
|
||||
// Check archive size limit if configured
|
||||
if config.Server.MaxArchiveSizeGB > 0 {
|
||||
var estimatedSize int64
|
||||
estimatedSize, err = computeArchiveSize(req.FromSource, itemPaths, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to compute archive size: %v", err)
|
||||
}
|
||||
maxSizeBytes := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
|
||||
if estimatedSize > maxSizeBytes {
|
||||
return http.StatusRequestEntityTooLarge, fmt.Errorf("archive size would exceed the maximum allowed size (maxArchiveSize: %d GB)", config.Server.MaxArchiveSizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
var createErr error
|
||||
file, err := os.OpenFile(destRealPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if format == "zip" {
|
||||
createErr = createZip(d, req.FromSource, file, itemPaths...)
|
||||
} else {
|
||||
createErr = createTarGzWithLevel(d, req.FromSource, file, compression, itemPaths...)
|
||||
}
|
||||
if createErr != nil {
|
||||
return http.StatusInternalServerError, createErr
|
||||
}
|
||||
|
||||
if req.DeleteAfter && d.user.Permissions.Delete {
|
||||
type itemToDelete struct {
|
||||
realPath string
|
||||
isDir bool
|
||||
}
|
||||
var toDelete []itemToDelete
|
||||
for _, full := range itemPaths {
|
||||
realPath, isDir, err := idx.GetRealPath(full)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
toDelete = append(toDelete, itemToDelete{realPath: realPath, isDir: isDir})
|
||||
}
|
||||
for i := 0; i < len(toDelete); i++ {
|
||||
for j := i + 1; j < len(toDelete); j++ {
|
||||
if len(toDelete[j].realPath) > len(toDelete[i].realPath) {
|
||||
toDelete[i], toDelete[j] = toDelete[j], toDelete[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range toDelete {
|
||||
if err := files.DeleteFiles(req.FromSource, item.realPath, item.isDir); err != nil {
|
||||
logger.Errorf("Failed to delete source after archive: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return renderJSON(w, r, map[string]string{"path": req.Destination}, http.StatusOK)
|
||||
}
|
||||
|
||||
// unarchiveHandler extracts an archive on the server. POST /resources/unarchive — server-side only.
|
||||
//
|
||||
// @Summary Extract an archive on the server
|
||||
// @Description Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
|
||||
// @Description
|
||||
// @Description **Request body parameters:**
|
||||
// @Description - **fromSource** (string, required): Source name where the archive file lives. Example: `"default"`
|
||||
// @Description - **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: `"restored"`
|
||||
// @Description - **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: `"/downloads/data.zip"`
|
||||
// @Description - **destination** (string, required): Directory path (on toSource) to extract into. Example: `"/projects/imported"`
|
||||
// @Description - **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: `true`
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body unarchiveRequest true "Request body: fromSource, toSource (optional), path, destination, deleteAfter (optional)"
|
||||
// @Success 200 {object} map[string]string "Extracted; returns {\"path\": \"<destination path>\", \"source\": \"<toSource>\"}"
|
||||
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, unsupported format)"
|
||||
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
|
||||
// @Failure 404 {object} map[string]string "Source or archive file not found"
|
||||
// @Failure 413 {object} map[string]string "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources/unarchive [post]
|
||||
func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share != nil {
|
||||
return http.StatusForbidden, fmt.Errorf("unarchive not allowed for shares")
|
||||
}
|
||||
if !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create resources")
|
||||
}
|
||||
|
||||
var req unarchiveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
|
||||
}
|
||||
if req.FromSource == "" || req.Path == "" || req.Destination == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("fromSource, path, and destination are required")
|
||||
}
|
||||
if req.ToSource == "" {
|
||||
req.ToSource = req.FromSource
|
||||
}
|
||||
|
||||
pathClean, err := utils.SanitizeUserPath(req.Path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
destClean, err := utils.SanitizeUserPath(req.Destination)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination: %v", err)
|
||||
}
|
||||
req.Path = pathClean
|
||||
req.Destination = destClean
|
||||
|
||||
idxFrom := indexing.GetIndex(req.FromSource)
|
||||
if idxFrom == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", req.FromSource)
|
||||
}
|
||||
userScopeFrom, err := d.user.GetScopeForSourceName(req.FromSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
fullArchivePath := utils.JoinPathAsUnix(userScopeFrom, req.Path)
|
||||
if store.Access != nil && !store.Access.Permitted(idxFrom.Path, fullArchivePath, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to archive %s", req.Path)
|
||||
}
|
||||
archiveReal, _, err := idxFrom.GetRealPath(fullArchivePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("archive path not found: %v", err)
|
||||
}
|
||||
|
||||
idxTo := indexing.GetIndex(req.ToSource)
|
||||
if idxTo == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", req.ToSource)
|
||||
}
|
||||
userScopeTo, err := d.user.GetScopeForSourceName(req.ToSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullDestPath := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestPath, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
// Destination may not exist yet; resolve parent dir then build destination path.
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
}
|
||||
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
|
||||
}
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
}
|
||||
destReal := filepath.Join(destParentReal, filepath.Base(req.Destination))
|
||||
if err = os.MkdirAll(destReal, fileutils.PermDir); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(archiveReal)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("archive not found: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is not an archive file: %s", req.Path)
|
||||
}
|
||||
|
||||
// Check archive size limit if configured
|
||||
if config.Server.MaxArchiveSizeGB > 0 {
|
||||
maxSizeBytes := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
|
||||
if info.Size() > maxSizeBytes {
|
||||
return http.StatusRequestEntityTooLarge, fmt.Errorf("archive size would exceed the maximum allowed size (maxArchiveSize: %d GB)", config.Server.MaxArchiveSizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
lower := strings.ToLower(archiveReal)
|
||||
var extractErr error
|
||||
if strings.HasSuffix(lower, ".zip") {
|
||||
extractErr = extractZip(archiveReal, destReal)
|
||||
} else if strings.HasSuffix(lower, ".tar.gz") || (strings.HasSuffix(lower, ".tgz")) {
|
||||
extractErr = extractTarGz(archiveReal, destReal)
|
||||
} else {
|
||||
return http.StatusBadRequest, fmt.Errorf("unsupported archive format (use .zip or .tar.gz)")
|
||||
}
|
||||
if extractErr != nil {
|
||||
return http.StatusInternalServerError, extractErr
|
||||
}
|
||||
|
||||
if req.DeleteAfter {
|
||||
if err := os.Remove(archiveReal); err != nil {
|
||||
logger.Errorf("Failed to delete archive after extract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return renderJSON(w, r, map[string]string{"path": req.Destination, "source": req.ToSource}, http.StatusOK)
|
||||
}
|
||||
|
||||
// addFile adds a file or directory to a tar or zip archive, respecting access rules.
|
||||
// For shares, path is already resolved; for users, access is checked via store.Access.
|
||||
func addFile(source string, path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
// Check access control directly for each file and silently skip if access is denied
|
||||
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
return nil // Silently skip this file/folder
|
||||
}
|
||||
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the base name of the top-level folder or file
|
||||
baseName := filepath.Base(realPath)
|
||||
|
||||
if info.IsDir() {
|
||||
// Walk through directory contents
|
||||
return filepath.Walk(realPath, func(filePath string, fileInfo os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate the relative path
|
||||
relPath, err := filepath.Rel(realPath, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Normalize for tar: convert \ to /
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
|
||||
// Skip adding `.` (current directory)
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check access control for each file/folder during walk
|
||||
if d.share == nil {
|
||||
indexRelPath := utils.JoinPathAsUnix(path, relPath)
|
||||
indexRelPath = filepath.ToSlash(indexRelPath)
|
||||
if !store.Access.Permitted(idx.Path, indexRelPath, d.user.Username) {
|
||||
if fileInfo.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend base folder name unless flatten is true
|
||||
if !flatten {
|
||||
relPath = filepath.Join(baseName, relPath)
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
if tarWriter != nil {
|
||||
header := &tar.Header{
|
||||
Name: relPath + "/",
|
||||
Mode: int64(fileutils.PermDir),
|
||||
Typeflag: tar.TypeDir,
|
||||
ModTime: fileInfo.ModTime(),
|
||||
}
|
||||
return tarWriter.WriteHeader(header)
|
||||
}
|
||||
if zipWriter != nil {
|
||||
_, err := zipWriter.Create(relPath + "/")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
|
||||
})
|
||||
}
|
||||
// For a single file, use the base name as the archive path
|
||||
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
|
||||
}
|
||||
|
||||
// addSingleFile writes one file into the given zip or tar writer.
|
||||
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "is a directory") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if tarWriter != nil {
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.ToSlash(archivePath)
|
||||
if err = tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
return err
|
||||
}
|
||||
|
||||
if zipWriter != nil {
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = archivePath
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createZip writes a ZIP archive into w containing the given paths; access rules apply.
|
||||
func createZip(d *requestContext, source string, w io.Writer, filenames ...string) error {
|
||||
zipWriter := zip.NewWriter(w)
|
||||
|
||||
for _, filepath := range filenames {
|
||||
err := addFile(source, filepath, d, nil, zipWriter, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to ZIP: %v", filepath, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize ZIP archive: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTarGz writes a tar.gz archive into w containing the given paths; access rules apply.
|
||||
func createTarGz(d *requestContext, source string, w io.Writer, filenames ...string) error {
|
||||
gzWriter := gzip.NewWriter(w)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
for _, filepath := range filenames {
|
||||
err := addFile(source, filepath, d, tarWriter, nil, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", filepath, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize TAR archive: %w", err)
|
||||
}
|
||||
if err := gzWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize GZIP compression: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTarGzWithLevel writes a tar.gz archive into w with the given gzip compression level (0=default, 1-9).
|
||||
func createTarGzWithLevel(d *requestContext, source string, w io.Writer, level int, filenames ...string) error {
|
||||
var gzWriter *gzip.Writer
|
||||
if level >= 1 && level <= 9 {
|
||||
var err error
|
||||
gzWriter, err = gzip.NewWriterLevel(w, level)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
gzWriter = gzip.NewWriter(w)
|
||||
}
|
||||
defer gzWriter.Close()
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
defer tarWriter.Close()
|
||||
|
||||
for _, filepath := range filenames {
|
||||
err := addFile(source, filepath, d, tarWriter, nil, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", filepath, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize TAR archive: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildAndStreamArchive resolves paths, creates a zip or tar.gz archive, and streams it to w.
|
||||
// It respects access rules. Used only by the raw handler for multi-file/directory download.
|
||||
func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
realPath, isDir, err := idx.GetRealPath(fileList[0])
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to get real path for %s: %v", fileList[0], err)
|
||||
}
|
||||
|
||||
algo := r.URL.Query().Get("algo")
|
||||
var extension string
|
||||
switch algo {
|
||||
case "zip", "true", "":
|
||||
extension = ".zip"
|
||||
case "tar.gz":
|
||||
extension = ".tar.gz"
|
||||
default:
|
||||
return http.StatusInternalServerError, errors.New("format not implemented")
|
||||
}
|
||||
|
||||
baseDirName := filepath.Base(filepath.Dir(realPath))
|
||||
if baseDirName == "" || baseDirName == "/" {
|
||||
baseDirName = "download"
|
||||
}
|
||||
if len(fileList) == 1 && isDir {
|
||||
baseDirName = filepath.Base(realPath)
|
||||
}
|
||||
originalFileName := baseDirName + extension
|
||||
|
||||
setContentDisposition(w, r, originalFileName)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
var writer io.Writer = w
|
||||
if d.share != nil && d.share.MaxBandwidth > 0 {
|
||||
limit := rate.Limit(d.share.MaxBandwidth * 1024)
|
||||
burst := d.share.MaxBandwidth * 1024
|
||||
writer = newThrottledWriter(w, limit, burst, r.Context())
|
||||
}
|
||||
|
||||
if extension == ".zip" {
|
||||
err = createZip(d, source, writer, fileList...)
|
||||
} else {
|
||||
err = createTarGz(d, source, writer, fileList...)
|
||||
}
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// archiveCreateRequest is the body for POST /resources/archive (server-side create).
|
||||
type archiveCreateRequest struct {
|
||||
// Source name where the paths to archive live (required). Example: "default"
|
||||
FromSource string `json:"fromSource"`
|
||||
// Source name where the archive file will be written (optional; default: fromSource). Example: "backups"
|
||||
ToSource string `json:"toSource"`
|
||||
// Paths of files or directories to add; directories are walked; access-denied entries skipped (required). Example: ["/docs/file.txt", "/photos"]
|
||||
Paths []string `json:"paths"`
|
||||
// Full path where the archive will be created; use .zip or .tar.gz extension (required). Example: "/backups/my-archive.zip"
|
||||
Destination string `json:"destination"`
|
||||
// Archive format: "zip" or "tar.gz" (optional; inferred from destination if omitted). Example: "zip"
|
||||
Format string `json:"format"`
|
||||
// Gzip compression level for tar.gz only, 0-9; 0 = default; ignored for zip (optional). Example: 6
|
||||
Compression int `json:"compression"`
|
||||
// If true, delete the source files/directories after successful archive creation (optional; requires delete permission). Example: true
|
||||
DeleteAfter bool `json:"deleteAfter"`
|
||||
}
|
||||
|
||||
// unarchiveRequest is the body for POST /resources/unarchive (server-side extract).
|
||||
type unarchiveRequest struct {
|
||||
// Source name where the archive file lives (required). Example: "default"
|
||||
FromSource string `json:"fromSource"`
|
||||
// Source name where contents will be extracted (optional; default: fromSource). Example: "restored"
|
||||
ToSource string `json:"toSource"`
|
||||
// Path to the archive file on fromSource; .zip, .tar.gz, or .tgz (required). Example: "/downloads/data.zip"
|
||||
Path string `json:"path"`
|
||||
// Directory path on toSource to extract into (required). Example: "/projects/imported"
|
||||
Destination string `json:"destination"`
|
||||
// If true, delete the archive file after successful extraction (optional; default: false). Example: true
|
||||
DeleteAfter bool `json:"deleteAfter"`
|
||||
}
|
||||
|
||||
// safeExtractPath ensures name does not escape destDir (no ".." or absolute).
|
||||
func safeExtractPath(destDir, name string) (string, error) {
|
||||
clean := filepath.Clean(name)
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
}
|
||||
abs := filepath.Join(destDir, clean)
|
||||
destAbs, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
entryAbs, err := filepath.Abs(abs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(entryAbs, destAbs) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func extractZip(archivePath, destDir string) error {
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
destPath, err := safeExtractPath(destDir, f.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
if err = os.MkdirAll(destPath, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, rc)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTarGz(archivePath, destDir string) error {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destPath, err := safeExtractPath(destDir, h.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(destPath, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeArchiveSize returns the combined size of the given paths, respecting access rules.
|
||||
// Paths denied by access are skipped (not counted).
|
||||
func computeArchiveSize(source string, fileList []string, d *requestContext) (int64, error) {
|
||||
var estimatedSize int64
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return 0, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
for _, path := range fileList {
|
||||
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
continue
|
||||
}
|
||||
realPath, isDir, err := idx.GetRealPath(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
indexPath := idx.MakeIndexPath(realPath, isDir)
|
||||
info, ok := idx.GetReducedMetadata(indexPath, isDir)
|
||||
if !ok {
|
||||
info, err = idx.GetFsInfo(indexPath, false, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
|
||||
}
|
||||
}
|
||||
estimatedSize += info.Size
|
||||
}
|
||||
return estimatedSize, nil
|
||||
}
|
||||
+107
-129
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v4/request"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
@@ -86,22 +87,25 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
if err.Error() != "the resource does not exist" {
|
||||
return nil, err
|
||||
}
|
||||
// Auto-create user on first proxy authentication
|
||||
user := users.User{
|
||||
LoginMethod: users.LoginMethodProxy,
|
||||
Username: proxyUser,
|
||||
}
|
||||
settings.ApplyUserDefaults(&user)
|
||||
if user.Username == config.Auth.AdminUsername {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
err = storage.CreateUser(user, user.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.user, err = store.Users.Get(proxyUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if config.Auth.Methods.ProxyAuth.CreateUser {
|
||||
user := users.User{
|
||||
LoginMethod: users.LoginMethodProxy,
|
||||
Username: proxyUser,
|
||||
}
|
||||
settings.ApplyUserDefaults(&user)
|
||||
if user.Username == config.Auth.AdminUsername {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
err = storage.CreateUser(user, user.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.user, err = store.Users.Get(proxyUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("proxy authentication failed - no user found")
|
||||
}
|
||||
}
|
||||
if data.user.LoginMethod != users.LoginMethodProxy {
|
||||
@@ -117,99 +121,6 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
return data.user, nil
|
||||
}
|
||||
|
||||
// setupJwtUser retrieves or creates a user based on external JWT token claims
|
||||
func setupJwtUser(r *http.Request, data *requestContext, username string, claims map[string]interface{}) (*users.User, error) {
|
||||
var err error
|
||||
// Retrieve the user from the store
|
||||
data.user, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
if err.Error() != "the resource does not exist" {
|
||||
return nil, err
|
||||
}
|
||||
// Auto-create user on first JWT authentication
|
||||
user := users.User{
|
||||
LoginMethod: users.LoginMethodJwt,
|
||||
Username: username,
|
||||
}
|
||||
settings.ApplyUserDefaults(&user)
|
||||
|
||||
// Check if user should be admin based on groups
|
||||
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
|
||||
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
|
||||
for _, group := range groups {
|
||||
if group == config.Auth.Methods.JwtAuth.AdminGroup {
|
||||
user.Permissions.Admin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if username matches admin username
|
||||
if user.Username == config.Auth.AdminUsername {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
|
||||
err = storage.CreateUser(user, user.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.user, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Verify login method matches
|
||||
if data.user.LoginMethod != users.LoginMethodJwt {
|
||||
return nil, errors.ErrWrongLoginMethod
|
||||
}
|
||||
|
||||
// Check if user is in allowed groups (if UserGroups is configured)
|
||||
if len(config.Auth.Methods.JwtAuth.UserGroups) > 0 {
|
||||
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
|
||||
allowed := false
|
||||
for _, userGroup := range groups {
|
||||
for _, allowedGroup := range config.Auth.Methods.JwtAuth.UserGroups {
|
||||
if userGroup == allowedGroup {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if allowed {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return nil, fmt.Errorf("user is not in allowed groups")
|
||||
}
|
||||
}
|
||||
|
||||
// Update admin status if needed
|
||||
shouldBeAdmin := false
|
||||
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
|
||||
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
|
||||
for _, group := range groups {
|
||||
if group == config.Auth.Methods.JwtAuth.AdminGroup {
|
||||
shouldBeAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.user.Username == config.Auth.AdminUsername {
|
||||
shouldBeAdmin = true
|
||||
}
|
||||
|
||||
if shouldBeAdmin != data.user.Permissions.Admin {
|
||||
data.user.Permissions.Admin = shouldBeAdmin
|
||||
err = store.Users.Update(data.user, true, "Permissions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return data.user, nil
|
||||
}
|
||||
|
||||
// loginHandler handles user authentication via password.
|
||||
// @Summary User login
|
||||
// @Description Authenticate a user with a username and password. The password must be URL-encoded and sent in the X-Password header to support special characters (e.g., ^, %, £, €, etc.).
|
||||
@@ -225,13 +136,16 @@ func setupJwtUser(r *http.Request, data *requestContext, username string, claims
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/login [post]
|
||||
func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.user.LoginMethod == users.LoginMethodProxy {
|
||||
return printToken(w, r, d.user)
|
||||
}
|
||||
passwordUser := d.user.LoginMethod == users.LoginMethodPassword
|
||||
enforcedOtp := config.Auth.Methods.PasswordAuth.EnforcedOtp
|
||||
missingOtp := d.user.TOTPSecret == ""
|
||||
if passwordUser && enforcedOtp && missingOtp {
|
||||
return http.StatusForbidden, errors.ErrNoTotpConfigured
|
||||
}
|
||||
return printToken(w, r, d.user)
|
||||
return printToken(w, r, d.user) // Pass the data object
|
||||
}
|
||||
|
||||
// logoutHandler handles user logout
|
||||
@@ -243,9 +157,7 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
// @Success 200 {object} map[string]string "{"logoutUrl": "http://..."}"
|
||||
// @Router /api/auth/logout [post]
|
||||
func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if err := auth.RevokeApiToken(store.Access, d.token); err != nil {
|
||||
logger.Errorf("Failed to revoke token on logout: %v", err)
|
||||
}
|
||||
defer auth.RevokeAPIKey(d.token)
|
||||
|
||||
// Clear the authentication cookie by setting it to expire in the past
|
||||
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
|
||||
@@ -258,7 +170,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
Value: "",
|
||||
Domain: strings.Split(host, ":")[0],
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(0, 0), // Expire immediately
|
||||
MaxAge: -1, // Delete cookie
|
||||
}
|
||||
@@ -275,16 +187,6 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
if oidcRedirectUrl != "" {
|
||||
logoutUrl = oidcRedirectUrl
|
||||
}
|
||||
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodLdap {
|
||||
ldapRedirectUrl := config.Auth.Methods.LdapAuth.LogoutRedirectUrl
|
||||
if ldapRedirectUrl != "" {
|
||||
logoutUrl = ldapRedirectUrl
|
||||
}
|
||||
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodJwt {
|
||||
jwtRedirectUrl := config.Auth.Methods.JwtAuth.LogoutRedirectUrl
|
||||
if jwtRedirectUrl != "" {
|
||||
logoutUrl = jwtRedirectUrl
|
||||
}
|
||||
}
|
||||
if logoutUrl == "" {
|
||||
logger.Debug("no logout url found, using default")
|
||||
@@ -355,7 +257,7 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
// @Success 200 {string} string "New JWT token generated"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid token"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/renew [post]
|
||||
// @Router /api/renew [post]
|
||||
func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// check if x-auth header is present and token is
|
||||
return printToken(w, r, d.user)
|
||||
@@ -363,7 +265,7 @@ func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
|
||||
func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int, error) {
|
||||
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
|
||||
tokenString, _, err := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
|
||||
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "key already exists with same name") {
|
||||
return http.StatusConflict, err
|
||||
@@ -375,16 +277,90 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int,
|
||||
// This allows backend to identify expired sessions and provide better user feedback
|
||||
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
|
||||
|
||||
setSessionCookie(w, r, tokenString, expiresTime)
|
||||
setSessionCookie(w, r, signed.Key, expiresTime)
|
||||
|
||||
// Still return token in body for backward compatibility and state management
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if _, err := w.Write([]byte(tokenString)); err != nil {
|
||||
if _, err := w.Write([]byte(signed.Key)); err != nil {
|
||||
return 401, errors.ErrUnauthorized
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions, minimal bool) (users.AuthToken, error) {
|
||||
_, ok := user.ApiKeys[name]
|
||||
if ok {
|
||||
return users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
|
||||
}
|
||||
now := time.Now()
|
||||
expires := now.Add(duration)
|
||||
|
||||
var tokenString string
|
||||
var err error
|
||||
|
||||
if minimal {
|
||||
// Create minimal token with only JWT standard claims
|
||||
minimalClaim := users.MinimalAuthToken{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expires),
|
||||
Issuer: "FileBrowser Quantum",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, minimalClaim)
|
||||
tokenString, err = token.SignedString([]byte(config.Auth.Key))
|
||||
if err != nil {
|
||||
return users.AuthToken{}, err
|
||||
}
|
||||
} else {
|
||||
// Create full token with permissions and user ID
|
||||
fullClaim := users.AuthToken{
|
||||
MinimalAuthToken: users.MinimalAuthToken{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expires),
|
||||
Issuer: "FileBrowser Quantum",
|
||||
},
|
||||
},
|
||||
Name: name,
|
||||
Permissions: perms,
|
||||
BelongsTo: user.ID,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, fullClaim)
|
||||
tokenString, err = token.SignedString([]byte(config.Auth.Key))
|
||||
if err != nil {
|
||||
return users.AuthToken{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create the AuthToken to store in database (always includes permissions and user ID)
|
||||
storedClaim := users.AuthToken{
|
||||
MinimalAuthToken: users.MinimalAuthToken{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expires),
|
||||
Issuer: "FileBrowser Quantum",
|
||||
},
|
||||
},
|
||||
Key: tokenString,
|
||||
Name: name,
|
||||
Permissions: perms,
|
||||
BelongsTo: user.ID,
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "WEB_TOKEN") {
|
||||
// don't add to api tokens, its a short lived web token
|
||||
return storedClaim, nil
|
||||
}
|
||||
|
||||
// Perform the user update
|
||||
err = store.Users.AddApiKey(user.ID, name, storedClaim)
|
||||
if err != nil {
|
||||
return storedClaim, err
|
||||
}
|
||||
return storedClaim, nil
|
||||
}
|
||||
|
||||
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||
if l.PasswordHash == "" {
|
||||
return 200, nil
|
||||
@@ -442,8 +418,10 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expi
|
||||
Value: token,
|
||||
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
|
||||
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
|
||||
Expires: expiresTime,
|
||||
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
|
||||
// Secure: true, // Enable this in production with HTTPS
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func (s *duplicateProcessingStats) shouldStop() (bool, string) {
|
||||
//
|
||||
// @Summary Find Duplicate Files
|
||||
// @Description Finds duplicate files using multi-stage filtering: size → type → fuzzy filename → progressive checksums. Files must match on size, MIME type, and have 50%+ filename similarity before checksum verification. Large fuzzy groups (>10 files) are skipped to avoid false positives. Checksums use 2-pass progressive verification (header → middle) for accuracy while minimizing disk I/O (~16KB read per file).
|
||||
// @Tags Tools
|
||||
// @Tags Duplicates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param source query string true "Source name for the desired source"
|
||||
@@ -142,7 +142,7 @@ func (s *duplicateProcessingStats) shouldStop() (bool, string) {
|
||||
// @Success 200 {object} duplicateResponse "List of duplicate file groups with metadata. Response includes 'incomplete' flag if processing stopped early due to resource limits."
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 503 {object} map[string]string "Service Unavailable (indexing in progress or another search running)"
|
||||
// @Router /api/tools/duplicateFinder [get]
|
||||
// @Router /api/duplicates [get]
|
||||
func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
opts, err := prepDuplicatesOptions(r, d)
|
||||
if err != nil {
|
||||
|
||||
+82
-72
@@ -26,9 +26,10 @@ type fileWatchResponse struct {
|
||||
// fileWatchMetadata contains file information for non-text files
|
||||
type fileWatchMetadata struct {
|
||||
Name string `json:"name"` // File name
|
||||
Size int64 `json:"size"` // File size in bytes (for directories, total size of all files)
|
||||
Size int64 `json:"size"` // File size in bytes
|
||||
Type string `json:"type"` // MIME type
|
||||
Modified time.Time `json:"modified"` // Modification time
|
||||
Path string `json:"path"` // File path
|
||||
}
|
||||
|
||||
// readLastNLines reads the last N lines from a file efficiently
|
||||
@@ -122,7 +123,7 @@ func readLastNLines(filePath string, n int) (string, error) {
|
||||
// @Failure 403 {object} map[string]string "Permission denied"
|
||||
// @Failure 404 {object} map[string]string "File not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/tools/fileWatcher [get]
|
||||
// @Router /api/tools/watch [get]
|
||||
func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Check for latency check request - return immediately with minimal response
|
||||
if r.URL.Query().Get("latencyCheck") != "" {
|
||||
@@ -139,7 +140,7 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
var err error
|
||||
path, err = utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
|
||||
// Parse lines parameter
|
||||
@@ -189,42 +190,55 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Get file/directory info
|
||||
// Verify it's a file, not a directory
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is a directory, not a file")
|
||||
}
|
||||
|
||||
// Check if file is a text file
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
|
||||
}
|
||||
|
||||
// Get MIME type from the index if available
|
||||
mimeType := "application/octet-stream"
|
||||
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
|
||||
if exists && reducedInfo.Type != "" {
|
||||
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
|
||||
mimeType = reducedInfo.Type
|
||||
}
|
||||
|
||||
response := fileWatchResponse{}
|
||||
// Handle directory - just return metadata, no content
|
||||
response.IsText = false
|
||||
response.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: 0, // Directories don't have a meaningful size
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
response := fileWatchResponse{
|
||||
IsText: isText,
|
||||
}
|
||||
if !info.IsDir() {
|
||||
// Handle regular file
|
||||
// Check if file is a text file
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
|
||||
if isText {
|
||||
// Read the last N lines for text files
|
||||
content, err := readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("error reading file: %v", err)
|
||||
}
|
||||
if isText {
|
||||
// Read the last N lines for text files only
|
||||
content, err := readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error reading file: %v", err)
|
||||
}
|
||||
response.Contents = content
|
||||
response.Contents = content
|
||||
// Include metadata for text files too (for file size display)
|
||||
response.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
} else {
|
||||
// For non-text files, return metadata
|
||||
response.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +265,7 @@ type fileWatchSSEEvent struct {
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 403 {object} map[string]string "Permission denied"
|
||||
// @Failure 404 {object} map[string]string "File not found"
|
||||
// @Router /api/tools/fileWatcher/sse [get]
|
||||
// @Router /api/tools/watch/sse [get]
|
||||
func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Check realtime permissions
|
||||
if !(d.user.Permissions.Realtime) {
|
||||
@@ -275,7 +289,7 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
@@ -332,13 +346,16 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// Get real file path
|
||||
realPath, _, err := idx.GetRealPath(scopePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Get file/directory info
|
||||
// Verify it's a file, not a directory
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is a directory, not a file")
|
||||
}
|
||||
|
||||
// Set up SSE headers
|
||||
@@ -369,13 +386,10 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// Get MIME type (we'll reuse this)
|
||||
mimeType := "application/octet-stream"
|
||||
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
|
||||
if exists && reducedInfo.Type != "" {
|
||||
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
|
||||
mimeType = reducedInfo.Type
|
||||
}
|
||||
|
||||
// Determine if this is a directory
|
||||
isDir := info.IsDir()
|
||||
|
||||
// Start background goroutine to periodically send file updates via events system
|
||||
stopTicker := make(chan struct{})
|
||||
go func() {
|
||||
@@ -383,14 +397,14 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
defer ticker.Stop()
|
||||
|
||||
// Send initial update
|
||||
sendFileWatchUpdate(username, realPath, path, source, lines, mimeType, isDir)
|
||||
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopTicker:
|
||||
return
|
||||
case <-ticker.C:
|
||||
sendFileWatchUpdate(username, realPath, path, source, lines, mimeType, isDir)
|
||||
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -429,58 +443,54 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
}
|
||||
|
||||
// sendFileWatchUpdate reads the file/directory and sends an update via the events system
|
||||
func sendFileWatchUpdate(username, realPath, path, source string, lines int, mimeType string, isDir bool) {
|
||||
// Re-check path (in case it was deleted or changed)
|
||||
// sendFileWatchUpdate reads the file and sends an update via the events system
|
||||
func sendFileWatchUpdate(username, realPath, path string, lines int, mimeType string) {
|
||||
// Re-check file (in case it was deleted or changed)
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
// Path no longer exists
|
||||
errorMsg, _ := json.Marshal(map[string]interface{}{"status": "error", "error": "path not found"})
|
||||
// File no longer exists
|
||||
errorMsg, _ := json.Marshal(map[string]interface{}{"status": "error", "error": "file not found"})
|
||||
events.SendToUsers("fileWatch", string(errorMsg), []string{username})
|
||||
return
|
||||
}
|
||||
|
||||
// Build the SSE event
|
||||
sseEvent := fileWatchSSEEvent{}
|
||||
// Check if file is a text file
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
// Error checking file, skip this update
|
||||
return
|
||||
}
|
||||
|
||||
if isDir {
|
||||
// Handle directory - just return metadata, no content
|
||||
sseEvent.IsText = false
|
||||
sseEvent.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: 0, // Directories don't have a meaningful size
|
||||
Type: "directory",
|
||||
Modified: info.ModTime(),
|
||||
}
|
||||
} else {
|
||||
// Handle regular file
|
||||
// Check if file is a text file
|
||||
var isText bool
|
||||
isText, err = utils.IsTextFile(realPath)
|
||||
// Build the SSE event
|
||||
sseEvent := fileWatchSSEEvent{
|
||||
IsText: isText,
|
||||
}
|
||||
|
||||
if isText {
|
||||
// Read the last N lines for text files
|
||||
var content string
|
||||
content, err = readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
// Error checking file, skip this update
|
||||
// Error reading, skip this update
|
||||
return
|
||||
}
|
||||
|
||||
sseEvent.IsText = isText
|
||||
sseEvent.Contents = content
|
||||
sseEvent.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
|
||||
if isText {
|
||||
// Read the last N lines for text files only
|
||||
var content string
|
||||
content, err = readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
// Error reading, skip this update
|
||||
return
|
||||
}
|
||||
sseEvent.Contents = content
|
||||
} else {
|
||||
// For non-text files, send metadata
|
||||
sseEvent.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
// For non-text files, Contents stays empty - frontend displays metadata
|
||||
}
|
||||
|
||||
// Serialize and send via events system
|
||||
|
||||
+76
-101
@@ -74,67 +74,51 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
devMode: settings.Env.IsDevMode,
|
||||
}
|
||||
|
||||
// Core routing
|
||||
router := http.NewServeMux()
|
||||
api := http.NewServeMux()
|
||||
publicRoutes := http.NewServeMux()
|
||||
publicApi := http.NewServeMux()
|
||||
// Initialize cached PWA manifest
|
||||
InitializePWAManifest()
|
||||
|
||||
// ========================================
|
||||
// User Routes - /api/users/ (with public routes)
|
||||
// ========================================
|
||||
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))
|
||||
api.HandleFunc("POST /users", withSelfOrAdmin(usersPostHandler))
|
||||
api.HandleFunc("PUT /users", withUser(userPutHandler))
|
||||
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
|
||||
publicApi.HandleFunc("GET /users", withUser(userGetHandler))
|
||||
api.HandleFunc("GET /health", healthHandler)
|
||||
|
||||
// ========================================
|
||||
// Auth Routes - /api/auth/
|
||||
// ========================================
|
||||
// Auth routes
|
||||
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
|
||||
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
|
||||
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
|
||||
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(generateOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(verifyOTPHandler))
|
||||
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
|
||||
api.HandleFunc("POST /auth/token", withUser(createApiTokenHandler))
|
||||
api.HandleFunc("DELETE /auth/token", withUser(deleteApiTokenHandler))
|
||||
api.HandleFunc("GET /auth/token/list", withUser(listApiTokensHandler))
|
||||
api.HandleFunc("GET /auth/token", withUser(getApiTokenHandler))
|
||||
api.HandleFunc("PUT /auth/token", withUser(createApiKeyHandler))
|
||||
api.HandleFunc("GET /auth/token", withUser(createApiKeyHandler))
|
||||
api.HandleFunc("DELETE /auth/token", withUser(deleteApiKeyHandler))
|
||||
api.HandleFunc("GET /auth/tokens", withUser(listApiKeysHandler))
|
||||
api.HandleFunc("GET /auth/oidc/callback", wrapHandler(oidcCallbackHandler))
|
||||
api.HandleFunc("GET /auth/oidc/login", wrapHandler(oidcLoginHandler))
|
||||
|
||||
// ========================================
|
||||
// Resources Routes - /api/resources/ (with public routes)
|
||||
// ========================================
|
||||
// Resources routes
|
||||
api.HandleFunc("GET /resources", withUser(resourceGetHandler))
|
||||
api.HandleFunc("GET /resources/items", withUser(itemsGetHandler))
|
||||
api.HandleFunc("DELETE /resources", withUser(resourceDeleteHandler))
|
||||
api.HandleFunc("POST /resources", withUser(resourcePostHandler))
|
||||
api.HandleFunc("PUT /resources", withUser(resourcePutHandler))
|
||||
api.HandleFunc("PATCH /resources", withUser(resourcePatchHandler))
|
||||
api.HandleFunc("DELETE /resources/bulk", withUser(resourceBulkDeleteHandler))
|
||||
api.HandleFunc("POST /resources/archive", withUser(archiveCreateHandler))
|
||||
api.HandleFunc("POST /resources/unarchive", withUser(unarchiveHandler))
|
||||
api.HandleFunc("GET /resources/download", withUser(downloadHandler))
|
||||
api.HandleFunc("GET /resources/preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
|
||||
publicApi.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
|
||||
publicApi.HandleFunc("GET /resources/items", withHashFile(publicItemsGetHandler))
|
||||
publicApi.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
|
||||
publicApi.HandleFunc("PUT /resources", withHashFile(publicPutHandler))
|
||||
publicApi.HandleFunc("DELETE /resources", withHashFile(publicDeleteHandler))
|
||||
publicApi.HandleFunc("DELETE /resources/bulk", withHashFile(publicBulkDeleteHandler))
|
||||
publicApi.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
|
||||
publicApi.HandleFunc("GET /resources/download", withHashFile(publicDownloadHandler))
|
||||
publicApi.HandleFunc("GET /resources/preview", withHashFile(publicPreviewHandler))
|
||||
// Legacy routes (backwards compatibility)
|
||||
api.HandleFunc("GET /raw", withUser(downloadHandler))
|
||||
publicApi.HandleFunc("GET /raw", withHashFile(publicDownloadHandler))
|
||||
|
||||
// ========================================
|
||||
// Access Routes - /api/access/
|
||||
// ========================================
|
||||
api.HandleFunc("POST /resources/bulk/delete", withUser(resourceBulkDeleteHandler))
|
||||
api.HandleFunc("GET /raw", withUser(rawHandler))
|
||||
api.HandleFunc("GET /preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
|
||||
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
|
||||
if settings.Env.IsDevMode {
|
||||
api.HandleFunc("GET /inspectIndex", inspectIndex)
|
||||
api.HandleFunc("GET /mockData", mockData)
|
||||
}
|
||||
// Access routes
|
||||
api.HandleFunc("GET /access", withAdmin(accessGetHandler))
|
||||
api.HandleFunc("POST /access", withAdmin(accessPostHandler))
|
||||
api.HandleFunc("PATCH /access", withAdmin(accessPatchHandler))
|
||||
@@ -143,96 +127,87 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("POST /access/group", withAdmin(groupPostHandler))
|
||||
api.HandleFunc("DELETE /access/group", withAdmin(groupDeleteHandler))
|
||||
|
||||
// ========================================
|
||||
// Share Routes - /api/share/
|
||||
// ========================================
|
||||
api.HandleFunc("GET /share/list", withPermShare(shareListHandler))
|
||||
// Share management routes (require permission)
|
||||
api.HandleFunc("GET /shares", withPermShare(shareListHandler))
|
||||
api.HandleFunc("GET /share/direct", withPermShare(shareDirectDownloadHandler))
|
||||
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
|
||||
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
|
||||
api.HandleFunc("PATCH /share", withPermShare(sharePatchHandler))
|
||||
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
|
||||
publicApi.HandleFunc("GET /share/info", withOrWithoutUser(shareInfoHandler))
|
||||
publicApi.HandleFunc("GET /share/image", withHashFile(getShareImage))
|
||||
|
||||
// ========================================
|
||||
// Settings Routes - /api/settings/
|
||||
// ========================================
|
||||
// Create API sub-router for public API endpoints
|
||||
publicAPI := http.NewServeMux()
|
||||
// NEW PUBLIC ROUTES - All publicly accessible endpoints
|
||||
// Public API routes (hash-based authentication)
|
||||
publicAPI.HandleFunc("GET /raw", withHashFile(publicRawHandler))
|
||||
publicAPI.HandleFunc("GET /preview", withTimeout(60*time.Second, withHashFileHelper(publicPreviewHandler)))
|
||||
publicAPI.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
|
||||
publicAPI.HandleFunc("GET /users", withUser(userGetHandler))
|
||||
publicAPI.HandleFunc("POST /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
|
||||
publicAPI.HandleFunc("GET /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
|
||||
publicAPI.HandleFunc("GET /onlyoffice/config", withHashFile(onlyofficeClientConfigGetHandler))
|
||||
publicAPI.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
|
||||
publicAPI.HandleFunc("PUT /resources", withHashFile(publicPutHandler))
|
||||
publicAPI.HandleFunc("DELETE /resources", withHashFile(publicDeleteHandler))
|
||||
publicAPI.HandleFunc("POST /resources/bulk/delete", withHashFile(publicBulkDeleteHandler))
|
||||
publicAPI.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
|
||||
publicAPI.HandleFunc("GET /shareinfo", withOrWithoutUser(shareInfoHandler))
|
||||
publicAPI.HandleFunc("GET /share/image", withHashFile(getShareImage))
|
||||
|
||||
// Settings routes
|
||||
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
|
||||
api.HandleFunc("GET /settings/config", withAdmin(settingsConfigHandler))
|
||||
api.HandleFunc("GET /settings/sources", withUser(getSourceInfoHandler))
|
||||
|
||||
// ========================================
|
||||
// Tools Routes - /api/tools/
|
||||
// ========================================
|
||||
api.HandleFunc("GET /tools/search", withUser(searchHandler))
|
||||
api.HandleFunc("GET /tools/duplicateFinder", withUser(duplicatesHandler))
|
||||
api.HandleFunc("GET /tools/fileWatcher", withUser(fileWatchHandler))
|
||||
api.HandleFunc("GET /tools/fileWatcher/sse", withUser(fileWatchSSEHandler))
|
||||
// Events routes
|
||||
api.HandleFunc("GET /events", withUser(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
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))
|
||||
|
||||
// ========================================
|
||||
// Media Routes - /api/media/
|
||||
// ========================================
|
||||
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
|
||||
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
|
||||
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
|
||||
api.HandleFunc("GET /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
|
||||
|
||||
// ========================================
|
||||
// OnlyOffice Routes - /api/office/ (with public routes)
|
||||
// ========================================
|
||||
api.HandleFunc("GET /office/config", withUser(onlyofficeClientConfigGetHandler))
|
||||
api.HandleFunc("POST /office/callback", withUser(onlyofficeCallbackHandler))
|
||||
api.HandleFunc("GET /office/callback", withUser(onlyofficeCallbackHandler))
|
||||
publicApi.HandleFunc("POST /office/callback", withHashFile(onlyofficeCallbackHandler))
|
||||
publicApi.HandleFunc("GET /office/callback", withHashFile(onlyofficeCallbackHandler))
|
||||
publicApi.HandleFunc("GET /office/config", withHashFile(onlyofficeClientConfigGetHandler))
|
||||
api.HandleFunc("GET /search", withUser(searchHandler))
|
||||
api.HandleFunc("GET /duplicates", withUser(duplicatesHandler))
|
||||
api.HandleFunc("GET /tools/watch", withUser(fileWatchHandler))
|
||||
api.HandleFunc("GET /tools/watch/sse", withUser(fileWatchSSEHandler))
|
||||
// Mount the public API sub-router
|
||||
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicAPI))
|
||||
|
||||
// ========================================
|
||||
// Misc Routes
|
||||
// ========================================
|
||||
api.HandleFunc("GET /health", healthHandler)
|
||||
api.HandleFunc("GET /events", withUser(sseHandler))
|
||||
if settings.Env.IsDevMode {
|
||||
api.HandleFunc("GET /inspectIndex", inspectIndex)
|
||||
api.HandleFunc("GET /mockData", mockData)
|
||||
}
|
||||
|
||||
// Mount public API
|
||||
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicApi))
|
||||
|
||||
// ========================================
|
||||
// Configure Main Router
|
||||
// ========================================
|
||||
// Mount the route groups
|
||||
apiPath := config.Server.BaseURL + "api"
|
||||
publicPath := config.Server.BaseURL + "public"
|
||||
webDavPath := config.Server.BaseURL + "dav"
|
||||
|
||||
// Mount primary API and public routes
|
||||
router.Handle(apiPath+"/", http.StripPrefix(apiPath, api))
|
||||
router.Handle(publicPath+"/", http.StripPrefix(publicPath, publicRoutes))
|
||||
|
||||
// WebDAV handler
|
||||
if !config.Server.DisableWebDAV {
|
||||
// Uses Basic Auth where password is JWT token
|
||||
// Note: do not trim /dav prefix here - webdav library requires it
|
||||
router.Handle(webDavPath+"/{source}/{path...}", withBasicAuth(webDAVHandler))
|
||||
}
|
||||
// WebDav resources (catch-all)
|
||||
// similar to NextCloud and others
|
||||
// (reddec) do not trim /dav prefix here - webdav library for some reason does not like it.
|
||||
router.Handle(webDavPath+"/{scope}/{path...}", wrapHandlerOpts(withUserHelper(createWebDAVHandler(webDavPath)), wrapperOpts{
|
||||
requestBasicAuth: true,
|
||||
}))
|
||||
|
||||
// Frontend share route redirect (DEPRECATED - maintain for backwards compatibility)
|
||||
// TODO: Playwright tests need updating to remove this redirect
|
||||
// Playwright tests need updating to remove this redirect.
|
||||
router.HandleFunc(fmt.Sprintf("GET %vshare/", config.Server.BaseURL), withOrWithoutUser(redirectToShare))
|
||||
|
||||
// New frontend share route handler
|
||||
// New frontend share route handler - handle share page and any subpaths
|
||||
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
|
||||
|
||||
// Static assets
|
||||
// Static and index file handlers
|
||||
publicRoutes.Handle("GET /static/", http.HandlerFunc(staticAssetHandler))
|
||||
|
||||
// Standard browser favicon route
|
||||
router.HandleFunc("GET /favicon.svg", http.HandlerFunc(staticAssetHandler))
|
||||
|
||||
// Index and utility routes
|
||||
router.HandleFunc(config.Server.BaseURL, withOrWithoutUser(indexHandler))
|
||||
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
|
||||
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL), withUser(swaggerHandler))
|
||||
|
||||
// Base URL redirect (non-root deployments)
|
||||
// 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)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// getJobsHandler returns job info for the user.
|
||||
// @Summary Get jobs info
|
||||
// @Description Returns job info for the user.
|
||||
// @Tags Jobs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param action path string true "Job action"
|
||||
// @Param target path string true "Job target"
|
||||
// @Success 200 {object} map[string]indexing.ReducedIndex "Job info"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/jobs/{action}/{target} [get]
|
||||
func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
sources := d.user.GetSourceNames()
|
||||
reducedIndexes := map[string]indexing.ReducedIndex{}
|
||||
for _, source := range sources {
|
||||
reducedIndex, err := indexing.GetIndexInfo(source, false)
|
||||
if err != nil {
|
||||
logger.Debugf("error getting index info: %v", err)
|
||||
continue
|
||||
}
|
||||
reducedIndexes[source] = reducedIndex
|
||||
}
|
||||
return renderJSON(w, r, reducedIndexes)
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
ldap "github.com/go-ldap/ldap/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// AuthenticateLDAPUser attempts LDAP authentication and returns the filebrowser user if successful.
|
||||
func AuthenticateLDAPUser(username, password string) (*users.User, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return nil, fmt.Errorf("username required")
|
||||
}
|
||||
groups, userAttributes, err := authenticateLDAP(username, password)
|
||||
if err != nil {
|
||||
logger.Debugf("ldap authentication failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use the configured UserIdentifier if available, otherwise use the login username
|
||||
ldapCfg := settings.Config.Auth.Methods.LdapAuth
|
||||
mappedUsername := username
|
||||
if ldapCfg.UserIdentifier != "" {
|
||||
if val, ok := userAttributes[ldapCfg.UserIdentifier]; ok && val != "" {
|
||||
mappedUsername = val
|
||||
logger.Debugf("ldap: mapped username from %s: %s -> %s", ldapCfg.UserIdentifier, username, mappedUsername)
|
||||
} else {
|
||||
logger.Warningf("ldap: userIdentifier '%s' not found in LDAP entry for user %s, using login username", ldapCfg.UserIdentifier, username)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debugf("ldap authentication successful, getting or creating user %s", mappedUsername)
|
||||
return getOrCreateLdapUser(mappedUsername, groups)
|
||||
}
|
||||
|
||||
func authenticateLDAP(username, password string) ([]string, map[string]string, error) {
|
||||
c := settings.Config.Auth.Methods.LdapAuth
|
||||
logger.Debugf("ldap: connecting to %s", c.Server)
|
||||
|
||||
var opts []ldap.DialOpt
|
||||
if c.DisableVerifyTLS && c.Scheme == "ldaps" {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
|
||||
logger.Warning("LDAP TLS verification is disabled.")
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL(c.Server, opts...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("ldap connect: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Bind with service account
|
||||
if c.UserPassword != "" {
|
||||
logger.Debugf("ldap: binding as service account %s", c.UserDN)
|
||||
if err = conn.Bind(c.UserDN, c.UserPassword); err != nil {
|
||||
return nil, nil, fmt.Errorf("ldap bind (service): %w", err)
|
||||
}
|
||||
} else {
|
||||
logger.Debugf("ldap: no service account bind (userPassword empty)")
|
||||
}
|
||||
|
||||
// Build list of attributes to fetch
|
||||
groupAttr := c.GroupsClaim
|
||||
if groupAttr == "" {
|
||||
groupAttr = "memberOf"
|
||||
}
|
||||
attributes := []string{"dn", groupAttr}
|
||||
if c.UserIdentifier != "" {
|
||||
attributes = append(attributes, c.UserIdentifier)
|
||||
}
|
||||
|
||||
filter := fmt.Sprintf(c.UserFilter, ldap.EscapeFilter(username))
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
c.BaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
filter,
|
||||
attributes,
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("ldap search (%s): %w", c.Server, err)
|
||||
}
|
||||
|
||||
if len(result.Entries) == 0 {
|
||||
return nil, nil, fmt.Errorf("user not found: %s (LDAP search for %s returned no entries)", username, filter)
|
||||
}
|
||||
|
||||
entry := result.Entries[0]
|
||||
if len(result.Entries) > 1 {
|
||||
// Prefer the user entry when multiple match (e.g. Authentik returns user + virtual group with same cn).
|
||||
if u := pickUserEntry(result.Entries); u != nil {
|
||||
entry = u
|
||||
logger.Debugf("ldap: multiple entries, using user entry DN=%s", entry.DN)
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("multiple entries for user: %s (set userFilter to narrow, e.g. (&(cn=%%s)(objectClass=user)))", username)
|
||||
}
|
||||
}
|
||||
userDN := entry.DN
|
||||
|
||||
// Verify password by binding as the user
|
||||
if err := conn.Bind(userDN, password); err != nil {
|
||||
logger.Debugf("ldap: bind as user failed: %v", err)
|
||||
return nil, nil, fmt.Errorf("ldap bind (user): %w", err)
|
||||
}
|
||||
|
||||
// Re-bind as service account for any follow-up (we're done; this is optional)
|
||||
if c.UserPassword != "" {
|
||||
_ = conn.Bind(c.UserDN, c.UserPassword)
|
||||
}
|
||||
|
||||
groups := entry.GetAttributeValues(groupAttr)
|
||||
|
||||
// Extract user attributes for identifier mapping
|
||||
userAttributes := make(map[string]string)
|
||||
if c.UserIdentifier != "" {
|
||||
values := entry.GetAttributeValues(c.UserIdentifier)
|
||||
if len(values) > 0 {
|
||||
userAttributes[c.UserIdentifier] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
return groups, userAttributes, nil
|
||||
}
|
||||
|
||||
// pickUserEntry returns the entry that represents a user when multiple entries match (e.g. Authentik user + virtual group).
|
||||
// Prefers entries with objectClass=user or DN under ou=users; returns nil if none or multiple user entries.
|
||||
func pickUserEntry(entries []*ldap.Entry) *ldap.Entry {
|
||||
var user *ldap.Entry
|
||||
for _, e := range entries {
|
||||
oc := e.GetAttributeValues("objectClass")
|
||||
dnLower := strings.ToLower(e.DN)
|
||||
isUser := false
|
||||
for _, c := range oc {
|
||||
if strings.EqualFold(c, "user") {
|
||||
isUser = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isUser && strings.Contains(dnLower, "ou=users") {
|
||||
isUser = true
|
||||
}
|
||||
if isUser {
|
||||
if user != nil {
|
||||
return nil // multiple user entries
|
||||
}
|
||||
user = e
|
||||
}
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// ldapGroupMatchesAdmin returns true if the LDAP group DN matches the configured admin group (full DN or CN value).
|
||||
func ldapGroupMatchesAdmin(groupDN, adminGroup string) bool {
|
||||
g := strings.TrimSpace(groupDN)
|
||||
if g == adminGroup {
|
||||
return true
|
||||
}
|
||||
dn, err := ldap.ParseDN(g)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, rdn := range dn.RDNs {
|
||||
for _, attr := range rdn.Attributes {
|
||||
if strings.EqualFold(attr.Type, "cn") && attr.Value == adminGroup {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getOrCreateLdapUser returns the filebrowser user for an LDAP-authenticated username, creating one if configured.
|
||||
func getOrCreateLdapUser(username string, groups []string) (*users.User, error) {
|
||||
logger.Debugf("getting or creating ldap user %s", username)
|
||||
ldapCfg := config.Auth.Methods.LdapAuth
|
||||
|
||||
// Check if user is in required groups (if userGroups is configured)
|
||||
if len(ldapCfg.UserGroups) > 0 {
|
||||
userInAllowedGroup := false
|
||||
for _, allowedGroup := range ldapCfg.UserGroups {
|
||||
for _, userGroup := range groups {
|
||||
if ldapGroupMatchesAdmin(userGroup, allowedGroup) {
|
||||
userInAllowedGroup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if userInAllowedGroup {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !userInAllowedGroup {
|
||||
logger.Warningf("User %s is not in any of the required groups %v. Access denied.", username, ldapCfg.UserGroups)
|
||||
return nil, fmt.Errorf("user %s is not authorized to access this application (not in required groups)", username)
|
||||
}
|
||||
logger.Debugf("User %s is in required group, allowing access.", username)
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
if ldapCfg.AdminGroup != "" {
|
||||
for _, g := range groups {
|
||||
if ldapGroupMatchesAdmin(g, ldapCfg.AdminGroup) {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isAdmin {
|
||||
logger.Debugf("User %s is in admin group %s, granting admin privileges.", username, ldapCfg.AdminGroup)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := store.Users.Get(username)
|
||||
if err != nil {
|
||||
if err.Error() != "the resource does not exist" {
|
||||
return nil, err
|
||||
}
|
||||
// Auto-create user on first LDAP authentication
|
||||
if ldapCfg.AdminGroup == "" {
|
||||
isAdmin = config.UserDefaults.Permissions.Admin
|
||||
}
|
||||
user = &users.User{
|
||||
Username: username,
|
||||
LoginMethod: users.LoginMethodLdap,
|
||||
}
|
||||
settings.ApplyUserDefaults(user)
|
||||
if isAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
if err = storage.CreateUser(*user, user.Permissions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if user.LoginMethod != users.LoginMethodLdap {
|
||||
return nil, errors.ErrWrongLoginMethod
|
||||
}
|
||||
if ldapCfg.AdminGroup != "" && isAdmin != user.Permissions.Admin {
|
||||
user.Permissions.Admin = isAdmin
|
||||
_ = store.Users.Update(user, true, "Permissions")
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.Access.SyncUserGroups(username, groups); err != nil {
|
||||
logger.Warningf("failed to sync ldap user %s groups: %v", username, err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
ldap "github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// newLDAPEntry creates a minimal *ldap.Entry for testing (DN and optional objectClass).
|
||||
func newLDAPEntry(dn string, objectClasses []string) *ldap.Entry {
|
||||
attrs := []*ldap.EntryAttribute{}
|
||||
if len(objectClasses) > 0 {
|
||||
attrs = append(attrs, &ldap.EntryAttribute{Name: "objectClass", Values: objectClasses})
|
||||
}
|
||||
return &ldap.Entry{DN: dn, Attributes: attrs}
|
||||
}
|
||||
|
||||
func TestPickUserEntry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entries []*ldap.Entry
|
||||
wantNil bool
|
||||
wantDN string // if wantNil is false, expected entry DN
|
||||
}{
|
||||
{
|
||||
name: "single user entry by objectClass",
|
||||
entries: []*ldap.Entry{
|
||||
newLDAPEntry("cn=alice,ou=users,dc=test", []string{"user", "organizationalPerson"}),
|
||||
},
|
||||
wantNil: false,
|
||||
wantDN: "cn=alice,ou=users,dc=test",
|
||||
},
|
||||
{
|
||||
name: "single user entry by ou=users in DN",
|
||||
entries: []*ldap.Entry{
|
||||
newLDAPEntry("cn=bob,ou=users,dc=example,dc=com", []string{"group"}),
|
||||
},
|
||||
wantNil: false,
|
||||
wantDN: "cn=bob,ou=users,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "user and virtual group - picks user",
|
||||
entries: []*ldap.Entry{
|
||||
newLDAPEntry("cn=akadmin,ou=virtual-groups,dc=test", []string{"group"}),
|
||||
newLDAPEntry("cn=akadmin,ou=users,dc=test", []string{"user"}),
|
||||
},
|
||||
wantNil: false,
|
||||
wantDN: "cn=akadmin,ou=users,dc=test",
|
||||
},
|
||||
{
|
||||
name: "two user entries - returns nil",
|
||||
entries: []*ldap.Entry{
|
||||
newLDAPEntry("cn=u1,ou=users,dc=test", []string{"user"}),
|
||||
newLDAPEntry("cn=u2,ou=users,dc=test", []string{"user"}),
|
||||
},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "only group entries - returns nil",
|
||||
entries: []*ldap.Entry{
|
||||
newLDAPEntry("cn=admins,ou=groups,dc=test", []string{"group"}),
|
||||
newLDAPEntry("cn=akadmin,ou=virtual-groups,dc=test", []string{"group"}),
|
||||
},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "empty entries",
|
||||
entries: []*ldap.Entry{},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "objectClass case insensitive",
|
||||
entries: []*ldap.Entry{
|
||||
newLDAPEntry("cn=u,ou=users,dc=test", []string{"USER"}),
|
||||
},
|
||||
wantNil: false,
|
||||
wantDN: "cn=u,ou=users,dc=test",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := pickUserEntry(tt.entries)
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("pickUserEntry() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("pickUserEntry() = nil, want non-nil entry")
|
||||
}
|
||||
if got.DN != tt.wantDN {
|
||||
t.Errorf("pickUserEntry() DN = %v, want %v", got.DN, tt.wantDN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdapGroupMatchesAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
groupDN string
|
||||
adminGroup string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact DN match",
|
||||
groupDN: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
|
||||
adminGroup: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "CN match",
|
||||
groupDN: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
|
||||
adminGroup: "authentik Admins",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "CN no match",
|
||||
groupDN: "cn=Other Group,ou=groups,dc=test",
|
||||
adminGroup: "authentik Admins",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exact no match",
|
||||
groupDN: "cn=admins,ou=groups,dc=test",
|
||||
adminGroup: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "whitespace trimmed",
|
||||
groupDN: " cn=admins,ou=groups,dc=test ",
|
||||
adminGroup: "cn=admins,ou=groups,dc=test",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid DN returns false",
|
||||
groupDN: "not-a-valid-dn",
|
||||
adminGroup: "admins",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty adminGroup no match",
|
||||
groupDN: "cn=admins,ou=groups,dc=test",
|
||||
adminGroup: "",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ldapGroupMatchesAdmin(tt.groupDN, tt.adminGroup)
|
||||
if got != tt.want {
|
||||
t.Errorf("ldapGroupMatchesAdmin(%q, %q) = %v, want %v", tt.groupDN, tt.adminGroup, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// subtitlesHandler handles subtitle requests for both external files and embedded streams
|
||||
// @Summary Get subtitle content
|
||||
// @Description Returns raw subtitle content from external files or embedded streams
|
||||
// @Tags Resources
|
||||
// @Tags Subtitles
|
||||
// @Accept json
|
||||
// @Produce text/plain
|
||||
// @Param path query string true "Index path to the video file"
|
||||
|
||||
+52
-195
@@ -5,9 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -115,8 +113,8 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
pathWithoutUserScope = pathWithoutUserScope + "/"
|
||||
}
|
||||
data.IndexPath = pathWithoutUserScope
|
||||
// skip file fetch for certain apis
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") || r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items") {
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") {
|
||||
// no need to fetch file info for uploads
|
||||
return fn(w, r, data)
|
||||
}
|
||||
file, err := FileInfoFasterFunc(utils.FileOptions{
|
||||
@@ -129,7 +127,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: link.ShowHidden,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, data.shareUser, store.Share)
|
||||
}, store.Access, data.shareUser)
|
||||
if err != nil {
|
||||
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")
|
||||
@@ -314,53 +312,16 @@ func withoutUserHelper(fn handleFunc) handleFunc {
|
||||
// allow user without OTP to pass
|
||||
func userWithoutOTPhelper(fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Check if request has a valid admin token first
|
||||
if tokenStr, err := extractToken(r); err == nil && tokenStr != "" {
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.Auth.Key), nil
|
||||
// This middleware is used when no user authentication is required
|
||||
// Call the actual handler function with the updated context
|
||||
username := r.URL.Query().Get("username")
|
||||
password := r.Header.Get("X-Password")
|
||||
if username == "" || password == "" {
|
||||
return withUserHelper(fn)(w, r, d)
|
||||
} else {
|
||||
if !config.Auth.Methods.PasswordAuth.Enabled {
|
||||
return 401, errors.ErrUnauthorized
|
||||
}
|
||||
var tk users.AuthToken
|
||||
if token, err := jwt.ParseWithClaims(tokenStr, &tk, keyFunc); err == nil && token.Valid {
|
||||
if !auth.IsRevokedApiToken(store.Access, tokenStr) {
|
||||
if tk.BelongsTo == 0 {
|
||||
// Minimal token - look up user ID
|
||||
userID, found := store.Access.GetUserIDFromToken(tokenStr)
|
||||
if found {
|
||||
tk.BelongsTo = userID
|
||||
}
|
||||
}
|
||||
if tk.BelongsTo > 0 {
|
||||
user, err := store.Users.Get(tk.BelongsTo)
|
||||
if err == nil && user.Permissions.Admin {
|
||||
// Valid admin token - allow operation
|
||||
d.user = user
|
||||
return fn(w, r, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try LDAP first if enabled; on success set d.user and continue to handler
|
||||
if config.Auth.Methods.LdapAuth.Enabled {
|
||||
// No valid admin token - proceed with username/password authentication
|
||||
username := r.URL.Query().Get("username")
|
||||
password := r.Header.Get("X-Password")
|
||||
// URL-decode password to support special characters in headers
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return 401, fmt.Errorf("invalid password encoding")
|
||||
}
|
||||
logger.Debug("ldap auth, calling AuthenticateLDAPUser")
|
||||
ldapUser, err := AuthenticateLDAPUser(username, password)
|
||||
if err == nil {
|
||||
logger.Debugf("ldap auth successful, calling handler")
|
||||
d.user = ldapUser
|
||||
return fn(w, r, d)
|
||||
}
|
||||
logger.Debug("ldap auth failed, calling password auth", err)
|
||||
}
|
||||
if config.Auth.Methods.PasswordAuth.Enabled {
|
||||
// Get the authentication method from the settings
|
||||
auther, err := store.Auth.Get("password")
|
||||
if err != nil {
|
||||
@@ -369,16 +330,14 @@ func userWithoutOTPhelper(fn handleFunc) handleFunc {
|
||||
// Authenticate the user based on the request
|
||||
user, err := auther.Auth(r, store.Users)
|
||||
if err != nil {
|
||||
logger.Debug("password auth failed, calling handler", err)
|
||||
if err == errors.ErrNoTotpProvided {
|
||||
return 403, err
|
||||
}
|
||||
return 401, errors.ErrUnauthorized
|
||||
}
|
||||
d.user = user
|
||||
return fn(w, r, d)
|
||||
}
|
||||
return withUserHelper(fn)(w, r, d)
|
||||
return fn(w, r, d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,75 +357,58 @@ func withUserHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
return fn(w, r, data)
|
||||
}
|
||||
|
||||
// Check for JWT external auth first (header or query param)
|
||||
if config.Auth.Methods.JwtAuth.Enabled {
|
||||
jwtToken := r.Header.Get(config.Auth.Methods.JwtAuth.Header)
|
||||
if jwtToken == "" {
|
||||
// Check query parameter (hardcoded to "jwt")
|
||||
jwtToken = r.URL.Query().Get("jwt")
|
||||
}
|
||||
|
||||
if jwtToken != "" {
|
||||
return getJwtUser(w, r, data, fn, jwtToken)
|
||||
}
|
||||
}
|
||||
|
||||
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
|
||||
isProxyUser := config.Auth.Methods.ProxyAuth.Enabled && proxyUser != ""
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.Auth.Key), nil
|
||||
}
|
||||
if data.token == "" {
|
||||
var err error
|
||||
data.token, err = extractToken(r)
|
||||
if err != nil && !isProxyUser {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
tokenString, err := extractToken(r)
|
||||
if err != nil && !isProxyUser {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
|
||||
data.token = tokenString
|
||||
var tk users.AuthToken
|
||||
token, err := jwt.ParseWithClaims(data.token, &tk, keyFunc)
|
||||
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
|
||||
if err != nil {
|
||||
if isProxyUser {
|
||||
return getProxyUser(w, r, data, fn, proxyUser)
|
||||
}
|
||||
// JWT library automatically validates expiration - if expired, it returns an error
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid token: %v", err)
|
||||
return http.StatusUnauthorized, fmt.Errorf("error processing token, %v", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid token")
|
||||
}
|
||||
if auth.IsRevokedApiToken(store.Access, data.token) {
|
||||
return http.StatusUnauthorized, fmt.Errorf("token is expired or revoked")
|
||||
if auth.IsRevokedApiKey(data.token) {
|
||||
if isProxyUser {
|
||||
return getProxyUser(w, r, data, fn, proxyUser)
|
||||
}
|
||||
return http.StatusUnauthorized, fmt.Errorf("token revoked")
|
||||
}
|
||||
// ExpiresAt should always be set in valid tokens created by our system
|
||||
// JWT library populates RegisteredClaims.ExpiresAt
|
||||
if tk.RegisteredClaims.ExpiresAt == nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("token is invalid or revoked")
|
||||
// If it's nil, the token is invalid
|
||||
if tk.ExpiresAt == nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid token: missing expiration")
|
||||
}
|
||||
// Check if token is about to expire for renewal header
|
||||
if tk.RegisteredClaims.ExpiresAt.Unix() < time.Now().Add(time.Minute*30).Unix() {
|
||||
if tk.ExpiresAt.Unix() < time.Now().Add(time.Minute*30).Unix() {
|
||||
w.Header().Add("X-Renew-Token", "true")
|
||||
}
|
||||
// Check if token is minimal/stateful (no BelongsTo in claim)
|
||||
if tk.BelongsTo == 0 {
|
||||
// Hash the token and look up user ID in access storage
|
||||
userID, found := store.Access.GetUserIDFromToken(data.token)
|
||||
if !found {
|
||||
return http.StatusUnauthorized, fmt.Errorf("token is invalid or revoked")
|
||||
tk.BelongsTo, err = getUserFromApiToken(data.token)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
tk.BelongsTo = userID
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Set cookie. Some clients like gvfs relies on it for concurrent uploads
|
||||
if tk.RegisteredClaims.ExpiresAt != nil {
|
||||
setSessionCookie(w, r, data.token, tk.RegisteredClaims.ExpiresAt.Time)
|
||||
if expire := tk.ExpiresAt; expire != nil {
|
||||
setSessionCookie(w, r, tokenString, expire.Time)
|
||||
}
|
||||
setUserInResponseWriter(w, data.user)
|
||||
if data.user.Username == "" {
|
||||
@@ -480,48 +422,6 @@ func withUserHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn handleFunc, jwtToken string) (int, error) {
|
||||
// Verify the external JWT token
|
||||
username, claims, err := auth.VerifyExternalJWT(
|
||||
jwtToken,
|
||||
config.Auth.Methods.JwtAuth.Secret,
|
||||
config.Auth.Methods.JwtAuth.Algorithm,
|
||||
config.Auth.Methods.JwtAuth.UserIdentifier,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Debugf("JWT verification failed: %v", err)
|
||||
return http.StatusForbidden, fmt.Errorf("JWT authentication failed: %w", err)
|
||||
}
|
||||
|
||||
// Setup user based on JWT claims
|
||||
user, err := setupJwtUser(r, data, username, claims)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
data.user = user
|
||||
setUserInResponseWriter(w, data.user)
|
||||
if data.user.Username == "" {
|
||||
return http.StatusForbidden, errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
// Generate a FileBrowser session token for JWT users if they don't have one
|
||||
if data.token == "" {
|
||||
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
|
||||
tokenString, _, err := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to generate token for JWT user %s: %v", username, err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to generate token")
|
||||
}
|
||||
data.token = tokenString
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func getProxyUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn handleFunc, proxyUser string) (int, error) {
|
||||
// proxy user logic
|
||||
user, err := setupProxyUser(r, data, proxyUser)
|
||||
@@ -536,12 +436,12 @@ func getProxyUser(w http.ResponseWriter, r *http.Request, data *requestContext,
|
||||
// Generate a token for proxy users if they don't have one
|
||||
if data.token == "" {
|
||||
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
|
||||
tokenString, _, err := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
|
||||
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to generate token for proxy user %s: %v", proxyUser, err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to generate token")
|
||||
}
|
||||
data.token = tokenString
|
||||
data.token = signed.Key
|
||||
}
|
||||
// Call the handler function, passing in the context (or return OK if no handler)
|
||||
if fn == nil {
|
||||
@@ -563,10 +463,16 @@ func withSelfOrAdminHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
|
||||
func wrapHandler(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandlerOpts(fn, wrapperOpts{})
|
||||
}
|
||||
|
||||
type wrapperOpts struct {
|
||||
requestBasicAuth bool
|
||||
}
|
||||
|
||||
func wrapHandlerOpts(fn handleFunc, opts wrapperOpts) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := &requestContext{
|
||||
ctx: r.Context(),
|
||||
}
|
||||
data := &requestContext{}
|
||||
|
||||
// Call the actual handler function and get status code and error
|
||||
status, err := fn(w, r, data)
|
||||
@@ -578,6 +484,10 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
|
||||
Message: err.Error(),
|
||||
}
|
||||
|
||||
if status == http.StatusUnauthorized && opts.requestBasicAuth {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
}
|
||||
|
||||
// Set the content type to JSON and status code
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
@@ -604,21 +514,6 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// wrapHandlerBasicAuth wraps a handler and automatically sets WWW-Authenticate header
|
||||
// for 401 Unauthorized responses, triggering Basic Auth challenge
|
||||
func wrapHandlerBasicAuth(fn handleFunc) http.HandlerFunc {
|
||||
// Wrap the handler to set WWW-Authenticate header for 401 responses
|
||||
wrappedFn := func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
status, err := fn(w, r, data)
|
||||
// Set WWW-Authenticate header before returning 401
|
||||
if status == http.StatusUnauthorized {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="WebDAV"`)
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
return wrapHandler(wrappedFn)
|
||||
}
|
||||
|
||||
func withPermShareHelper(fn handleFunc) handleFunc {
|
||||
return withUserHelper(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.user.Permissions.Share {
|
||||
@@ -628,30 +523,11 @@ func withPermShareHelper(fn handleFunc) handleFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// withBasicAuthHelper extracts Basic Auth credentials and uses the password as a JWT token
|
||||
// to authenticate the user. The username is ignored, and the password should be a JWT token.
|
||||
func withBasicAuthHelper(fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
_, password, ok := r.BasicAuth()
|
||||
if !ok || password == "" {
|
||||
// Return 401 - wrapHandlerBasicAuth will set WWW-Authenticate header
|
||||
return http.StatusUnauthorized, fmt.Errorf("basic authentication required")
|
||||
}
|
||||
data.token = password
|
||||
return withUserHelper(fn)(w, r, data)
|
||||
}
|
||||
}
|
||||
|
||||
// withBasicAuth returns an http.HandlerFunc for use with router.Handle.
|
||||
// It extracts Basic Auth credentials and uses the password as a JWT token to authenticate the user.
|
||||
func withBasicAuth(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandlerBasicAuth(withBasicAuthHelper(fn))
|
||||
}
|
||||
|
||||
func withPermShare(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(withPermShareHelper(fn))
|
||||
}
|
||||
|
||||
// Example of wrapping specific middleware functions for use with http.HandleFunc
|
||||
func withHashFile(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(withHashFileHelper(fn))
|
||||
}
|
||||
@@ -765,26 +641,6 @@ func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
|
||||
}
|
||||
}
|
||||
|
||||
func getRemoteIP(r *http.Request) string {
|
||||
// 1. Check X-Forwarded-For
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff != "" {
|
||||
// The first IP is the original client
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
|
||||
// 2. Check X-Real-IP
|
||||
xri := r.Header.Get("X-Real-IP")
|
||||
if xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// 3. Fallback to RemoteAddr (strip port if necessary)
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
return ip
|
||||
}
|
||||
|
||||
// LoggingMiddleware logs each request and its status code.
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -793,6 +649,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
if rcv := recover(); rcv != nil {
|
||||
method := r.Method
|
||||
url := r.URL.String()
|
||||
remoteAddr := r.RemoteAddr
|
||||
username := "unknown" // Default username
|
||||
|
||||
// Attempt to get username from ResponseWriterWrapper if it's set
|
||||
@@ -805,7 +662,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
stackTrace := string(buf[:n])
|
||||
|
||||
logger.Errorf("PANIC RECOVERED: %v\nUser: %s\nMethod: %s\nURL: %s\nRemoteAddr: %s\nGo Stack Trace:\n%s",
|
||||
rcv, username, method, url, getRemoteIP(r), stackTrace)
|
||||
rcv, username, method, url, remoteAddr, stackTrace)
|
||||
|
||||
// Attempt to send a 500 error response to the client
|
||||
// This is a best-effort; the connection might be broken or process too unstable.
|
||||
@@ -848,7 +705,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
|
||||
r.Method,
|
||||
wrappedWriter.StatusCode,
|
||||
getRemoteIP(r),
|
||||
r.RemoteAddr,
|
||||
truncUser,
|
||||
fmt.Sprintf("%vms", duration.Milliseconds()),
|
||||
fullURL))
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
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/access"
|
||||
@@ -53,7 +52,7 @@ func mockFileInfoFaster(t *testing.T) {
|
||||
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
|
||||
|
||||
// Mock the function to skip execution
|
||||
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: opts.Path,
|
||||
@@ -110,7 +109,7 @@ func TestWithAdminHelper(t *testing.T) {
|
||||
data := &requestContext{
|
||||
user: tc.user,
|
||||
}
|
||||
tokenString, _, err := auth.MakeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm, false)
|
||||
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Error making token for request: %v", err)
|
||||
}
|
||||
@@ -127,7 +126,7 @@ func TestWithAdminHelper(t *testing.T) {
|
||||
}
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: tokenString,
|
||||
Value: token.Key,
|
||||
})
|
||||
|
||||
// Call the handler with the test request and mock context
|
||||
|
||||
+30
-46
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
@@ -75,7 +74,7 @@ func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
|
||||
// oidcLoginHandler initiates OIDC login.
|
||||
// @Summary OIDC login
|
||||
// @Description Initiates OIDC login flow.
|
||||
// @Tags Auth
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 302 {string} string "Redirect to OIDC provider"
|
||||
@@ -109,7 +108,7 @@ func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// oidcCallbackHandler handles OIDC callback.
|
||||
// @Summary OIDC callback
|
||||
// @Description Handles OIDC login callback.
|
||||
// @Tags Auth
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param code query string false "OIDC code"
|
||||
@@ -247,29 +246,11 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// based on the configured UserIdentifier and logs the user into the application.
|
||||
// It creates a new user if one doesn't exist.
|
||||
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string, groups []string) (int, error) {
|
||||
oidcCfg := config.Auth.Methods.OidcAuth
|
||||
|
||||
// Check if user is in required groups (if userGroups is configured)
|
||||
if len(oidcCfg.UserGroups) > 0 {
|
||||
userInAllowedGroup := false
|
||||
for _, allowedGroup := range oidcCfg.UserGroups {
|
||||
if slices.Contains(groups, allowedGroup) {
|
||||
userInAllowedGroup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !userInAllowedGroup {
|
||||
logger.Warningf("User %s is not in any of the required groups %v. Access denied.", username, oidcCfg.UserGroups)
|
||||
return http.StatusForbidden, fmt.Errorf("user %s is not authorized to access this application (not in required groups)", username)
|
||||
}
|
||||
logger.Debugf("User %s is in required group, allowing access.", username)
|
||||
}
|
||||
|
||||
isAdmin := false // Default to non-admin user
|
||||
if oidcCfg.AdminGroup != "" {
|
||||
if slices.Contains(groups, oidcCfg.AdminGroup) {
|
||||
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, oidcCfg.AdminGroup)
|
||||
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)
|
||||
@@ -279,29 +260,32 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
if err.Error() != "the resource does not exist" {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// Auto-create user on first OIDC authentication
|
||||
if oidcCfg.AdminGroup == "" {
|
||||
isAdmin = config.UserDefaults.Permissions.Admin
|
||||
}
|
||||
user = &users.User{
|
||||
Username: username,
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
}
|
||||
settings.ApplyUserDefaults(user)
|
||||
if isAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
err = storage.CreateUser(*user, user.Permissions)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
user, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
if config.Auth.Methods.OidcAuth.CreateUser {
|
||||
if config.Auth.Methods.OidcAuth.AdminGroup == "" {
|
||||
isAdmin = config.UserDefaults.Permissions.Admin
|
||||
}
|
||||
user = &users.User{
|
||||
Username: username,
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
}
|
||||
settings.ApplyUserDefaults(user)
|
||||
if isAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
err = storage.CreateUser(*user, user.Permissions)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
user, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
} else {
|
||||
return http.StatusForbidden, fmt.Errorf("user %s does not exist and createUser is disabled. Your admin needs to create your user before you can access this application", username)
|
||||
}
|
||||
} else {
|
||||
// update user admin perms
|
||||
if isAdmin != user.Permissions.Admin && oidcCfg.AdminGroup != "" {
|
||||
if isAdmin != user.Permissions.Admin && config.Auth.Methods.OidcAuth.AdminGroup != "" {
|
||||
user.Permissions.Admin = isAdmin
|
||||
err = store.Users.Update(user, true, "Permissions")
|
||||
if err != nil {
|
||||
@@ -320,7 +304,7 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
|
||||
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
|
||||
// Generate a signed token for the user
|
||||
tokenString, _, err2 := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
|
||||
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
|
||||
if err2 != nil {
|
||||
// Handle potential errors during token generation
|
||||
if strings.Contains(err2.Error(), "key already exists with same name") {
|
||||
@@ -341,7 +325,7 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: tokenString,
|
||||
Value: signed.Key,
|
||||
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
|
||||
|
||||
@@ -77,43 +77,6 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
Groups: []string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preferred_username and sub",
|
||||
jsonData: `{"sub":"auth0|123","preferred_username":"jdoe","email":"jdoe@example.com"}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"sub": "auth0|123",
|
||||
"preferred_username": "jdoe",
|
||||
"email": "jdoe@example.com",
|
||||
},
|
||||
Groups: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty groups string",
|
||||
jsonData: `{"name":"Dana","groups":""}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"name": "Dana",
|
||||
"groups": "",
|
||||
},
|
||||
Groups: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single group in array",
|
||||
jsonData: `{"preferred_username":"single","groups":["admins"]}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"preferred_username": "single",
|
||||
"groups": []interface{}{"admins"},
|
||||
},
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -135,12 +98,3 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInfoUnmarshaller_InvalidJSON(t *testing.T) {
|
||||
var userdata userInfo
|
||||
u := &userInfoUnmarshaller{userInfo: &userdata, groupsClaim: "groups"}
|
||||
err := json.Unmarshal([]byte(`{invalid json}`), u)
|
||||
if err == nil {
|
||||
t.Fatal("Unmarshal expected to fail on invalid JSON")
|
||||
}
|
||||
}
|
||||
+45
-51
@@ -65,21 +65,6 @@ type OnlyOfficeJWTPayload struct {
|
||||
} `json:"actions"`
|
||||
}
|
||||
|
||||
// onlyofficeClientConfigGetHandler retrieves OnlyOffice client configuration
|
||||
//
|
||||
// @Summary Get OnlyOffice client configuration
|
||||
// @Description Returns the configuration needed for OnlyOffice document editor client
|
||||
// @Tags Office
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param source query string false "Source name"
|
||||
// @Param path query string false "File path"
|
||||
// @Param hash query string false "Share hash (for public shares)"
|
||||
// @Success 200 {object} map[string]interface{} "OnlyOffice configuration"
|
||||
// @Failure 400 {object} map[string]string "Missing or invalid parameters"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Router /api/office/config [get]
|
||||
// @Security ApiKeyAuth
|
||||
func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if settings.Config.Integrations.OnlyOffice.Url == "" {
|
||||
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
|
||||
@@ -99,7 +84,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(providedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
providedPath = cleanPath
|
||||
|
||||
@@ -131,7 +116,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
Source: source,
|
||||
Expand: false,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, d.user, store.Share)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, path, err)
|
||||
return errToStatus(err), err
|
||||
@@ -280,12 +265,14 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
|
||||
}
|
||||
|
||||
escapedPath := url.QueryEscape(path)
|
||||
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s&source=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), escapedPath, token, url.QueryEscape(source))
|
||||
downloadURL := fmt.Sprintf("%s/api/raw?file=%s&auth=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), escapedPath, token)
|
||||
if hash != "" {
|
||||
downloadURL = fmt.Sprintf("%s/public/api/resources/download?file=%s&auth=%s&hash=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), escapedPath, token, hash)
|
||||
downloadURL = downloadURL + "&hash=" + hash
|
||||
} else {
|
||||
downloadURL = downloadURL + "&source=" + url.QueryEscape(source)
|
||||
}
|
||||
|
||||
return downloadURL
|
||||
}
|
||||
|
||||
@@ -331,7 +318,7 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
|
||||
params.Set("path", path) // This should be the path relative to the share, not the full filesystem path
|
||||
params.Set("auth", token)
|
||||
|
||||
callbackURL = fmt.Sprintf("%s/public/api/office/callback?%s",
|
||||
callbackURL = fmt.Sprintf("%s/public/api/onlyoffice/callback?%s",
|
||||
strings.TrimSuffix(baseURL, "/"), params.Encode())
|
||||
} else {
|
||||
// Regular callback URL - include source for non-share requests
|
||||
@@ -340,7 +327,7 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
|
||||
params.Set("path", path)
|
||||
params.Set("auth", token)
|
||||
|
||||
callbackURL = fmt.Sprintf("%s/api/office/callback?%s",
|
||||
callbackURL = fmt.Sprintf("%s/api/onlyoffice/callback?%s",
|
||||
strings.TrimSuffix(baseURL, "/"), params.Encode())
|
||||
}
|
||||
|
||||
@@ -352,13 +339,6 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// Extract clean parameters from query string
|
||||
source := r.URL.Query().Get("source")
|
||||
path := r.URL.Query().Get("path")
|
||||
user := d.user
|
||||
|
||||
if d.share != nil {
|
||||
source = d.share.GetSourceName()
|
||||
path = d.IndexPath
|
||||
user = d.shareUser
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (path == "" || source == "") && d.fileInfo.Hash == "" {
|
||||
@@ -369,10 +349,40 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return returnOnlyOfficeError(w, r, 400, err.Error())
|
||||
logger.Errorf("OnlyOffice callback: invalid path: %v", err)
|
||||
return returnOnlyOfficeError(w, r, 400, "invalid path")
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
var sourceInfo *settings.Source
|
||||
var ok bool
|
||||
if d.fileInfo.Hash != "" {
|
||||
sourceInfo, ok = settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: share source not found")
|
||||
return returnOnlyOfficeError(w, r, 404, "source not found")
|
||||
}
|
||||
} else {
|
||||
sourceInfo, ok = settings.Config.Server.NameToSource[source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: source not found")
|
||||
return returnOnlyOfficeError(w, r, 404, "source not found")
|
||||
}
|
||||
}
|
||||
|
||||
if d.fileInfo.Hash == "" {
|
||||
// Regular user request - need to resolve scope
|
||||
userScope, scopeErr := d.user.GetScopeForSourceName(source)
|
||||
if scopeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
|
||||
return returnOnlyOfficeError(w, r, 403, "source not available")
|
||||
}
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
} else {
|
||||
source = sourceInfo.Name
|
||||
// path is index path, so we build from share path
|
||||
path = utils.JoinPathAsUnix(d.share.Path, path)
|
||||
}
|
||||
// Handle document closure - clean up document key cache
|
||||
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
|
||||
data.Status == onlyOfficeStatusDocumentClosedWithNoChanges {
|
||||
@@ -477,14 +487,14 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
// Check share permissions first if this is a share request
|
||||
if d.share != nil {
|
||||
if d.fileInfo.Hash != "" {
|
||||
if !d.share.AllowModify {
|
||||
logger.Errorf("OnlyOffice callback: edit permission not allowed for this share")
|
||||
return returnOnlyOfficeError(w, r, 403, "edit permission not allowed for this share")
|
||||
}
|
||||
} else {
|
||||
// Verify user has modify permissions
|
||||
if !user.Permissions.Modify {
|
||||
if !d.user.Permissions.Modify {
|
||||
logger.Errorf("OnlyOffice callback: user %s lacks modify permissions for path=%s",
|
||||
d.user.Username, path)
|
||||
return returnOnlyOfficeError(w, r, 403, "user lacks modify permissions")
|
||||
@@ -518,7 +528,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
_, err = files.FileInfoFaster(utils.FileOptions{
|
||||
Source: source,
|
||||
Path: path,
|
||||
}, store.Access, user, store.Share)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice callback: original file no longer exists at path=%s: %v",
|
||||
path, err)
|
||||
@@ -533,7 +543,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
// Get user scope to resolve full index path for write operation
|
||||
userScope, err := user.GetScopeForSourceName(source)
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return returnOnlyOfficeError(w, r, 403, "user scope not found")
|
||||
}
|
||||
@@ -565,22 +575,6 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
return returnOnlyOfficeSuccess(w, r)
|
||||
}
|
||||
|
||||
// onlyofficeCallbackHandler handles OnlyOffice document server callbacks
|
||||
//
|
||||
// @Summary Handle OnlyOffice document server callback
|
||||
// @Description Receives callbacks from OnlyOffice document server for document status changes and saves
|
||||
// @Tags Office
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param source query string false "Source name"
|
||||
// @Param path query string false "File path"
|
||||
// @Param hash query string false "Share hash (for public shares)"
|
||||
// @Success 200 {object} map[string]interface{} "Callback processed successfully"
|
||||
// @Failure 400 {object} map[string]string "Invalid callback data"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Router /api/office/callback [post]
|
||||
// @Router /api/office/callback [get]
|
||||
// @Security ApiKeyAuth
|
||||
func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Parse callback data based on request method
|
||||
var callbackData *OnlyOfficeCallback
|
||||
|
||||
+69
-80
@@ -53,7 +53,7 @@ func isClientCancellation(ctx context.Context, err error) bool {
|
||||
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Failure 501 {object} map[string]string "Preview generation not implemented"
|
||||
// @Router /api/resources/preview [get]
|
||||
// @Router /api/preview [get]
|
||||
func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if config.Server.DisablePreviews {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
@@ -71,7 +71,7 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
Source: source,
|
||||
AlbumArt: true, // Extract album art for audio previews
|
||||
Expand: true,
|
||||
}, store.Access, d.user, store.Share)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
logger.Errorf("error getting file info: %v", err)
|
||||
return errToStatus(err), err
|
||||
@@ -99,60 +99,60 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// getDirectoryPreview returns the previewable file at the given frame index (0–3) for motion preview.
|
||||
// atPercentage maps to frame: 0→0, 1–25→1, 26–50→2, 51–100→3. The returned file is frameIndex % n where n is the number of previewable items.
|
||||
func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*iteminfo.ExtendedFileInfo, error) {
|
||||
// Build list of previewable item names in stable order (same as d.fileInfo.Files)
|
||||
var previewableNames []string
|
||||
// getDirectoryPreview finds a valid preview file within a directory.
|
||||
func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.ExtendedFileInfo, error) {
|
||||
var lastErr error
|
||||
for _, item := range d.fileInfo.Files {
|
||||
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
continue
|
||||
}
|
||||
previewableNames = append(previewableNames, item.Name)
|
||||
}
|
||||
if len(previewableNames) == 0 {
|
||||
return nil, fmt.Errorf("no previewable files found in directory")
|
||||
}
|
||||
// Cycle: 1 image → always 0; 2 images → 0,1,0,1; 3 → 0,1,2,0; 4 → 0,1,2,3
|
||||
index := frameIndex % len(previewableNames)
|
||||
name := previewableNames[index]
|
||||
|
||||
source := d.fileInfo.Source
|
||||
path := utils.JoinPathAsUnix(d.fileInfo.Path, name)
|
||||
user := d.user
|
||||
if d.share != nil {
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source not found for share")
|
||||
}
|
||||
source = sourceInfo.Name
|
||||
path = d.IndexPath + name
|
||||
user = d.shareUser
|
||||
}
|
||||
fileInfo, err := files.FileInfoFaster(
|
||||
utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
AlbumArt: true,
|
||||
Metadata: true,
|
||||
}, store.Access, user, store.Share)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tempCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
|
||||
cancel()
|
||||
if previewErr != nil {
|
||||
if !errors.Is(previewErr, context.Canceled) && !errors.Is(previewErr, context.DeadlineExceeded) {
|
||||
logger.Debugf("Skipping preview file in directory '%s': %s (error: %v)", d.fileInfo.Name, name, previewErr)
|
||||
// Fallback: try first item (frame 0) once so atPercentage=0 still works when another frame fails
|
||||
if index != 0 {
|
||||
return getDirectoryPreview(r, d, 0)
|
||||
source := d.fileInfo.Source
|
||||
path := utils.JoinPathAsUnix(d.fileInfo.Path, item.Name)
|
||||
if d.share != nil {
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source not found for share")
|
||||
}
|
||||
source = sourceInfo.Name
|
||||
path = d.IndexPath + item.Name
|
||||
}
|
||||
return nil, previewErr
|
||||
fileInfo, err := files.FileInfoFaster(
|
||||
utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
AlbumArt: true, // Extract album art for audio previews
|
||||
Metadata: true,
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue // Try next file if this one fails
|
||||
}
|
||||
// Try to generate preview to verify the file is not corrupted
|
||||
tempCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
|
||||
cancel()
|
||||
if previewErr != nil {
|
||||
// Skip context errors (timeout or cancellation) - they're not corruption issues
|
||||
if !errors.Is(previewErr, context.Canceled) && !errors.Is(previewErr, context.DeadlineExceeded) {
|
||||
// File might be corrupted, try next one
|
||||
logger.Debugf("Skipping preview file in directory '%s': %s (error: %v)",
|
||||
d.fileInfo.Name, item.Name, previewErr)
|
||||
} else {
|
||||
// if it is a context error, return the error
|
||||
// don't keep trying
|
||||
return nil, previewErr
|
||||
}
|
||||
lastErr = previewErr
|
||||
continue
|
||||
}
|
||||
return fileInfo, nil
|
||||
}
|
||||
return fileInfo, nil
|
||||
// If we exhausted all files and none worked, return the last error
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("no valid preview files found in directory: %w", lastErr)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no previewable files found in directory")
|
||||
}
|
||||
|
||||
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -164,39 +164,14 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
return http.StatusBadRequest, fmt.Errorf("this item does not have a preview")
|
||||
}
|
||||
|
||||
seekPercentage := 0
|
||||
percentage := r.URL.Query().Get("atPercentage")
|
||||
if percentage != "" {
|
||||
var err error
|
||||
seekPercentage, err = strconv.Atoi(percentage)
|
||||
if err != nil {
|
||||
seekPercentage = 0
|
||||
}
|
||||
if seekPercentage < 0 || seekPercentage > 100 {
|
||||
seekPercentage = 0
|
||||
}
|
||||
}
|
||||
|
||||
// For directories: map atPercentage to frame index 0–3 for motion preview (cycle over previewable items)
|
||||
var dirFrameIndex int
|
||||
if d.fileInfo.Type == "directory" {
|
||||
switch {
|
||||
case seekPercentage <= 0:
|
||||
dirFrameIndex = 0
|
||||
case seekPercentage <= 25:
|
||||
dirFrameIndex = 1
|
||||
case seekPercentage <= 50:
|
||||
dirFrameIndex = 2
|
||||
default:
|
||||
dirFrameIndex = 3
|
||||
}
|
||||
fileInfo, err := getDirectoryPreview(r, d, dirFrameIndex)
|
||||
// Get extended file info of first previewable item in directory
|
||||
fileInfo, err := getDirectoryPreview(r, d)
|
||||
if err != nil {
|
||||
logger.Errorf("error getting directory preview: %v", err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
d.fileInfo = *fileInfo
|
||||
seekPercentage = 0
|
||||
}
|
||||
|
||||
setContentDisposition(w, r, d.fileInfo.Name)
|
||||
@@ -204,15 +179,29 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
|
||||
resizable := iteminfo.ResizableImageTypes[ext]
|
||||
|
||||
// For small, displayable images (jpg, png, etc.) serve the original to avoid processing.
|
||||
const maxSizeForOriginal = 128 * 1024 // 128KB
|
||||
if resizable && (config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
|
||||
// If image is under 500KB, serve the original instead of generating a preview
|
||||
const maxSizeForOriginal = 500 * 1024 // 500KB
|
||||
if (!resizable || config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
|
||||
return rawFileHandler(w, r, d.fileInfo)
|
||||
}
|
||||
|
||||
seekPercentage := 0
|
||||
percentage := r.URL.Query().Get("atPercentage")
|
||||
if percentage != "" {
|
||||
var err error
|
||||
// convert string to int
|
||||
seekPercentage, err = strconv.Atoi(percentage)
|
||||
if err != nil {
|
||||
seekPercentage = 10
|
||||
}
|
||||
if seekPercentage < 0 || seekPercentage > 100 {
|
||||
seekPercentage = 10
|
||||
}
|
||||
}
|
||||
|
||||
officeUrl := ""
|
||||
if d.fileInfo.OnlyOfficeId != "" {
|
||||
pathUrl := fmt.Sprintf("/api/resources/download?file=%s&source=%s", url.QueryEscape(d.fileInfo.Path), url.QueryEscape(d.fileInfo.Source))
|
||||
pathUrl := fmt.Sprintf("/api/raw?file=%s&source=%s", url.QueryEscape(d.fileInfo.Path), url.QueryEscape(d.fileInfo.Source))
|
||||
pathUrl = pathUrl + "&auth=" + d.token
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
officeUrl = config.Server.InternalUrl + pathUrl
|
||||
|
||||
+41
-47
@@ -4,26 +4,28 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"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/preview"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
|
||||
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
|
||||
)
|
||||
|
||||
// publicDownloadHandler serves the raw content of a file, multiple files, or directory via a public share.
|
||||
// publicRawHandler serves the raw content of a file, multiple files, or directory via a public share.
|
||||
// @Summary Download files from a public share
|
||||
// @Description Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.
|
||||
// @Description
|
||||
// @Description **Multiple Files:**
|
||||
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
|
||||
// @Description - This supports filenames containing commas and special characters
|
||||
// @Tags Shares
|
||||
// @Tags Public Shares
|
||||
// @Accept json
|
||||
// @Produce octet-stream
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
@@ -36,8 +38,8 @@ import (
|
||||
// @Failure 404 {object} map[string]string "Share not found or file not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Failure 501 {object} map[string]string "Downloads disabled for upload shares"
|
||||
// @Router /public/api/resources/download [get]
|
||||
func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// @Router /public/api/raw [get]
|
||||
func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share.ShareType == "upload" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("downloads are disabled for upload shares")
|
||||
}
|
||||
@@ -114,7 +116,7 @@ func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
// publicShareHandler returns file or directory information from a public share.
|
||||
// @Summary Get file/directory information from a public share
|
||||
// @Description Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.
|
||||
// @Tags Shares
|
||||
// @Tags Public Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
@@ -137,7 +139,7 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *request
|
||||
// publicUploadHandler processes file uploads to a public upload share.
|
||||
// @Summary Upload files to a public upload share
|
||||
// @Description Handles file and directory uploads to an upload-only public share. Supports chunked uploads, conflict resolution (override), and directory creation.
|
||||
// @Tags Shares
|
||||
// @Tags Public Shares
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
@@ -179,6 +181,7 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// @Summary Health Check
|
||||
// @Schemes
|
||||
// @Description Returns the health status of the API.
|
||||
// @Tags Health
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} HttpResponse "successful health check response"
|
||||
@@ -195,7 +198,7 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// publicPreviewHandler handles the preview request for images from public shares.
|
||||
// @Summary Get image/video preview from a public share
|
||||
// @Description Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.
|
||||
// @Tags Shares
|
||||
// @Tags Public Shares
|
||||
// @Accept json
|
||||
// @Produce image/jpeg
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
@@ -206,7 +209,7 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 404 {object} map[string]string "File not found or preview not available"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Failure 501 {object} map[string]string "Previews disabled globally, for this share, or for upload shares"
|
||||
// @Router /public/api/resources/preview [get]
|
||||
// @Router /public/api/preview [get]
|
||||
func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if config.Server.DisablePreviews || d.share.DisableThumbnails {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
@@ -226,7 +229,7 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
// publicPutHandler handles the PUT request for a public share.
|
||||
// @Summary Update a file in a public share
|
||||
// @Description Updates the content of a file in a public share.
|
||||
// @Tags Shares
|
||||
// @Tags Public Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
@@ -257,7 +260,7 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
|
||||
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
|
||||
@@ -279,7 +282,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
FollowSymlinks: true,
|
||||
Path: d.IndexPath,
|
||||
Source: d.share.Source,
|
||||
}, store.Access, d.shareUser, store.Share)
|
||||
}, store.Access, d.shareUser)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
@@ -296,7 +299,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// publicBulkDeleteHandler deletes multiple resources from a public share in a single request.
|
||||
// @Summary Bulk delete resources from public share
|
||||
// @Description Deletes multiple resources specified in the request body. Returns a list of succeeded and failed deletions.
|
||||
// @Tags Shares
|
||||
// @Tags Public Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
@@ -306,7 +309,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or empty items array"
|
||||
// @Failure 403 {object} map[string]string "Forbidden - delete not allowed for this share"
|
||||
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
|
||||
// @Router /public/api/resources/bulk [delete]
|
||||
// @Router /public/api/resources/bulk/delete [post]
|
||||
func publicBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.share.AllowDelete {
|
||||
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
|
||||
@@ -323,7 +326,7 @@ func publicBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestC
|
||||
// publicPatchHandler performs a patch operation (e.g., move, copy, rename) on resources in a public share.
|
||||
// @Summary Move, copy, or rename resources in a public share
|
||||
// @Description Performs move, copy, or rename operations on multiple resources within a public share. All operations are performed atomically.
|
||||
// @Tags Shares
|
||||
// @Tags Public Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
@@ -392,7 +395,7 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// getShareImage serves banner or favicon files for shares as resizable previews
|
||||
// @Summary Get share image (banner or favicon) as preview
|
||||
// @Description Returns a resizable preview (large size) for the banner or favicon file of a share
|
||||
// @Tags Shares
|
||||
// @Tags Public Shares
|
||||
// @Produce image/jpeg
|
||||
// @Param hash query string true "Share hash"
|
||||
// @Param banner query bool false "Request banner file"
|
||||
@@ -416,7 +419,7 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
|
||||
}
|
||||
|
||||
sourceName, assetPath, err := d.share.GetShareImagePartsHelper(isBanner)
|
||||
sourceName, assetPath, err := getShareImagePartsHelper(d.share, isBanner)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid asset configuration: %v", err)
|
||||
}
|
||||
@@ -430,7 +433,7 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
Metadata: false,
|
||||
ShowHidden: false,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, shareCreatedByUser, store.Share)
|
||||
}, store.Access, shareCreatedByUser)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("error accessing share asset: source=%v path=%v error=%v", sourceName, assetPath, err)
|
||||
@@ -462,37 +465,28 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return status, err
|
||||
}
|
||||
|
||||
// publicItemsGetHandler efficiently returns a basic list of items for a directory in a public share.
|
||||
// @Summary Get directory items (public share)
|
||||
// @Description Efficiently returns a basic list of items for the specified path in a public share. Use hash for authentication instead of source. Use 'only' parameter to filter by only files or folders.
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
// @Param path query string false "Path within the share to list child items. Defaults to share root."
|
||||
// @Param only query string false "Filter: 'files', 'folders', or omit for both"
|
||||
// @Success 200 {object} files.Items "lists files and folders"
|
||||
// @Failure 403 {object} map[string]string "Forbidden (access denied)"
|
||||
// @Failure 404 {object} map[string]string "Share not found or source not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /public/api/resources/items [get]
|
||||
func publicItemsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
sourceInfo, ok := config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("source not found")
|
||||
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
|
||||
|
||||
// Get the asset query string from share
|
||||
var assetQueryString string
|
||||
if isBanner {
|
||||
assetQueryString = share.Banner
|
||||
} else {
|
||||
assetQueryString = share.Favicon
|
||||
}
|
||||
items, err := files.GetDirItems(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: d.IndexPath,
|
||||
Source: sourceInfo.Name,
|
||||
ShowHidden: d.shareUser.ShowHidden,
|
||||
Only: r.URL.Query().Get("only"),
|
||||
}, store.Access, d.shareUser)
|
||||
|
||||
if assetQueryString == "" {
|
||||
return "", "", fmt.Errorf("asset not configured for this share")
|
||||
}
|
||||
|
||||
// Parse the query string to extract source and path
|
||||
assetParams, err := url.ParseQuery(assetQueryString)
|
||||
if err != nil {
|
||||
if err == errors.ErrAccessDenied {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
return http.StatusInternalServerError, err
|
||||
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
|
||||
}
|
||||
return renderJSON(w, r, items)
|
||||
|
||||
sourceName := assetParams.Get("source")
|
||||
assetPath := assetParams.Get("path")
|
||||
|
||||
return sourceName, assetPath, nil
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
@@ -24,13 +29,6 @@ type throttledReadSeeker struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// throttledWriter is a wrapper around an io.Writer that throttles the writing speed.
|
||||
type throttledWriter struct {
|
||||
w io.Writer
|
||||
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{
|
||||
@@ -58,29 +56,6 @@ func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
return r.rs.Seek(offset, whence)
|
||||
}
|
||||
|
||||
// newThrottledWriter creates a new throttledWriter.
|
||||
func newThrottledWriter(w io.Writer, limit rate.Limit, burst int, ctx context.Context) *throttledWriter {
|
||||
return &throttledWriter{
|
||||
w: w,
|
||||
limiter: rate.NewLimiter(limit, burst),
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *throttledWriter) Write(p []byte) (n int, err error) {
|
||||
n, err = w.w.Write(p)
|
||||
if n > 0 {
|
||||
if waitErr := w.limiter.WaitN(w.ctx, n); waitErr != nil {
|
||||
if err != nil {
|
||||
// The original error (like io.EOF) is potentially more important
|
||||
// than the context error.
|
||||
err = waitErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// toASCIIFilename converts a filename to ASCII-safe format by replacing non-ASCII characters with underscores
|
||||
func toASCIIFilename(fileName string) string {
|
||||
var result strings.Builder
|
||||
@@ -110,8 +85,8 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q; filename*=utf-8''%s", dispositionType, asciiFileName, encodedFileName))
|
||||
}
|
||||
|
||||
// downloadHandler serves the raw content of a file, multiple files, or directory in various formats.
|
||||
// @Summary Download content of a file, multiple files, or directory
|
||||
// rawHandler serves the raw content of a file, multiple files, or directory in various formats.
|
||||
// @Summary Get raw content of a file, multiple files, or directory
|
||||
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
|
||||
// @Description
|
||||
// @Description **Filename Encoding:**
|
||||
@@ -133,8 +108,8 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
// @Failure 400 {object} map[string]string "Invalid request path"
|
||||
// @Failure 404 {object} map[string]string "File or directory not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources/download [get]
|
||||
func downloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// @Router /api/raw [get]
|
||||
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
source := r.URL.Query().Get("source")
|
||||
fileList := r.URL.Query()["file"]
|
||||
|
||||
@@ -150,6 +125,149 @@ func downloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return rawFilesHandler(w, r, d, source, fileList)
|
||||
}
|
||||
|
||||
func addFile(source string, path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
|
||||
// Path handling is now done inside FileInfoFaster
|
||||
// For shares, path comes from share context
|
||||
// For regular users, FileInfoFaster will apply user scope
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
// Check access control directly for each file and silently skip if access is denied
|
||||
if d.share == nil && store.Access != nil {
|
||||
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
return nil // Silently skip this file/folder
|
||||
}
|
||||
}
|
||||
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the base name of the top-level folder or file
|
||||
baseName := filepath.Base(realPath)
|
||||
|
||||
if info.IsDir() {
|
||||
// Walk through directory contents
|
||||
return filepath.Walk(realPath, func(filePath string, fileInfo os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate the relative path
|
||||
relPath, err := filepath.Rel(realPath, filePath) // Use realPath directly
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Normalize for tar: convert \ to /
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
|
||||
// Skip adding `.` (current directory)
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check access control for each file/folder during walk
|
||||
if d.share == nil {
|
||||
indexRelPath := utils.JoinPathAsUnix(path, relPath)
|
||||
indexRelPath = filepath.ToSlash(indexRelPath) // Normalize separators
|
||||
if !store.Access.Permitted(idx.Path, indexRelPath, d.user.Username) {
|
||||
// Skip this file/folder silently
|
||||
if fileInfo.IsDir() {
|
||||
// Skip the entire directory by returning filepath.SkipDir
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend base folder name unless flatten is true
|
||||
if !flatten {
|
||||
relPath = filepath.Join(baseName, relPath)
|
||||
relPath = filepath.ToSlash(relPath) // Ensure normalized separators
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
if tarWriter != nil {
|
||||
header := &tar.Header{
|
||||
Name: relPath + "/",
|
||||
Mode: int64(fileutils.PermDir),
|
||||
Typeflag: tar.TypeDir,
|
||||
ModTime: fileInfo.ModTime(),
|
||||
}
|
||||
return tarWriter.WriteHeader(header)
|
||||
}
|
||||
if zipWriter != nil {
|
||||
_, err := zipWriter.Create(relPath + "/")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.ToSlash(archivePath)
|
||||
if err = tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
return err
|
||||
}
|
||||
|
||||
if zipWriter != nil {
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = archivePath
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
|
||||
if !d.user.Permissions.Download && d.share == nil {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to download")
|
||||
@@ -219,7 +337,11 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
}
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Compute estimated download size
|
||||
estimatedSize, err := computeArchiveSize(source, fileList, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// ** Single file download with Content-Length **
|
||||
if len(fileList) == 1 && !isDir {
|
||||
// Get document ID and log context for OnlyOffice downloads
|
||||
@@ -279,6 +401,11 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
|
||||
sizeInMB := estimatedSize / 1024 / 1024
|
||||
// if larger than 500 MB, log it
|
||||
if sizeInMB > 500 {
|
||||
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName)
|
||||
}
|
||||
// serve content allows for range requests.
|
||||
// video scrubbing, etc.
|
||||
// Note: http.ServeContent will respect our already-set Content-Disposition header
|
||||
@@ -294,8 +421,177 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
// ** Archive (ZIP/TAR.GZ) handling ** — delegate to archive package
|
||||
return BuildAndStreamArchive(w, r, d, source, fileList)
|
||||
if config.Server.MaxArchiveSizeGB > 0 {
|
||||
maxSize := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
|
||||
if estimatedSize > maxSize {
|
||||
return http.StatusRequestEntityTooLarge, fmt.Errorf("pre-archive combined size of files exceeds maximum limit of %d GB", config.Server.MaxArchiveSizeGB)
|
||||
}
|
||||
}
|
||||
// ** Archive (ZIP/TAR.GZ) handling **
|
||||
algo := r.URL.Query().Get("algo")
|
||||
var extension string
|
||||
switch algo {
|
||||
case "zip", "true", "":
|
||||
extension = ".zip"
|
||||
case "tar.gz":
|
||||
extension = ".tar.gz"
|
||||
default:
|
||||
return http.StatusInternalServerError, errors.New("format not implemented")
|
||||
}
|
||||
|
||||
baseDirName := filepath.Base(filepath.Dir(firstFilePath))
|
||||
if baseDirName == "" || baseDirName == "/" {
|
||||
baseDirName = "download"
|
||||
}
|
||||
if len(fileList) == 1 && isDir {
|
||||
baseDirName = filepath.Base(realPath)
|
||||
}
|
||||
// Store original filename before any encoding
|
||||
originalFileName := baseDirName + extension
|
||||
|
||||
archiveData := filepath.Join(config.Server.CacheDir, utils.InsecureRandomIdentifier(10))
|
||||
if extension == ".zip" {
|
||||
archiveData = archiveData + ".zip"
|
||||
err = createZip(d, source, archiveData, fileList...)
|
||||
} else {
|
||||
archiveData = archiveData + ".tar.gz"
|
||||
err = createTarGz(d, source, archiveData, fileList...)
|
||||
}
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// stream archive to response
|
||||
fd, err := os.Open(archiveData)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
// Get file size
|
||||
fileInfo, err := fd.Stat()
|
||||
if err != nil {
|
||||
os.Remove(archiveData) // Remove the file if stat fails
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
sizeInMB := fileInfo.Size() / 1024 / 1024
|
||||
if sizeInMB > 500 {
|
||||
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, originalFileName)
|
||||
}
|
||||
|
||||
// Set headers AFTER computing actual archive size
|
||||
// Use the same setContentDisposition logic for archives
|
||||
setContentDisposition(w, r, originalFileName)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
// Stream the file
|
||||
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.Debugf("client likely aborted: %v, failed to copy archive data to response: %v", r.Context().Err(), err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func computeArchiveSize(source string, fileList []string, d *requestContext) (int64, error) {
|
||||
var estimatedSize int64
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return 0, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
for _, path := range fileList {
|
||||
if d.share == nil {
|
||||
// Check access control for each file in the archive
|
||||
// Silently skip if access is denied (as if the file doesn't exist)
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
continue // Skip this file and continue with the next one
|
||||
}
|
||||
}
|
||||
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
realPath, isDir, err := idx.GetRealPath(path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
indexPath := idx.MakeIndexPath(realPath, isDir)
|
||||
info, ok := idx.GetReducedMetadata(indexPath, isDir)
|
||||
if !ok {
|
||||
info, err = idx.GetFsInfo(indexPath, false, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
|
||||
}
|
||||
}
|
||||
estimatedSize += info.Size
|
||||
}
|
||||
return estimatedSize, nil
|
||||
}
|
||||
|
||||
func createZip(d *requestContext, source string, tmpDirPath string, filenames ...string) error {
|
||||
file, err := os.Create(tmpDirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(file)
|
||||
|
||||
for _, filepath := range filenames {
|
||||
err := addFile(source, filepath, d, nil, zipWriter, false)
|
||||
if err != nil {
|
||||
// Access control failures return nil, so any error here is a real error
|
||||
logger.Errorf("Failed to add %s to ZIP: %v", filepath, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Close the ZIP writer and check for errors
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize ZIP archive: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createTarGz(d *requestContext, source string, tmpDirPath string, filenames ...string) error {
|
||||
file, err := os.Create(tmpDirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzWriter := gzip.NewWriter(file)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
for _, filepath := range filenames {
|
||||
err := addFile(source, filepath, d, tarWriter, nil, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", filepath, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Close writers in reverse order and check for errors
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize TAR archive: %w", err)
|
||||
}
|
||||
if err := gzWriter.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize GZIP compression: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isOnlyOfficeCompatibleFile checks if a file extension is supported by OnlyOffice
|
||||
+18
-53
@@ -61,7 +61,6 @@ func validateMoveOperation(src, dst string, isSrcDir bool) error {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the resource"
|
||||
// @Param skipExtendedAttrs query string false "Skip extended attributes for faster retrieval, no hasPreview"
|
||||
// @Param source query string true "Source name for the desired source, default is used if not provided"
|
||||
// @Param content query string false "Include file content if true"
|
||||
// @Param metadata query string false "Extract audio/video metadata if true"
|
||||
@@ -75,7 +74,6 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
source := r.URL.Query().Get("source")
|
||||
getContent := r.URL.Query().Get("content") == "true"
|
||||
getMetadata := r.URL.Query().Get("metadata") == "true"
|
||||
skipExtendedAttrs := r.URL.Query().Get("skipExtendedAttrs") == "true"
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: path,
|
||||
@@ -85,9 +83,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
Metadata: getMetadata,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
SkipExtendedAttrs: skipExtendedAttrs,
|
||||
ShowSharedAttr: true,
|
||||
}, store.Access, d.user, store.Share)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -141,7 +137,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
Source: source,
|
||||
Expand: false,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
}, store.Access, d.user, store.Share)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -205,7 +201,7 @@ type MoveCopyResponse struct {
|
||||
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or empty items array"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
|
||||
// @Router /api/resources/bulk [delete]
|
||||
// @Router /api/resources/bulk/delete [post]
|
||||
func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
filePermUser := d.user
|
||||
if d.share != nil {
|
||||
@@ -278,7 +274,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
Path: indexPath,
|
||||
Source: sourceName,
|
||||
ShowHidden: true,
|
||||
}, store.Access, filePermUser, store.Share)
|
||||
}, store.Access, filePermUser)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
@@ -331,10 +327,10 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// Get file info
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: item.Path,
|
||||
Path: idx.MakeIndexPath(item.Path, false),
|
||||
Source: item.Source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, filePermUser, store.Share)
|
||||
}, store.Access, filePermUser)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -390,7 +386,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
@@ -491,7 +487,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
|
||||
var fileInfo *iteminfo.ExtendedFileInfo
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser, store.Share)
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
@@ -550,7 +546,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser, store.Share)
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
@@ -589,15 +585,21 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
// Only allow PUT for files.
|
||||
if strings.HasSuffix(path, "/") {
|
||||
return http.StatusMethodNotAllowed, nil
|
||||
}
|
||||
// Get user scope to resolve full index path for write operation
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
// Check access control for the target path
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
@@ -608,14 +610,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
|
||||
// check if destination is a directory
|
||||
stat, err := os.Stat(filepath.Join(idx.Path + fullIndexPath))
|
||||
if err == nil && stat.IsDir() {
|
||||
// if directory return StatusMethodNotAllowed
|
||||
return http.StatusMethodNotAllowed, fmt.Errorf("path is a directory")
|
||||
}
|
||||
|
||||
err = files.WriteFile(source, fullIndexPath, r.Body)
|
||||
err = files.WriteFile(source, utils.JoinPathAsUnix(userScope, path), r.Body)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
@@ -932,7 +927,7 @@ func patchAction(ctx context.Context, params patchActionParams) error {
|
||||
Source: params.srcIndex,
|
||||
IsDir: params.isSrcDir,
|
||||
ShowHidden: params.d.user.ShowHidden,
|
||||
}, store.Access, params.d.user, store.Share)
|
||||
}, store.Access, params.d.user)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -970,33 +965,3 @@ func mockData(w http.ResponseWriter, r *http.Request) {
|
||||
mockDir := indexing.CreateMockData(NumDirs, numFiles)
|
||||
renderJSON(w, r, mockDir) // nolint:errcheck
|
||||
}
|
||||
|
||||
// itemsGetHandler efficiently returns a basic list of items for a directory.
|
||||
// @Summary Get directory items
|
||||
// @Description Efficiently returns a basic list of items for the specified path and source. Use 'only' parameter to filter by only files or folders
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "A directory path to list child items"
|
||||
// @Param source query string true "The source name which contains the path"
|
||||
// @Param only query string false "Filter: 'files', 'folders', or omit for both"
|
||||
// @Success 200 {object} files.Items "lists files and folders"
|
||||
// @Failure 403 {object} map[string]string "Forbidden (access denied)"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources/items [get]
|
||||
func itemsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
items, err := files.GetDirItems(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: r.URL.Query().Get("path"),
|
||||
Source: r.URL.Query().Get("source"),
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
Only: r.URL.Query().Get("only"),
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
if err == errors.ErrAccessDenied {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return renderJSON(w, r, items)
|
||||
}
|
||||
+13
-51
@@ -2,10 +2,8 @@ package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
@@ -19,9 +17,7 @@ type searchOptions struct {
|
||||
searchScope string
|
||||
combinedPath map[string]string // source -> combinedPath
|
||||
sessionId string
|
||||
largest bool
|
||||
olderThanUnix int64 // optional; 0 = unset. Modified time must be strictly before this Unix second.
|
||||
newerThanUnix int64 // optional; 0 = unset. Modified time must be >= this Unix second.
|
||||
largest bool
|
||||
}
|
||||
|
||||
// searchHandler handles search requests for files based on the provided query.
|
||||
@@ -43,16 +39,14 @@ type searchOptions struct {
|
||||
// - source: Source name (deprecated, use 'sources' instead)
|
||||
// - sources: Comma-separated list of source names (e.g., "source1,source2")
|
||||
// - scope: Optional path within user scope to search
|
||||
// - olderThan: Optional Unix time in seconds; only items modified strictly before this instant
|
||||
// - newerThan: Optional Unix time in seconds; only items modified on or after this instant
|
||||
//
|
||||
// Example request (single source):
|
||||
//
|
||||
// GET api/tools/search?query=myfile&source=mysource
|
||||
// GET api/search?query=myfile&source=mysource
|
||||
//
|
||||
// Example request (multiple sources):
|
||||
//
|
||||
// GET api/tools/search?query=myfile&sources=source1,source2
|
||||
// GET api/search?query=myfile&sources=source1,source2
|
||||
//
|
||||
// Example response:
|
||||
// [
|
||||
@@ -72,30 +66,23 @@ type searchOptions struct {
|
||||
//
|
||||
// @Summary Search Files
|
||||
// @Description Searches for files matching the provided query. Returns file paths and metadata based on the user's session and scope. Supports searching across multiple sources when using the 'sources' parameter.
|
||||
// @Tags Tools
|
||||
// @Tags Search
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param query query string true "Search query"
|
||||
// @Param source query string false "Source name for the desired source (deprecated, use 'sources' instead)"
|
||||
// @Param sources query string false "Comma-separated list of source names to search across multiple sources. When multiple sources are specified, scope is always the user's scope for each source."
|
||||
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only. Ignored when multiple sources are specified."
|
||||
// @Param olderThan query int false "Unix seconds; only results modified strictly before this time"
|
||||
// @Param newerThan query int false "Unix seconds; only results modified on or after this time"
|
||||
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
|
||||
// @Success 200 {array} indexing.SearchResult "List of search results with source field populated"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Router /api/tools/search [get]
|
||||
// @Router /api/search [get]
|
||||
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
searchOptions, err := prepSearchOptions(r, d)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
searchSize := indexing.DefaultSearchResults
|
||||
if searchOptions.largest {
|
||||
searchSize = 200
|
||||
}
|
||||
|
||||
var response []*indexing.SearchResult
|
||||
if len(searchOptions.sources) == 1 {
|
||||
// Single source - use the existing Search method for backward compatibility
|
||||
@@ -104,10 +91,10 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.sources[0])
|
||||
}
|
||||
combinedPath := searchOptions.combinedPath[searchOptions.sources[0]]
|
||||
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
|
||||
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
|
||||
} else {
|
||||
// Multiple sources - use the new SearchMultiSources function
|
||||
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
|
||||
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
|
||||
}
|
||||
|
||||
// Filter out items that are not permitted according to access rules and trim user scope from paths
|
||||
@@ -135,14 +122,6 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
|
||||
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
|
||||
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
|
||||
largest := r.URL.Query().Get("largest") == "true"
|
||||
olderThanUnix, err := parseOptionalUnixQueryParam("olderThan", r.URL.Query().Get("olderThan"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newerThanUnix, err := parseOptionalUnixQueryParam("newerThan", r.URL.Query().Get("newerThan"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cleanScope, err := utils.SanitizeUserPath(scope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scope: %v", err)
|
||||
@@ -192,28 +171,11 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
|
||||
}
|
||||
|
||||
return &searchOptions{
|
||||
query: query,
|
||||
sources: sources,
|
||||
searchScope: searchScope,
|
||||
combinedPath: combinedPathMap,
|
||||
sessionId: sessionId,
|
||||
largest: largest,
|
||||
olderThanUnix: olderThanUnix,
|
||||
newerThanUnix: newerThanUnix,
|
||||
query: query,
|
||||
sources: sources,
|
||||
searchScope: searchScope,
|
||||
combinedPath: combinedPathMap,
|
||||
sessionId: sessionId,
|
||||
largest: largest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseOptionalUnixQueryParam(name, raw string) (int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
v, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s: must be a non-negative Unix timestamp in seconds", name)
|
||||
}
|
||||
if v > math.MaxInt64 {
|
||||
return 0, fmt.Errorf("invalid %s: value too large", name)
|
||||
}
|
||||
return int64(v), nil
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// settingsGetHandler retrieves the current system settings.
|
||||
@@ -97,21 +95,3 @@ func settingsConfigHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
func getSourceInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
sources := d.user.GetSourceNames()
|
||||
reducedIndexes := map[string]indexing.ReducedIndex{}
|
||||
for _, source := range sources {
|
||||
reducedIndex, err := indexing.GetIndexInfo(source, false)
|
||||
if err != nil {
|
||||
logger.Debugf("error getting index info: %v", err)
|
||||
continue
|
||||
}
|
||||
showScannerInfo := r.URL.Query().Get("scanners") == "true"
|
||||
if !showScannerInfo {
|
||||
reducedIndex.Scanners = nil
|
||||
}
|
||||
reducedIndexes[source] = reducedIndex
|
||||
}
|
||||
return renderJSON(w, r, reducedIndexes)
|
||||
}
|
||||
+19
-15
@@ -36,12 +36,7 @@ type ShareResponse struct {
|
||||
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link, user *users.User) ([]*ShareResponse, error) {
|
||||
responses := make([]*ShareResponse, 0, len(shares))
|
||||
for _, s := range shares {
|
||||
// Look for the username of the user who created the share
|
||||
creator, err := store.Users.Get(s.UserID)
|
||||
username := ""
|
||||
if err == nil {
|
||||
username = creator.Username
|
||||
}
|
||||
username := user.Username
|
||||
|
||||
// Get source info to convert path to name for frontend
|
||||
sourceInfo, ok := config.Server.SourceMap[s.Source]
|
||||
@@ -49,6 +44,9 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link, user
|
||||
// Source not found - likely corrupted data. Try to find by name as fallback
|
||||
sourceInfo, ok = config.Server.NameToSource[s.Source]
|
||||
if !ok {
|
||||
// Still not found - this share is invalid, skip it and delete it
|
||||
logger.Error("Invalid share - deleting", "hash", s.Hash, "source", s.Source)
|
||||
_ = store.Share.Delete(s.Hash) // Best effort delete
|
||||
continue
|
||||
}
|
||||
// Found by name - this is corrupted data, fix it
|
||||
@@ -85,7 +83,7 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link, user
|
||||
// @Produce json
|
||||
// @Success 200 {array} share.Link "List of share links"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/share/list [get]
|
||||
// @Router /api/shares [get]
|
||||
func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
var err error
|
||||
var shares []*share.Link
|
||||
@@ -155,7 +153,7 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Success 200 "Share link deleted successfully"
|
||||
// @Failure 400 {object} map[string]string "Bad request - missing or invalid hash"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/share [delete]
|
||||
// @Router /api/shares [delete]
|
||||
func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
|
||||
@@ -380,7 +378,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(providedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
|
||||
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
|
||||
@@ -579,7 +577,7 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool, token stri
|
||||
|
||||
if config.Server.ExternalUrl != "" {
|
||||
if isDirectDownload {
|
||||
shareURL = fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s", config.Server.ExternalUrl, config.Server.BaseURL, hash, tokenParam)
|
||||
shareURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s%s", config.Server.ExternalUrl, config.Server.BaseURL, hash, tokenParam)
|
||||
} else {
|
||||
shareURL = fmt.Sprintf("%s%spublic/share/%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
|
||||
}
|
||||
@@ -602,7 +600,7 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool, token stri
|
||||
scheme = getScheme(r)
|
||||
}
|
||||
if isDirectDownload {
|
||||
shareURL = fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s", scheme, host, config.Server.BaseURL, hash, tokenParam)
|
||||
shareURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s%s", scheme, host, config.Server.BaseURL, hash, tokenParam)
|
||||
} else {
|
||||
shareURL = fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, config.Server.BaseURL, hash)
|
||||
}
|
||||
@@ -619,7 +617,7 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool, token stri
|
||||
// @Param hash query string true "Hash of the share link"
|
||||
// @Success 200 {object} share.CommonShare "Share information"
|
||||
// @Failure 404 {object} map[string]string "Share hash not found"
|
||||
// @Router /public/api/share/info [get]
|
||||
// @Router /public/api/shareinfo [get]
|
||||
func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
// Get the file link by hash (need full Link to get Token)
|
||||
@@ -629,8 +627,14 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
commonShare := shareLink.CommonShare
|
||||
commonShare.ShareURL = getShareURL(r, hash, false, "")
|
||||
commonShare.BannerUrl = shareLink.BannerURL()
|
||||
commonShare.FaviconUrl = shareLink.FaviconURL()
|
||||
_, _, err = getShareImagePartsHelper(shareLink, true)
|
||||
if err == nil {
|
||||
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
|
||||
}
|
||||
_, _, err = getShareImagePartsHelper(shareLink, false)
|
||||
if err == nil {
|
||||
commonShare.FaviconUrl = fmt.Sprintf("%spublic/api/share/image?favicon=true&hash=%s", config.Server.BaseURL, hash)
|
||||
}
|
||||
commonShare.Source = ""
|
||||
commonShare.Path = ""
|
||||
commonShare.DownloadURL = ""
|
||||
@@ -643,7 +647,7 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
}
|
||||
commonShare.SourceURL = shareLink.SourceURL(d.user)
|
||||
commonShare.CanEditShare = shareLink.UserCanEdit(d.user)
|
||||
commonShare.CanEdit = shareLink.UserCanEdit(d.user)
|
||||
if commonShare.SourceURL != "" {
|
||||
link := users.SidebarLink{
|
||||
Name: "sourceLocation",
|
||||
|
||||
+110
-15
@@ -13,10 +13,10 @@ import (
|
||||
"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/icons"
|
||||
)
|
||||
|
||||
var templateRenderer *TemplateRenderer
|
||||
var cachedManifest PWAManifest // generated at startup
|
||||
|
||||
type TemplateRenderer struct {
|
||||
templates *template.Template
|
||||
@@ -119,10 +119,21 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if d.share.DisableSidebar {
|
||||
disableSidebar = true
|
||||
}
|
||||
banner = d.share.BannerURL()
|
||||
shareFavicon := d.share.FaviconURL()
|
||||
if shareFavicon != "" {
|
||||
favicon = shareFavicon
|
||||
if strings.HasPrefix(strings.ToLower(d.share.Banner), "http") {
|
||||
banner = d.share.Banner
|
||||
} else {
|
||||
_, _, err := getShareImagePartsHelper(d.share, true)
|
||||
if err == nil {
|
||||
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(d.share.Favicon), "http") {
|
||||
favicon = d.share.Favicon
|
||||
} else {
|
||||
_, _, err := getShareImagePartsHelper(d.share, false)
|
||||
if err == nil {
|
||||
favicon = fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
|
||||
}
|
||||
}
|
||||
shareHash = d.share.Hash
|
||||
}
|
||||
@@ -216,9 +227,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
|
||||
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"ldapAvailable": config.Auth.Methods.LdapAuth.Enabled,
|
||||
"mediaAvailable": settings.MediaEnabled(),
|
||||
"exiftoolAvailable": settings.Config.Integrations.Media.ExiftoolPath != "",
|
||||
"muPdfAvailable": settings.Env.MuPdfAvailable,
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
@@ -229,7 +238,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
"oidcLoginButtonText": config.Frontend.OIDCLoginButtonText,
|
||||
"loginButtonText": config.Frontend.LoginButtonText,
|
||||
}
|
||||
|
||||
// Marshal each variable to JSON strings for direct template usage
|
||||
@@ -282,7 +290,7 @@ func manifestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
||||
|
||||
if err := json.NewEncoder(w).Encode(icons.CachedManifest); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(cachedManifest); err != nil {
|
||||
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -294,8 +302,11 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
|
||||
|
||||
// Strip baseURL and /static/ prefix to get clean asset path
|
||||
path := strings.TrimPrefix(r.URL.Path, "/public")
|
||||
path = strings.TrimPrefix(path, "/static/")
|
||||
path := r.URL.Path
|
||||
path = strings.TrimPrefix(path, config.Server.BaseURL) // Remove /testing/ or other baseURL
|
||||
path = strings.TrimPrefix(path, "/") // Remove leading slash FIRST
|
||||
path = strings.TrimPrefix(path, "static/") // Then remove static/ prefix
|
||||
|
||||
// Handle special routes that need path mapping
|
||||
var assetPath string
|
||||
switch path {
|
||||
@@ -320,9 +331,9 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// Fall back to embedded default favicon.png
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "icons/favicon-32x32.png",
|
||||
"icons/pwa-icon-192.png", "icons/pwa-icon-256.png", "icons/pwa-icon-512.png",
|
||||
"icons/apple-touch-icon.png":
|
||||
case "favicon-32x32.png",
|
||||
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
|
||||
"apple-touch-icon.png":
|
||||
// Serve generated icons from cache directory
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
@@ -331,7 +342,7 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// Fall back to embedded favicon.png if generation failed
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "icons/mstile-256x256.png":
|
||||
case "mstile-256x256.png":
|
||||
// Windows tile - redirect to pwa-icon-256.png (they're identical)
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.png")
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
@@ -392,3 +403,87 @@ func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
}
|
||||
return handleWithStaticData(w, r, d, "index.html", "text/html")
|
||||
}
|
||||
|
||||
// PWAManifest represents the web app manifest structure
|
||||
type PWAManifest struct {
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"short_name"`
|
||||
Icons []PWAIcon `json:"icons"`
|
||||
StartURL string `json:"start_url"`
|
||||
Display string `json:"display"`
|
||||
BackgroundColor string `json:"background_color"`
|
||||
ThemeColor string `json:"theme_color"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// PWAIcon represents an icon in the web app manifest
|
||||
type PWAIcon struct {
|
||||
Src string `json:"src"`
|
||||
Sizes string `json:"sizes"`
|
||||
Type string `json:"type"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
// generatePWAManifest creates the PWA manifest structure
|
||||
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
|
||||
shortName := name
|
||||
if len(name) > 12 {
|
||||
shortName = name[:12]
|
||||
}
|
||||
|
||||
return PWAManifest{
|
||||
Name: name,
|
||||
ShortName: shortName,
|
||||
StartURL: baseURL,
|
||||
Display: "standalone",
|
||||
BackgroundColor: "#ffffff",
|
||||
ThemeColor: themeColor,
|
||||
Description: description,
|
||||
Icons: []PWAIcon{
|
||||
{
|
||||
Src: pwaIcon192,
|
||||
Sizes: "192x192",
|
||||
Type: "image/png",
|
||||
Purpose: "any",
|
||||
},
|
||||
{
|
||||
Src: pwaIcon256,
|
||||
Sizes: "256x256",
|
||||
Type: "image/png",
|
||||
Purpose: "any maskable",
|
||||
},
|
||||
{
|
||||
Src: pwaIcon512,
|
||||
Sizes: "512x512",
|
||||
Type: "image/png",
|
||||
Purpose: "any",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// InitializePWAManifest generates and caches the PWA manifest at startup
|
||||
func InitializePWAManifest() {
|
||||
staticURL := config.Server.BaseURL + "public/static"
|
||||
title := config.Frontend.Name
|
||||
description := config.Frontend.Description
|
||||
defaultThemeColor := "#455a64"
|
||||
|
||||
// Determine PWA icon URLs based on custom favicon settings
|
||||
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
|
||||
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
|
||||
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
|
||||
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
|
||||
favicon := staticURL + "/favicon"
|
||||
pwaIcon192 = favicon
|
||||
pwaIcon256 = favicon
|
||||
pwaIcon512 = favicon
|
||||
} else if settings.Env.FaviconIsCustom {
|
||||
pwaIcon192 = staticURL + "/pwa-icon-192.png"
|
||||
pwaIcon256 = staticURL + "/pwa-icon-256.png"
|
||||
pwaIcon512 = staticURL + "/pwa-icon-512.png"
|
||||
}
|
||||
|
||||
cachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
|
||||
// @Summary Generate OTP
|
||||
// @Description Generates a new TOTP secret and QR code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.
|
||||
// @Tags Auth
|
||||
// @Tags OTP
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
@@ -46,7 +46,7 @@ func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// verifyOTPHandler handles the verification of a TOTP code.
|
||||
// @Summary Verify OTP
|
||||
// @Description Verifies the provided TOTP code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.
|
||||
// @Tags Auth
|
||||
// @Tags OTP
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
|
||||
@@ -5,16 +5,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
type UserRequest struct {
|
||||
@@ -90,7 +87,6 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
func prepForFrontend(u *users.User) {
|
||||
u.Password = ""
|
||||
u.ApiKeys = nil
|
||||
u.Tokens = nil
|
||||
u.OtpEnabled = u.TOTPSecret != ""
|
||||
u.TOTPSecret = ""
|
||||
u.TOTPNonce = ""
|
||||
@@ -310,29 +306,10 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
req.User.TOTPNonce = ""
|
||||
}
|
||||
|
||||
// Get the old user to check if permissions changed
|
||||
oldUser, err := store.Users.Get(req.User.ID)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
err = store.Users.Update(&req.User, d.user.Permissions.Admin, req.Which...)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
// Revoke all API keys if API permission was removed
|
||||
if slices.Contains(req.Which, "Permissions") && oldUser.Permissions.Api && !req.User.Permissions.Api {
|
||||
for _, tokenInfo := range oldUser.Tokens {
|
||||
if err := auth.RevokeApiToken(store.Access, tokenInfo.Token); err != nil {
|
||||
logger.Errorf("Failed to revoke API key: %v", err)
|
||||
}
|
||||
// Also remove from HashedTokens
|
||||
if err := store.Access.RemoveApiToken(tokenInfo.Token); err != nil {
|
||||
logger.Errorf("Failed to remove api token: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return http.StatusNoContent, nil
|
||||
}
|
||||
+50
-407
@@ -1,438 +1,81 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/net/webdav"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
// fileInfoWrapper wraps iteminfo.ItemInfo to implement os.FileInfo
|
||||
type fileInfoWrapper struct {
|
||||
iteminfo.ItemInfo
|
||||
}
|
||||
|
||||
func (f *fileInfoWrapper) Name() string { return f.ItemInfo.Name }
|
||||
func (f *fileInfoWrapper) Size() int64 { return f.ItemInfo.Size }
|
||||
func (f *fileInfoWrapper) Mode() os.FileMode { return f.mode() }
|
||||
func (f *fileInfoWrapper) ModTime() time.Time { return f.ItemInfo.ModTime }
|
||||
func (f *fileInfoWrapper) IsDir() bool { return f.Type == "directory" }
|
||||
func (f *fileInfoWrapper) Sys() interface{} { return nil }
|
||||
|
||||
func (f *fileInfoWrapper) mode() os.FileMode {
|
||||
if f.IsDir() {
|
||||
return os.ModeDir | fileutils.PermDir
|
||||
}
|
||||
return fileutils.PermFile
|
||||
}
|
||||
|
||||
// filteredFileSystem wraps a webdav.FileSystem and filters directory listings using FileInfoFaster
|
||||
type filteredFileSystem struct {
|
||||
fs webdav.FileSystem
|
||||
source string
|
||||
user *users.User
|
||||
userScope string
|
||||
// Cache FileInfoFaster results per path to avoid redundant calls within the same request
|
||||
fileInfoCache map[string]*iteminfo.ExtendedFileInfo
|
||||
}
|
||||
|
||||
// getFileInfo retrieves file information with caching and access control
|
||||
// requestPath should NOT include user scope - FileInfoFaster applies it internally
|
||||
// This function prefers cache, then tries FileInfoFaster which applies access control
|
||||
func (ffs *filteredFileSystem) getFileInfo(requestPath string, expand bool) (*iteminfo.ExtendedFileInfo, error) {
|
||||
// Initialize cache if needed
|
||||
if ffs.fileInfoCache == nil {
|
||||
ffs.fileInfoCache = make(map[string]*iteminfo.ExtendedFileInfo)
|
||||
}
|
||||
|
||||
// Create cache key (don't modify requestPath itself!)
|
||||
cacheKey := requestPath
|
||||
if expand {
|
||||
cacheKey += ":expand"
|
||||
}
|
||||
if cached, found := ffs.fileInfoCache[cacheKey]; found {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// If we're looking for non-expanded but have expanded cached, we can use it
|
||||
// (expanded contains all the same info plus more)
|
||||
if !expand {
|
||||
expandedKey := requestPath + ":expand"
|
||||
if cached, found := ffs.fileInfoCache[expandedKey]; found {
|
||||
return cached, nil
|
||||
func createWebDAVHandler(prefix string) handleFunc {
|
||||
var locks = webdav.NewMemLS()
|
||||
// webdavHandler serves the webdav requests.
|
||||
return func(writer http.ResponseWriter, request *http.Request, d *requestContext) (int, error) {
|
||||
fullPath := request.PathValue("path")
|
||||
source := request.PathValue("scope")
|
||||
if !userHasReadWriteAccess(d) {
|
||||
// (reddec): we're currently allowing WebDAV access only for users with R/W access for simplicity.
|
||||
// in the follow-ups, we need to add mapping of webdav operations and permissions (which is trickier than it looks like).
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
|
||||
}
|
||||
}
|
||||
|
||||
// Call FileInfoFaster with clean requestPath (without scope or cache suffix)
|
||||
// FileInfoFaster applies user scope internally AND enforces access control
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: requestPath,
|
||||
Source: ffs.source,
|
||||
Expand: expand,
|
||||
ShowHidden: ffs.user.ShowHidden,
|
||||
SkipExtendedAttrs: true,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, ffs.user, store.Share)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache result using the cache key
|
||||
ffs.fileInfoCache[cacheKey] = fileInfo
|
||||
|
||||
// If we got an expanded directory listing, also cache individual file entries
|
||||
// so Stat calls on those files can use the cache
|
||||
if expand && fileInfo.Type == "directory" {
|
||||
dirPath := requestPath
|
||||
// Cache each file and folder from the directory listing
|
||||
for _, file := range fileInfo.Files {
|
||||
filePath := utils.JoinPathAsUnix(dirPath, file.Name)
|
||||
// Create a minimal ExtendedFileInfo for the file (without expanding its contents)
|
||||
fileEntry := &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
ItemInfo: file.ItemInfo,
|
||||
},
|
||||
}
|
||||
ffs.fileInfoCache[filePath] = fileEntry
|
||||
userscope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
logger.Debugf("error getting scope from source name: %v", err)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
for _, folder := range fileInfo.Folders {
|
||||
folderPath := utils.JoinPathAsUnix(dirPath, folder.Name)
|
||||
// Create a minimal ExtendedFileInfo for the folder (without expanding its contents)
|
||||
folderEntry := &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
ItemInfo: folder,
|
||||
},
|
||||
}
|
||||
ffs.fileInfoCache[folderPath] = folderEntry
|
||||
}
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
virtualPath := utils.JoinPathAsUnix(userscope, fullPath)
|
||||
|
||||
// checkAccess validates if the user can access a given path
|
||||
// This is used by all write operations (mkdir, delete, rename, etc.)
|
||||
// Returns nil if access is allowed, error otherwise
|
||||
func (ffs *filteredFileSystem) checkAccess(requestPath string) error {
|
||||
// First, validate permissions using CheckPermissions
|
||||
indexPath, _, err := files.CheckPermissions(utils.FileOptions{
|
||||
FollowSymlinks: false,
|
||||
Path: requestPath,
|
||||
Source: ffs.source,
|
||||
ShowHidden: ffs.user.ShowHidden,
|
||||
}, store.Access, ffs.user)
|
||||
if err != nil {
|
||||
logger.Debugf("checkAccess: CheckPermissions denied for %s: %v", requestPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Try to get file info to verify it's accessible
|
||||
// Use non-expanded to be faster (we just need to know if it exists and is accessible)
|
||||
_, err = ffs.getFileInfo(requestPath, false)
|
||||
if err == nil {
|
||||
// Successfully got info - access allowed
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, commonerrors.ErrAccessDenied) {
|
||||
logger.Debugf("checkAccess: access explicitly denied for %s", requestPath)
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// CRITICAL: If item is not indexed AND not viewable, deny access
|
||||
// This prevents WebDAV from creating/modifying files in non-indexed areas
|
||||
if errors.Is(err, commonerrors.ErrNotViewable) {
|
||||
logger.Debugf("checkAccess: path not viewable for %s", requestPath)
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// If not indexed but potentially viewable, we need to check more carefully
|
||||
if errors.Is(err, commonerrors.ErrNotIndexed) {
|
||||
// Get the index to check viewability
|
||||
idx := indexing.GetIndex(ffs.source)
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source not found")
|
||||
logger.Debugf("source %s not found", source)
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
|
||||
// Check if the item is viewable using GetFileInfo (without expand)
|
||||
info, getErr := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: false,
|
||||
ShowHidden: ffs.user.ShowHidden,
|
||||
Expand: false,
|
||||
SkipExtendedAttrs: true,
|
||||
})
|
||||
realPath, _, _ := idx.GetRealPath(virtualPath)
|
||||
scopePath, _, _ := idx.GetRealPath(userscope)
|
||||
|
||||
// If GetFileInfo succeeds, the item is viewable despite not being indexed
|
||||
if getErr == nil && info != nil {
|
||||
logger.Debugf("checkAccess: path not indexed but viewable for %s", requestPath)
|
||||
return nil // Allow access to viewable items
|
||||
// checking only base (scope) path, as per-item permission is currently impossible to implement.
|
||||
// however, when we are granting an access to the user, we are granting it per-folder, so everything should be fine.
|
||||
// TODO: properly handle symlink and hidden files in requests
|
||||
info, err := idx.GetFsInfo(userscope, false, true)
|
||||
if err != nil {
|
||||
logger.Debugf("error getting dir info: %v", err)
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
|
||||
// Not viewable - deny access
|
||||
logger.Debugf("checkAccess: path not indexed and not viewable for %s", requestPath)
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// For other errors (like file not found), that's okay for new file/directory creation
|
||||
// The caller will handle whether the operation is appropriate
|
||||
logger.Debugf("checkAccess: path check returned: %v (may be acceptable for new items)", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// filteredFile wraps a webdav.File and filters Readdir results based on FileInfoFaster
|
||||
type filteredFile struct {
|
||||
webdav.File
|
||||
fs *filteredFileSystem
|
||||
requestPath string // The request path (without user scope)
|
||||
isDir bool // Whether this is a directory
|
||||
}
|
||||
|
||||
func (ff *filteredFile) Readdir(count int) ([]os.FileInfo, error) {
|
||||
// If not a directory, use the underlying file's Readdir
|
||||
if !ff.isDir {
|
||||
return ff.File.Readdir(count)
|
||||
}
|
||||
|
||||
// Pass the requestPath (without scope) to getCachedFileInfo
|
||||
// FileInfoFaster will apply the user's scope internally
|
||||
fileInfo, err := ff.fs.getFileInfo(ff.requestPath, true)
|
||||
if err != nil {
|
||||
logger.Debugf("readdir: getFileInfo failed for requestPath=%s: %v", ff.requestPath, err)
|
||||
// Handle errors gracefully - return empty directory for access/indexing issues
|
||||
// This is especially important when user's scope points to a non-viewable directory
|
||||
if errors.Is(err, commonerrors.ErrAccessDenied) ||
|
||||
errors.Is(err, commonerrors.ErrNotIndexed) ||
|
||||
errors.Is(err, commonerrors.ErrNotViewable) {
|
||||
logger.Debugf("readdir: access issue for %s: %v - returning empty", ff.requestPath, err)
|
||||
return []os.FileInfo{}, nil
|
||||
}
|
||||
// Other errors - propagate them
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build os.FileInfo list directly from FileInfoFaster's filtered results
|
||||
// No need to read from underlying filesystem - FileInfoFaster already filtered by permissions
|
||||
entries := make([]os.FileInfo, 0, len(fileInfo.Files)+len(fileInfo.Folders))
|
||||
|
||||
// Add folders first (common convention)
|
||||
for _, folder := range fileInfo.Folders {
|
||||
entries = append(entries, &fileInfoWrapper{ItemInfo: folder})
|
||||
}
|
||||
|
||||
// Add files
|
||||
for _, file := range fileInfo.Files {
|
||||
entries = append(entries, &fileInfoWrapper{ItemInfo: file.ItemInfo})
|
||||
}
|
||||
|
||||
// Handle count parameter
|
||||
if count > 0 && len(entries) > count {
|
||||
return entries[:count], nil
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||
if !ffs.user.Permissions.Create {
|
||||
return fmt.Errorf("create permission required")
|
||||
}
|
||||
|
||||
// Check access before creating directory
|
||||
if err := ffs.checkAccess(name); err != nil {
|
||||
logger.Debugf("Mkdir: access denied for %s: %v", name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return ffs.fs.Mkdir(ctx, name, perm)
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
// Check if this is a write operation
|
||||
isWrite := (flag&os.O_WRONLY) != 0 || (flag&os.O_RDWR) != 0 || (flag&os.O_CREATE) != 0
|
||||
|
||||
if isWrite {
|
||||
// Check user permissions first
|
||||
if !ffs.user.Permissions.Create && !ffs.user.Permissions.Modify {
|
||||
return nil, fmt.Errorf("write permission required")
|
||||
err = store.Access.CheckChildItemAccess(&iteminfo.ExtendedFileInfo{
|
||||
FileInfo: *info,
|
||||
Source: source,
|
||||
RealPath: scopePath,
|
||||
}, idx, d.user.Username)
|
||||
if err != nil {
|
||||
logger.Debugf("error checking child item access: %v", err)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
// For write operations, check access
|
||||
if err := ffs.checkAccess(requestPath); err != nil {
|
||||
logger.Debugf("OpenFile: write access denied for %s: %v", requestPath, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
logger.Debugf("webdav: method=%s, request=%s, source=%s, path=%s, virtual_path=%s, real_path=%s, scope_path=%s", request.Method, request.URL.Path, source, fullPath, virtualPath, realPath, scopePath)
|
||||
|
||||
file, err := ffs.fs.OpenFile(ctx, requestPath, flag, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wrap the file to filter directory listings
|
||||
// name is the request path (without user scope)
|
||||
return &filteredFile{
|
||||
File: file,
|
||||
fs: ffs,
|
||||
requestPath: requestPath,
|
||||
isDir: stat.IsDir(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) RemoveAll(ctx context.Context, requestPath string) error {
|
||||
if !ffs.user.Permissions.Delete {
|
||||
return fmt.Errorf("delete permission required")
|
||||
}
|
||||
|
||||
// Check access before deleting
|
||||
if err := ffs.checkAccess(requestPath); err != nil {
|
||||
logger.Debugf("RemoveAll: access denied for %s: %v", requestPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return ffs.fs.RemoveAll(ctx, requestPath)
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) Rename(ctx context.Context, oldPath, newPath string) error {
|
||||
if !ffs.user.Permissions.Modify {
|
||||
return fmt.Errorf("modify permission required")
|
||||
}
|
||||
|
||||
// Check access for both old and new paths
|
||||
if err := ffs.checkAccess(oldPath); err != nil {
|
||||
logger.Debugf("Rename: access denied for source %s: %v", oldPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ffs.checkAccess(newPath); err != nil {
|
||||
logger.Debugf("Rename: access denied for destination %s: %v", newPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return ffs.fs.Rename(ctx, oldPath, newPath)
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) Stat(ctx context.Context, requestPath string) (os.FileInfo, error) {
|
||||
// Try to get file info (uses cache if available, FileInfoFaster otherwise)
|
||||
_, err := ffs.getFileInfo(requestPath, false)
|
||||
if err == nil {
|
||||
// Successfully got info - use underlying filesystem
|
||||
return ffs.fs.Stat(ctx, requestPath)
|
||||
}
|
||||
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, commonerrors.ErrAccessDenied) || errors.Is(err, commonerrors.ErrNotViewable) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
// For not indexed, check if it's viewable
|
||||
if errors.Is(err, commonerrors.ErrNotIndexed) {
|
||||
// Use checkAccess to determine if viewable
|
||||
if accessErr := ffs.checkAccess(requestPath); accessErr != nil {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
// Viewable - allow underlying filesystem
|
||||
return ffs.fs.Stat(ctx, requestPath)
|
||||
}
|
||||
|
||||
// For other errors (like file not found), let underlying filesystem handle it
|
||||
return ffs.fs.Stat(ctx, requestPath)
|
||||
}
|
||||
|
||||
// webDAVHandler serves WebDAV requests.
|
||||
func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.user.Permissions.Download {
|
||||
return http.StatusForbidden, fmt.Errorf("download permission required")
|
||||
}
|
||||
if r.Method == "DELETE" && !d.user.Permissions.Delete {
|
||||
return http.StatusForbidden, fmt.Errorf("delete permission required")
|
||||
}
|
||||
isWrite := r.Method == http.MethodPut || r.Method == "MKCOL"
|
||||
if isWrite && !userCanWrite(d.user.Permissions) {
|
||||
return http.StatusForbidden, fmt.Errorf("user has no permission to modify")
|
||||
}
|
||||
requestPath := utils.AddTrailingSlashIfNotExists(r.PathValue("path"))
|
||||
source := r.PathValue("source")
|
||||
if !strings.HasPrefix(requestPath, "/") {
|
||||
requestPath = "/" + requestPath
|
||||
}
|
||||
logger.Debugf("webdav: method=%s, request=%s, source=%s, requestPath=%s", r.Method, r.URL.Path, source, requestPath)
|
||||
_, userScope, err := files.CheckPermissions(utils.FileOptions{
|
||||
FollowSymlinks: false,
|
||||
Path: requestPath,
|
||||
Source: source,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
|
||||
// Get the user's scope to determine the WebDAV root directory
|
||||
// Resolve the scope path to get the real filesystem root for WebDAV
|
||||
// This is the root directory that WebDAV will use to resolve relative paths
|
||||
scopePath, _, err := idx.GetRealPath(userScope)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
|
||||
// Construct the WebDAV prefix from BaseURL
|
||||
webDavPrefix := config.Server.BaseURL + "dav"
|
||||
prefix := webDavPrefix + "/" + source
|
||||
// Wrap the filesystem to filter directory listings using FileInfoFaster
|
||||
// We pass requestPath (without scope) to FileInfoFaster, which applies scope internally
|
||||
filteredFS := &filteredFileSystem{
|
||||
fs: webdav.Dir(scopePath),
|
||||
source: source,
|
||||
user: d.user,
|
||||
userScope: userScope,
|
||||
}
|
||||
|
||||
wd := &webdav.Handler{
|
||||
Prefix: prefix,
|
||||
FileSystem: filteredFS,
|
||||
LockSystem: idx.WebdavLock,
|
||||
Logger: func(req *http.Request, err error) {
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
// Filter out expected/benign errors that don't indicate actual failures
|
||||
if strings.Contains(errStr, "no such file or directory") ||
|
||||
strings.Contains(errStr, "skip this directory") {
|
||||
return
|
||||
wd := &webdav.Handler{
|
||||
Prefix: prefix + "/" + source,
|
||||
FileSystem: webdav.Dir(scopePath),
|
||||
LockSystem: locks,
|
||||
Logger: func(request *http.Request, err error) {
|
||||
if err != nil {
|
||||
logger.Errorf("webdav handler failed on path %s: %s", request.URL.Path, err)
|
||||
}
|
||||
logger.Errorf("webdav handler failed on path %s: %s", req.URL.Path, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
wd.ServeHTTP(writer, request)
|
||||
return 0, nil // errors and responses (XML-formatted) are handled by webdav handler
|
||||
}
|
||||
|
||||
wd.ServeHTTP(w, r)
|
||||
return 200, nil // errors and responses (XML-formatted) are handled by webdav handler
|
||||
}
|
||||
|
||||
func userCanWrite(permissions users.Permissions) bool {
|
||||
return permissions.Create && permissions.Modify && permissions.Delete
|
||||
func userHasReadWriteAccess(d *requestContext) bool {
|
||||
return d.user.Permissions.Create && d.user.Permissions.Delete && d.user.Permissions.Modify && d.user.Permissions.Download
|
||||
}
|
||||
@@ -1,803 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
storm "github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"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/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
// setupWebDAVTestEnv sets up the test environment with multiple sources and users
|
||||
func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
// Create temp directories for test sources
|
||||
tempDir := t.TempDir()
|
||||
source1Path := filepath.Join(tempDir, "source1")
|
||||
source2Path := filepath.Join(tempDir, "source2")
|
||||
dockerPath := filepath.Join(source1Path, "_docker")
|
||||
|
||||
// Create directory structure
|
||||
dirs := []string{
|
||||
filepath.Join(source1Path, "public"),
|
||||
filepath.Join(source1Path, "private"),
|
||||
filepath.Join(source1Path, "viewable-only"),
|
||||
filepath.Join(source1Path, "not-viewable"),
|
||||
dockerPath,
|
||||
filepath.Join(dockerPath, "data"),
|
||||
filepath.Join(source2Path, "shared"),
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create test files
|
||||
testFiles := map[string]string{
|
||||
filepath.Join(source1Path, "public", "readme.txt"): "public content",
|
||||
filepath.Join(source1Path, "private", "secret.txt"): "private content",
|
||||
filepath.Join(source1Path, "viewable-only", "data.txt"): "viewable content",
|
||||
filepath.Join(source1Path, "not-viewable", "hidden.txt"): "hidden content",
|
||||
filepath.Join(dockerPath, "data", "config.yml"): "docker config",
|
||||
filepath.Join(source2Path, "shared", "document.txt"): "shared content",
|
||||
}
|
||||
|
||||
for path, content := range testFiles {
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup database
|
||||
dbPath := filepath.Join(tempDir, "db")
|
||||
db, err := storm.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err = bolt.NewStorage(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Clear any previous access rules
|
||||
store.Access.AllRules = make(access.SourceRuleMap)
|
||||
store.Access.Groups = make(access.GroupMap)
|
||||
|
||||
// Set cache directory for index database
|
||||
settings.Config.Server.CacheDir = tempDir
|
||||
|
||||
// Setup config with sources - update both the local config var AND the global settings.Config
|
||||
config = &settings.Settings{
|
||||
Server: settings.Server{
|
||||
BaseURL: "/", // Use / for tests to avoid prefix mismatch
|
||||
CacheDir: tempDir,
|
||||
SourceMap: map[string]*settings.Source{
|
||||
source1Path: {
|
||||
Path: source1Path,
|
||||
Name: "source1",
|
||||
},
|
||||
source2Path: {
|
||||
Path: source2Path,
|
||||
Name: "source2",
|
||||
},
|
||||
},
|
||||
NameToSource: map[string]*settings.Source{
|
||||
"source1": {
|
||||
Path: source1Path,
|
||||
Name: "source1",
|
||||
},
|
||||
"source2": {
|
||||
Path: source2Path,
|
||||
Name: "source2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// CRITICAL: Also update the global settings.Config so access.Permitted can find sources
|
||||
settings.Config.Server.SourceMap = config.Server.SourceMap
|
||||
settings.Config.Server.NameToSource = config.Server.NameToSource
|
||||
|
||||
// Initialize user resolvers
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
// Create minimal mock indices for webdav handler
|
||||
indexing.SetTestIndex("source1", source1Path)
|
||||
indexing.SetTestIndex("source2", source2Path)
|
||||
t.Cleanup(func() {
|
||||
indexing.ClearTestIndices()
|
||||
})
|
||||
|
||||
// Mock CheckPermissions to bypass index requirement
|
||||
mockCheckPermissions(t, source1Path, source2Path)
|
||||
|
||||
// Initialize mock indexing - this mocks FileInfoFaster
|
||||
mockWebDAVIndexing(t, source1Path, source2Path)
|
||||
|
||||
return source1Path, source2Path
|
||||
}
|
||||
|
||||
// mockCheckPermissions mocks the CheckPermissions function to bypass index lookups
|
||||
func mockCheckPermissions(t *testing.T, source1Path, source2Path string) {
|
||||
t.Helper()
|
||||
|
||||
// Store original function
|
||||
originalCheckPermissions := files.CheckPermissionsFunc
|
||||
t.Cleanup(func() { files.CheckPermissionsFunc = originalCheckPermissions })
|
||||
|
||||
// Mock the function
|
||||
files.CheckPermissionsFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
// Get the source path
|
||||
var sourcePath string
|
||||
if opts.Source == "source1" {
|
||||
sourcePath = source1Path
|
||||
} else if opts.Source == "source2" {
|
||||
sourcePath = source2Path
|
||||
} else {
|
||||
return "", "", fmt.Errorf("unknown source: %s", opts.Source)
|
||||
}
|
||||
|
||||
// Get user scope for this source - match by source PATH, not name
|
||||
userScope := "/"
|
||||
hasScope := false
|
||||
for _, scope := range user.Scopes {
|
||||
if scope.Name == sourcePath {
|
||||
userScope = scope.Scope
|
||||
hasScope = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If user has no scope for this source, deny access
|
||||
if !hasScope && len(user.Scopes) > 0 {
|
||||
return "", "", fmt.Errorf("user has no access to source: %s", opts.Source)
|
||||
}
|
||||
|
||||
// Sanitize path
|
||||
safePath, err := utils.SanitizeUserPath(opts.Path)
|
||||
if err != nil {
|
||||
return "", "", commonerrors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Combine scope + sanitized path
|
||||
indexPath := utils.JoinPathAsUnix(userScope, safePath)
|
||||
|
||||
// Check access control
|
||||
if access != nil && !access.Permitted(sourcePath, indexPath, user.Username) {
|
||||
return "", "", commonerrors.ErrAccessDenied
|
||||
}
|
||||
|
||||
return indexPath, userScope, nil
|
||||
}
|
||||
}
|
||||
|
||||
// mockWebDAVIndexing mocks the indexing system for WebDAV tests
|
||||
func mockWebDAVIndexing(t *testing.T, source1Path, source2Path string) {
|
||||
// Mock FileInfoFaster to simulate indexing behavior
|
||||
originalFileInfoFaster := files.FileInfoFasterFunc
|
||||
t.Cleanup(func() { files.FileInfoFasterFunc = originalFileInfoFaster })
|
||||
|
||||
files.FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
// Resolve source name to source path first
|
||||
sourcePath := ""
|
||||
if opts.Source == "source1" {
|
||||
sourcePath = source1Path
|
||||
} else if opts.Source == "source2" {
|
||||
sourcePath = source2Path
|
||||
} else {
|
||||
return nil, fmt.Errorf("unknown source: %s", opts.Source)
|
||||
}
|
||||
|
||||
// Simulate access control
|
||||
if access != nil && user != nil {
|
||||
// Simple scope check based on user's configured scopes (which use source PATH)
|
||||
hasAccess := false
|
||||
for _, scope := range user.Scopes {
|
||||
if scope.Name == sourcePath {
|
||||
hasAccess = true
|
||||
// Check if path is within user's scope
|
||||
userScope := scope.Scope
|
||||
fullPath := utils.JoinPathAsUnix(userScope, opts.Path)
|
||||
|
||||
// Check if user has permission using the access storage
|
||||
if !access.Permitted(sourcePath, fullPath, user.Username) {
|
||||
return nil, commonerrors.ErrAccessDenied
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAccess && len(user.Scopes) > 0 {
|
||||
return nil, commonerrors.ErrAccessDenied
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate indexing rules
|
||||
path := opts.Path
|
||||
|
||||
// Paths containing "not-viewable" are not indexed and not viewable
|
||||
if strings.Contains(path, "not-viewable") {
|
||||
return nil, commonerrors.ErrNotViewable
|
||||
}
|
||||
|
||||
// Paths containing "viewable-only" are not indexed but viewable
|
||||
if strings.Contains(path, "viewable-only") {
|
||||
return nil, commonerrors.ErrNotIndexed
|
||||
}
|
||||
|
||||
// Simulate directory expansion
|
||||
var files []iteminfo.ExtendedItemInfo
|
||||
var folders []iteminfo.ItemInfo
|
||||
|
||||
if opts.Expand && (path == "/" || strings.HasSuffix(path, "/")) {
|
||||
// Return mock directory contents based on path
|
||||
if path == "/" || path == "" {
|
||||
folders = []iteminfo.ItemInfo{
|
||||
{Name: "public", Type: "directory"},
|
||||
{Name: "private", Type: "directory"},
|
||||
{Name: "viewable-only", Type: "directory"},
|
||||
}
|
||||
} else if strings.Contains(path, "public") {
|
||||
files = []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "readme.txt", Size: 14, Type: "text/plain"}},
|
||||
}
|
||||
} else if strings.Contains(path, "private") {
|
||||
files = []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "secret.txt", Size: 15, Type: "text/plain"}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: path,
|
||||
Files: files,
|
||||
Folders: folders,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: filepath.Base(path),
|
||||
Type: "directory",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Test PROPFIND with different user scopes
|
||||
func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
|
||||
source1Path, source2Path := setupWebDAVTestEnv(t)
|
||||
|
||||
// Create users with different scopes
|
||||
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
|
||||
adminUser := &users.User{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
Permissions: users.Permissions{
|
||||
Admin: true,
|
||||
Download: true,
|
||||
Create: true,
|
||||
Delete: true,
|
||||
Modify: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
{Name: source2Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
scopedUser := &users.User{
|
||||
ID: 2,
|
||||
Username: "scoped",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/_docker"},
|
||||
},
|
||||
}
|
||||
|
||||
restrictedUser := &users.User{
|
||||
ID: 3,
|
||||
Username: "restricted",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/public"},
|
||||
},
|
||||
}
|
||||
|
||||
// Save users
|
||||
if err := store.Users.Save(adminUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Users.Save(scopedUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Users.Save(restrictedUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *users.User
|
||||
source string
|
||||
path string
|
||||
expectedStatus int
|
||||
shouldHaveContent bool
|
||||
}{
|
||||
{
|
||||
name: "Admin can access root of source1",
|
||||
user: adminUser,
|
||||
source: "source1",
|
||||
path: "/",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
shouldHaveContent: true,
|
||||
},
|
||||
{
|
||||
name: "Scoped user can access their scope root",
|
||||
user: scopedUser,
|
||||
source: "source1",
|
||||
path: "/",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
shouldHaveContent: false, // Empty because _docker is not viewable
|
||||
},
|
||||
{
|
||||
name: "Restricted user can only see public folder",
|
||||
user: restrictedUser,
|
||||
source: "source1",
|
||||
path: "/",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
shouldHaveContent: true,
|
||||
},
|
||||
{
|
||||
name: "Admin can access source2",
|
||||
user: adminUser,
|
||||
source: "source2",
|
||||
path: "/",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
shouldHaveContent: true,
|
||||
},
|
||||
{
|
||||
name: "Scoped user cannot access source2",
|
||||
user: scopedUser,
|
||||
source: "source2",
|
||||
path: "/",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
// Initialize indexing for sources
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
initTestIndex(t, "source2", source2Path)
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("PROPFIND", "/dav/"+tc.source+tc.path, nil)
|
||||
req.SetPathValue("source", tc.source)
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: tc.user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
|
||||
}
|
||||
|
||||
if tc.shouldHaveContent && w.Code == 207 {
|
||||
body := w.Body.String()
|
||||
if body == "" {
|
||||
t.Error("expected response body to have content")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test WebDAV write operations with access control
|
||||
func TestWebDAV_WriteOperations(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
|
||||
// Create users with different permissions
|
||||
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
|
||||
fullAccessUser := &users.User{
|
||||
ID: 1,
|
||||
Username: "fullaccess",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
Create: true,
|
||||
Delete: true,
|
||||
Modify: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
readOnlyUser := &users.User{
|
||||
ID: 2,
|
||||
Username: "readonly",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
scopedUser := &users.User{
|
||||
ID: 3,
|
||||
Username: "scoped",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
Create: true,
|
||||
Modify: true,
|
||||
Delete: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/public"},
|
||||
},
|
||||
}
|
||||
|
||||
// Save users
|
||||
if err := store.Users.Save(fullAccessUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Users.Save(readOnlyUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Users.Save(scopedUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *users.User
|
||||
method string
|
||||
path string
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "Full access user can PUT file",
|
||||
user: fullAccessUser,
|
||||
method: http.MethodPut,
|
||||
path: "/public/newfile.txt",
|
||||
expectedStatus: http.StatusCreated, // 201 for new file creation
|
||||
},
|
||||
{
|
||||
name: "Read-only user cannot PUT file",
|
||||
user: readOnlyUser,
|
||||
method: http.MethodPut,
|
||||
path: "/public/newfile.txt",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Full access user can MKCOL",
|
||||
user: fullAccessUser,
|
||||
method: "MKCOL",
|
||||
path: "/newdir",
|
||||
expectedStatus: http.StatusCreated, // 201 for new directory
|
||||
},
|
||||
{
|
||||
name: "Read-only user cannot MKCOL",
|
||||
user: readOnlyUser,
|
||||
method: "MKCOL",
|
||||
path: "/newdir",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Full access user can DELETE",
|
||||
user: fullAccessUser,
|
||||
method: http.MethodDelete,
|
||||
path: "/public/readme.txt",
|
||||
expectedStatus: http.StatusNoContent, // 204 for successful deletion
|
||||
},
|
||||
{
|
||||
name: "Read-only user cannot DELETE",
|
||||
user: readOnlyUser,
|
||||
method: http.MethodDelete,
|
||||
path: "/public/readme.txt",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Scoped user can write in their scope",
|
||||
user: scopedUser,
|
||||
method: http.MethodPut,
|
||||
path: "/scopedfile.txt", // Path relative to scope (/public)
|
||||
expectedStatus: http.StatusCreated, // 201 for new file
|
||||
},
|
||||
{
|
||||
name: "User cannot write to not-viewable directory",
|
||||
user: fullAccessUser,
|
||||
method: http.MethodPut,
|
||||
path: "/not-viewable/file.txt",
|
||||
expectedStatus: 404, // Not viewable = acts like it doesn't exist
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body io.Reader
|
||||
if tc.method == http.MethodPut {
|
||||
body = strings.NewReader("test content")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(tc.method, "/dav/source1"+tc.path, body)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: tc.user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test access control rules
|
||||
func TestWebDAV_AccessControl(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
|
||||
// Create users
|
||||
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
|
||||
user1 := &users.User{
|
||||
ID: 1,
|
||||
Username: "user1",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
Create: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
user2 := &users.User{
|
||||
ID: 2,
|
||||
Username: "user2",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
// Save users
|
||||
if err := store.Users.Save(user1, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Users.Save(user2, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Initialize index
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
|
||||
// Create access rules by using the internal structure
|
||||
// User1 can access /private, user2 cannot
|
||||
// Set up a deny rule for user2 at /private
|
||||
// IMPORTANT: Access system requires trailing slashes for exact match
|
||||
rule := &access.AccessRule{
|
||||
Deny: access.RuleSet{
|
||||
Users: access.StringSet{"user2": struct{}{}},
|
||||
},
|
||||
}
|
||||
|
||||
// Add the rule to the access storage
|
||||
if store.Access.AllRules == nil {
|
||||
store.Access.AllRules = make(access.SourceRuleMap)
|
||||
}
|
||||
if store.Access.AllRules[source1Path] == nil {
|
||||
store.Access.AllRules[source1Path] = make(access.RuleMap)
|
||||
}
|
||||
store.Access.AllRules[source1Path]["/private/"] = rule // Add trailing slash
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *users.User
|
||||
path string
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "User1 can access private folder",
|
||||
user: user1,
|
||||
path: "/private",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
},
|
||||
{
|
||||
name: "User2 cannot access private folder",
|
||||
user: user2,
|
||||
path: "/private",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Both users can access public folder",
|
||||
user: user1,
|
||||
path: "/public",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
},
|
||||
{
|
||||
name: "User2 can also access public folder",
|
||||
user: user2,
|
||||
path: "/public",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("PROPFIND", "/dav/source1"+tc.path, nil)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: tc.user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test indexing states (indexed, viewable, not-viewable)
|
||||
func TestWebDAV_IndexingStates(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
|
||||
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
|
||||
user := &users.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
Create: true,
|
||||
Modify: true,
|
||||
Delete: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.Users.Save(user, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
expectedStatus int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Can read indexed directory",
|
||||
path: "/public",
|
||||
method: "PROPFIND",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
description: "Indexed directories should be accessible",
|
||||
},
|
||||
{
|
||||
name: "Can read viewable-only directory",
|
||||
path: "/viewable-only",
|
||||
method: "PROPFIND",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
description: "Not indexed but viewable directories should be readable",
|
||||
},
|
||||
{
|
||||
name: "Cannot read not-viewable directory",
|
||||
path: "/not-viewable",
|
||||
method: "PROPFIND",
|
||||
expectedStatus: 405, // WebDAV returns 405 Method Not Allowed when resource denied
|
||||
description: "Not viewable directories should be denied",
|
||||
},
|
||||
{
|
||||
name: "Cannot write to viewable-only directory",
|
||||
path: "/viewable-only/newfile.txt",
|
||||
method: http.MethodPut,
|
||||
expectedStatus: 404, // Returns 404 when trying to write to non-existent file in denied location
|
||||
description: "Viewable-only directories should deny write",
|
||||
},
|
||||
{
|
||||
name: "Cannot write to not-viewable directory",
|
||||
path: "/not-viewable/newfile.txt",
|
||||
method: http.MethodPut,
|
||||
expectedStatus: 404, // Not viewable = acts like it doesn't exist
|
||||
description: "Not viewable directories should deny all access",
|
||||
},
|
||||
{
|
||||
name: "Can write to indexed directory",
|
||||
path: "/public/newfile.txt",
|
||||
method: http.MethodPut,
|
||||
expectedStatus: http.StatusCreated, // 201 for new file creation
|
||||
description: "Indexed directories should allow write with permissions",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body io.Reader
|
||||
if tc.method == http.MethodPut {
|
||||
body = strings.NewReader("test content")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(tc.method, "/dav/source1"+tc.path, body)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("%s: expected status %d, got %d (err: %v)", tc.description, tc.expectedStatus, w.Code, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to initialize a test index - simplified for WebDAV tests
|
||||
func initTestIndex(t *testing.T, name, path string) {
|
||||
// For WebDAV tests, indices are already initialized in setupWebDAVTestEnv
|
||||
t.Helper()
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
|
||||
// PWAManifest represents the web app manifest structure
|
||||
type PWAManifest struct {
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"short_name"`
|
||||
Icons []PWAIcon `json:"icons"`
|
||||
StartURL string `json:"start_url"`
|
||||
Display string `json:"display"`
|
||||
BackgroundColor string `json:"background_color"`
|
||||
ThemeColor string `json:"theme_color"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// PWAIcon represents an icon in the web app manifest
|
||||
type PWAIcon struct {
|
||||
Src string `json:"src"`
|
||||
Sizes string `json:"sizes"`
|
||||
Type string `json:"type"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
// CachedManifest holds the generated PWA manifest at startup
|
||||
var CachedManifest PWAManifest
|
||||
|
||||
// generatePWAManifest creates the PWA manifest structure
|
||||
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
|
||||
shortName := name
|
||||
if len(name) > 12 {
|
||||
shortName = name[:12]
|
||||
}
|
||||
|
||||
return PWAManifest{
|
||||
Name: name,
|
||||
ShortName: shortName,
|
||||
StartURL: baseURL,
|
||||
Display: "standalone",
|
||||
BackgroundColor: "#ffffff",
|
||||
ThemeColor: themeColor,
|
||||
Description: description,
|
||||
Icons: []PWAIcon{
|
||||
{
|
||||
Src: pwaIcon192,
|
||||
Sizes: "192x192",
|
||||
Type: "image/png",
|
||||
Purpose: "any",
|
||||
},
|
||||
{
|
||||
Src: pwaIcon256,
|
||||
Sizes: "256x256",
|
||||
Type: "image/png",
|
||||
Purpose: "any maskable",
|
||||
},
|
||||
{
|
||||
Src: pwaIcon512,
|
||||
Sizes: "512x512",
|
||||
Type: "image/png",
|
||||
Purpose: "any",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// InitializePWAManifest generates and caches the PWA manifest at startup
|
||||
func InitializePWAManifest() {
|
||||
config := &settings.Config
|
||||
staticURL := config.Server.BaseURL + "public/static"
|
||||
title := config.Frontend.Name
|
||||
description := config.Frontend.Description
|
||||
defaultThemeColor := "#455a64"
|
||||
|
||||
// Determine PWA icon URLs based on custom favicon settings
|
||||
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
|
||||
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
|
||||
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
|
||||
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
|
||||
favicon := staticURL + "/favicon"
|
||||
pwaIcon192 = favicon
|
||||
pwaIcon256 = favicon
|
||||
pwaIcon512 = favicon
|
||||
} else if settings.Env.FaviconIsCustom {
|
||||
pwaIcon192 = staticURL + "/icons/pwa-icon-192.png"
|
||||
pwaIcon256 = staticURL + "/icons/pwa-icon-256.png"
|
||||
pwaIcon512 = staticURL + "/icons/pwa-icon-512.png"
|
||||
}
|
||||
|
||||
CachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
)
|
||||
|
||||
// setupConditionalTestIndex creates a test index with conditional rules configured
|
||||
@@ -624,149 +623,3 @@ func TestIsNeverWatchPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsViewable_DoesNotMutateFolderPaths verifies that IsViewable doesn't modify FolderPaths
|
||||
func TestIsViewable_DoesNotMutateFolderPaths(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
|
||||
idx.Config.ResolvedRules.FolderNames["private"] = settings.ConditionalRule{
|
||||
FolderNames: "private",
|
||||
Viewable: false,
|
||||
}
|
||||
|
||||
if len(idx.Config.ResolvedRules.FolderPaths) != 0 {
|
||||
t.Fatalf("expected no preloaded folder path rules, got %d", len(idx.Config.ResolvedRules.FolderPaths))
|
||||
}
|
||||
|
||||
if idx.IsViewable(true, "/private/", false, false) {
|
||||
t.Fatal("expected /private/ to be non-viewable")
|
||||
}
|
||||
|
||||
if len(idx.Config.ResolvedRules.FolderPaths) != 0 {
|
||||
t.Fatalf("IsViewable should not mutate FolderPaths, got %d entries", len(idx.Config.ResolvedRules.FolderPaths))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsViewableWithParentCheck_RecursiveParentWalking tests API request parent checking
|
||||
func TestIsViewableWithParentCheck_RecursiveParentWalking(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
|
||||
idx.Config.ResolvedRules.FolderPaths["/private"] = settings.ConditionalRule{
|
||||
FolderPath: "/private",
|
||||
Viewable: false,
|
||||
}
|
||||
// Enable global hidden folder blocking
|
||||
idx.Config.ResolvedRules.IgnoreAllHidden = true
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
isDir bool
|
||||
expected bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Direct non-viewable folder",
|
||||
path: "/private",
|
||||
isDir: true,
|
||||
expected: false,
|
||||
description: "Folder with explicit non-viewable rule should not be viewable",
|
||||
},
|
||||
{
|
||||
name: "Child of non-viewable folder",
|
||||
path: "/private/subfolder",
|
||||
isDir: true,
|
||||
expected: false,
|
||||
description: "Child should inherit parent's non-viewable status",
|
||||
},
|
||||
{
|
||||
name: "Deep child of non-viewable folder",
|
||||
path: "/private/subfolder/deep/nested",
|
||||
isDir: true,
|
||||
expected: false,
|
||||
description: "Deeply nested child should inherit parent's non-viewable status",
|
||||
},
|
||||
{
|
||||
name: "File in non-viewable folder",
|
||||
path: "/private/secret.txt",
|
||||
isDir: false,
|
||||
expected: false,
|
||||
description: "File should inherit parent folder's non-viewable status",
|
||||
},
|
||||
{
|
||||
name: "Unrelated folder",
|
||||
path: "/public/documents",
|
||||
isDir: true,
|
||||
expected: true,
|
||||
description: "Unrelated folder should be viewable",
|
||||
},
|
||||
{
|
||||
name: "Hidden folder",
|
||||
path: "/.hiddenDir",
|
||||
isDir: true,
|
||||
expected: false,
|
||||
description: "Hidden folder should not be viewable when IgnoreAllHidden is true",
|
||||
},
|
||||
{
|
||||
name: "File in hidden folder",
|
||||
path: "/.hiddenDir/nested.txt",
|
||||
isDir: false,
|
||||
expected: false,
|
||||
description: "File in hidden folder should inherit parent's non-viewable status",
|
||||
},
|
||||
{
|
||||
name: "Nested hidden folder",
|
||||
path: "/subfolderExclusions/.hiddenDir",
|
||||
isDir: true,
|
||||
expected: false,
|
||||
description: "Nested hidden folder should not be viewable",
|
||||
},
|
||||
{
|
||||
name: "File in nested hidden folder",
|
||||
path: "/subfolderExclusions/.hiddenDir/nested.txt",
|
||||
isDir: false,
|
||||
expected: false,
|
||||
description: "File in nested hidden folder should not be viewable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Determine if the path is hidden
|
||||
realPath := utils.JoinPathAsUnix(idx.Path, tt.path)
|
||||
isHidden := IsHidden(realPath)
|
||||
|
||||
result := idx.IsViewableWithParentCheck(tt.isDir, tt.path, false, isHidden)
|
||||
if result != tt.expected {
|
||||
t.Errorf("%s: expected %v, got %v (path=%s, isHidden=%v)",
|
||||
tt.description, tt.expected, result, tt.path, isHidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsViewableWithParentCheck_DoesNotMutate verifies thread safety
|
||||
func TestIsViewableWithParentCheck_DoesNotMutate(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
|
||||
idx.Config.ResolvedRules.FolderNames["restricted"] = settings.ConditionalRule{
|
||||
FolderNames: "restricted",
|
||||
Viewable: false,
|
||||
}
|
||||
|
||||
initialFolderPathCount := len(idx.Config.ResolvedRules.FolderPaths)
|
||||
|
||||
// Call IsViewableWithParentCheck multiple times with various paths
|
||||
paths := []string{
|
||||
"/restricted/data/file.txt",
|
||||
"/projects/restricted/subfolder/doc.pdf",
|
||||
"/restricted/deep/nested/path/file.csv",
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
_ = idx.IsViewableWithParentCheck(false, path, false, false)
|
||||
}
|
||||
|
||||
// Verify no mutation occurred
|
||||
if len(idx.Config.ResolvedRules.FolderPaths) != initialFolderPathCount {
|
||||
t.Errorf("FolderPaths was mutated: expected %d, got %d",
|
||||
initialFolderPathCount, len(idx.Config.ResolvedRules.FolderPaths))
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func TestProcessDirectoryItem(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
itemInfo, size, shouldCount := idx.processDirectoryItem(
|
||||
tt.file, tt.combinedPath,
|
||||
@@ -212,6 +212,7 @@ func TestProcessDirectoryItem(t *testing.T) {
|
||||
|
||||
if itemInfo == nil {
|
||||
t.Fatalf("expected non-nil itemInfo")
|
||||
return
|
||||
}
|
||||
|
||||
if size != tt.expectSize {
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -86,7 +85,6 @@ type Stats struct {
|
||||
Complexity uint `json:"complexity"`
|
||||
LastScanned time.Time `json:"lastScanned"`
|
||||
DiskUsed uint64 `json:"used"`
|
||||
UsedAlt uint64 `json:"usedAlt"`
|
||||
}
|
||||
|
||||
// reduced index is json exposed to the client
|
||||
@@ -115,8 +113,6 @@ type Index struct {
|
||||
// Scan session tracking: timestamp when scan session started (for timestamp-based conflict detection)
|
||||
scanSessionStartTime int64 // Unix timestamp when current scan session started (0 if no active scan)
|
||||
scanUpdatedPaths map[string]bool // Tracks directories updated by the scan (to distinguish from API updates)
|
||||
// WebDAV lock system for this source (isolated per source)
|
||||
WebdavLock webdav.LockSystem
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -390,7 +386,6 @@ func Initialize(source *settings.Source, mock bool, isNewDb bool) {
|
||||
scanUpdatedPaths: make(map[string]bool),
|
||||
folderSizes: make(map[string]uint64), // In-memory folder size tracking
|
||||
folderSizesUnsynced: make(map[string]struct{}), // Track changed folders
|
||||
WebdavLock: webdav.NewMemLS(), // Initialize WebDAV lock system for this source
|
||||
}
|
||||
newIndex.ReducedIndex = ReducedIndex{
|
||||
Status: "indexing",
|
||||
@@ -426,12 +421,11 @@ type PathContext struct {
|
||||
|
||||
// FileInfoRequest specifies what information to retrieve
|
||||
type FileInfoRequest struct {
|
||||
IndexPath string
|
||||
FollowSymlinks bool
|
||||
ShowHidden bool
|
||||
Expand bool // get child items for directories
|
||||
IsRoutineScan bool // scanner vs API call
|
||||
SkipExtendedAttrs bool // whether to skip extended attributes
|
||||
IndexPath string
|
||||
FollowSymlinks bool
|
||||
ShowHidden bool
|
||||
Expand bool // get child items for directories
|
||||
IsRoutineScan bool // scanner vs API call
|
||||
}
|
||||
|
||||
// resolvePathContext resolves all path characteristics in a SINGLE stat call
|
||||
@@ -487,14 +481,7 @@ func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*Pa
|
||||
// Returns whether the path is viewable and/or indexable
|
||||
// Does NOT check user permissions (that's handled in the API layer)
|
||||
func (idx *Index) evaluateIndexRules(ctx *PathContext, isRoutineScan bool) (isViewable bool, isIndexable bool) {
|
||||
// For scanner (routine scan), only check the item itself
|
||||
// For API requests, check item and all parent paths
|
||||
if isRoutineScan {
|
||||
isViewable = idx.IsViewable(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink, ctx.IsHidden)
|
||||
} else {
|
||||
isViewable = idx.IsViewableWithParentCheck(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink, ctx.IsHidden)
|
||||
}
|
||||
|
||||
isViewable = idx.IsViewable(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink)
|
||||
shouldSkip := idx.ShouldSkip(ctx.IsDir, ctx.IndexPath, ctx.IsHidden, ctx.IsSymlink, isRoutineScan)
|
||||
isIndexable = !shouldSkip
|
||||
return isViewable, isIndexable
|
||||
@@ -510,11 +497,12 @@ func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Apply index rules (non-routine scan)
|
||||
isViewable, isIndexable := idx.evaluateIndexRules(ctx, false)
|
||||
// API request: path must be viewable
|
||||
if !isViewable {
|
||||
return nil, errors.ErrNotViewable
|
||||
// 2. Apply index rules
|
||||
isViewable, isIndexable := idx.evaluateIndexRules(ctx, req.IsRoutineScan)
|
||||
|
||||
// Path must be either viewable OR indexable
|
||||
if !isViewable && !isIndexable {
|
||||
return nil, errors.ErrNotIndexed
|
||||
}
|
||||
|
||||
// 3. Return appropriate info
|
||||
@@ -565,7 +553,7 @@ func (idx *Index) getDirInfoFromContext(ctx *PathContext, isViewable, isIndexabl
|
||||
Recursive: false,
|
||||
CheckViewable: true,
|
||||
IsRoutineScan: req.IsRoutineScan,
|
||||
SkipExtendedAttrs: req.SkipExtendedAttrs || !isIndexable,
|
||||
SkipExtendedAttrs: !isIndexable, // Only fetch extended attrs if indexable
|
||||
FollowSymlinks: req.FollowSymlinks,
|
||||
ShowHidden: req.ShowHidden,
|
||||
}
|
||||
@@ -659,16 +647,16 @@ func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileI
|
||||
// Check if item is accessible
|
||||
hidden := IsHidden(realPath)
|
||||
isSymlink := dirInfo.Mode()&os.ModeSymlink != 0
|
||||
isViewable := idx.IsViewable(dirInfo.IsDir(), indexPath, isSymlink, hidden)
|
||||
isViewable := idx.IsViewable(dirInfo.IsDir(), indexPath, isSymlink)
|
||||
isSkipped := idx.ShouldSkip(dirInfo.IsDir(), indexPath, hidden, isSymlink, false)
|
||||
|
||||
// Deny access if not viewable AND skipped
|
||||
if opts.CheckViewable {
|
||||
if !isViewable {
|
||||
return nil, errors.ErrNotViewable
|
||||
return nil, errors.ErrNotIndexed
|
||||
}
|
||||
} else if isSkipped {
|
||||
return nil, errors.ErrNotViewable
|
||||
return nil, errors.ErrNotIndexed
|
||||
}
|
||||
|
||||
realSize, _ := idx.handleFile(dirInfo, indexPath, realPath, false, nil)
|
||||
@@ -933,7 +921,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
|
||||
// Check viewable/indexable rules
|
||||
if !shouldSkip {
|
||||
isViewable := idx.IsViewable(isDir, fullCombined, isSymlink, hidden)
|
||||
isViewable := idx.IsViewable(isDir, fullCombined, isSymlink)
|
||||
isSkipped := idx.ShouldSkip(isDir, fullCombined, hidden, isSymlink, opts.IsRoutineScan)
|
||||
if opts.CheckViewable {
|
||||
shouldSkip = !isViewable && isSkipped
|
||||
@@ -1341,41 +1329,53 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
|
||||
}
|
||||
|
||||
// IsViewable checks if a path is viewable (allows FS access)
|
||||
// Only checks the item itself, not parent paths
|
||||
func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHidden bool) bool {
|
||||
if indexPath == "/" || idx.Config.ResolvedRules.NoRules {
|
||||
func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool) bool {
|
||||
if indexPath == "/" {
|
||||
return true
|
||||
}
|
||||
|
||||
rules := idx.Config.ResolvedRules
|
||||
|
||||
// Check if hidden and should be blocked
|
||||
if isHidden && rules.IgnoreAllHidden {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check symlinks
|
||||
if isSymlink && rules.IgnoreAllSymlinks {
|
||||
if isSymlink && idx.Config.ResolvedRules.IgnoreAllSymlinks {
|
||||
return false
|
||||
}
|
||||
|
||||
rules := idx.Config.ResolvedRules
|
||||
baseName := filepath.Base(indexPath)
|
||||
if indexPath == "/" {
|
||||
baseName = filepath.Base(idx.Path)
|
||||
}
|
||||
|
||||
if isDir {
|
||||
// Check folder rules
|
||||
// Check folder rules - return false if explicitly set to non-viewable
|
||||
if rule, exists := rules.FolderNames[baseName]; exists {
|
||||
// Cache this match in FolderPaths so children inherit via prefix matching
|
||||
if _, ok := rules.FolderPaths[indexPath]; !ok {
|
||||
rules.FolderPaths[indexPath] = rule
|
||||
}
|
||||
return rule.Viewable
|
||||
}
|
||||
if rule, exists := rules.FolderPaths[indexPath]; exists {
|
||||
return rule.Viewable
|
||||
}
|
||||
for path, rule := range rules.FolderPaths {
|
||||
if strings.HasPrefix(indexPath, path) {
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.FolderEndsWith {
|
||||
if strings.HasSuffix(baseName, rule.FolderEndsWith) {
|
||||
// Cache this match in FolderPaths so children inherit
|
||||
if _, ok := rules.FolderPaths[indexPath]; !ok {
|
||||
rules.FolderPaths[indexPath] = rule
|
||||
}
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.FolderStartsWith {
|
||||
if strings.HasPrefix(baseName, rule.FolderStartsWith) {
|
||||
// Cache this match in FolderPaths so children inherit
|
||||
if _, ok := rules.FolderPaths[indexPath]; !ok {
|
||||
rules.FolderPaths[indexPath] = rule
|
||||
}
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
@@ -1387,6 +1387,11 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHid
|
||||
if rule, exists := rules.FilePaths[indexPath]; exists {
|
||||
return rule.Viewable
|
||||
}
|
||||
for path, rule := range rules.FilePaths {
|
||||
if strings.HasPrefix(indexPath, path) {
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.FileEndsWith {
|
||||
if strings.HasSuffix(baseName, rule.FileEndsWith) {
|
||||
return rule.Viewable
|
||||
@@ -1397,44 +1402,27 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHid
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
// Check if file inherits viewable status from parent folder
|
||||
for path, rule := range rules.FolderPaths {
|
||||
if strings.HasPrefix(indexPath, path) {
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true // Default: viewable unless explicitly set to false
|
||||
}
|
||||
|
||||
// IsViewableWithParentCheck checks if a path is viewable by walking up the directory tree
|
||||
// Used for API requests where we need to check inherited rules from parent folders
|
||||
func (idx *Index) IsViewableWithParentCheck(isDir bool, indexPath string, isSymlink bool, isHidden bool) bool {
|
||||
if idx.Config.DisableIndexing || idx.Config.ResolvedRules.NoRules {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check the item itself - if not viewable, stop here
|
||||
if !idx.IsViewable(isDir, indexPath, isSymlink, isHidden) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Item is viewable, now check parent recursively
|
||||
parentDir := utils.GetParentDirectoryPath(indexPath)
|
||||
if parentDir == "" || parentDir == "/" {
|
||||
return true // Reached root with no non-viewable rules, default to viewable
|
||||
}
|
||||
|
||||
// Check if parent is hidden
|
||||
realPath, _, _ := idx.GetRealPath(parentDir)
|
||||
return idx.IsViewableWithParentCheck(true, parentDir, false, IsHidden(realPath))
|
||||
}
|
||||
|
||||
// ShouldSkip checks if a path should be skipped from indexing
|
||||
// isRoutineScan: true for scanner (no parent checks), false for API (check parents)
|
||||
func (idx *Index) ShouldSkip(isDir bool, adjustedPath string, isHidden bool, isSymlink bool, isRoutineScan bool) bool {
|
||||
rules := idx.Config.ResolvedRules
|
||||
if adjustedPath == "/" {
|
||||
return false
|
||||
}
|
||||
if idx.Config.DisableIndexing {
|
||||
return true
|
||||
}
|
||||
rules := idx.Config.ResolvedRules
|
||||
if adjustedPath == "/" || rules.NoRules {
|
||||
return false
|
||||
}
|
||||
if isHidden && rules.IgnoreAllHidden {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ type Scanner struct {
|
||||
lastScanned time.Time
|
||||
quickScanTime int
|
||||
fullScanTime int
|
||||
statsMu sync.RWMutex
|
||||
scanStartTime int64
|
||||
isScanning bool // True when scanner is actively scanning or waiting for mutex
|
||||
|
||||
@@ -42,18 +41,6 @@ type Scanner struct {
|
||||
stopOnce sync.Once // Ensures stopChan is only closed once
|
||||
}
|
||||
|
||||
func (s *Scanner) withStatsLock(fn func()) {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
fn()
|
||||
}
|
||||
|
||||
func (s *Scanner) withStatsRLock(fn func()) {
|
||||
s.statsMu.RLock()
|
||||
defer s.statsMu.RUnlock()
|
||||
fn()
|
||||
}
|
||||
|
||||
// start begins the scanner's main loop
|
||||
func (s *Scanner) start() {
|
||||
// Wait a bit to stagger child scanner initial scans (root goes first)
|
||||
@@ -139,20 +126,14 @@ func (s *Scanner) runRootScan(quick bool) {
|
||||
|
||||
if quick {
|
||||
s.runQuickScanRoot()
|
||||
s.statsMu.Lock()
|
||||
s.quickScanTime = int(time.Since(startTime).Seconds())
|
||||
s.statsMu.Unlock()
|
||||
} else {
|
||||
s.runFullScanRoot()
|
||||
s.statsMu.Lock()
|
||||
s.fullScanTime = int(time.Since(startTime).Seconds())
|
||||
s.statsMu.Unlock()
|
||||
s.updateComplexity()
|
||||
}
|
||||
|
||||
s.statsMu.Lock()
|
||||
s.lastScanned = time.Now()
|
||||
s.statsMu.Unlock()
|
||||
s.checkForNewChildDirectories()
|
||||
if err := s.idx.db.ShrinkMemory(); err != nil {
|
||||
logger.Errorf("Failed to shrink memory: %v", err)
|
||||
@@ -166,10 +147,8 @@ func (s *Scanner) runFullScanRoot() {
|
||||
}
|
||||
|
||||
// Reset counters and initialize state for full scan
|
||||
s.withStatsLock(func() {
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
})
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.idx.mu.Lock()
|
||||
if s.idx.scanSessionStartTime == 0 {
|
||||
@@ -182,9 +161,7 @@ func (s *Scanner) runFullScanRoot() {
|
||||
|
||||
batchSize := s.idx.db.BatchSize
|
||||
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
|
||||
s.withStatsLock(func() {
|
||||
s.filesChanged = false
|
||||
})
|
||||
s.filesChanged = false
|
||||
|
||||
_, _, err := s.idx.indexDirectory("/", config, s)
|
||||
if err != nil {
|
||||
@@ -207,9 +184,7 @@ func (s *Scanner) runFullScanRoot() {
|
||||
|
||||
func (s *Scanner) runQuickScanRoot() {
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.withStatsLock(func() {
|
||||
s.filesChanged = false
|
||||
})
|
||||
s.filesChanged = false
|
||||
|
||||
// Get root-level folders from folderSizes map
|
||||
s.idx.folderSizesMu.RLock()
|
||||
@@ -253,21 +228,15 @@ func (s *Scanner) runChildScan(quick bool) {
|
||||
if quick {
|
||||
// Quick scan: iterate folderSizes, check modtimes only (257x faster!)
|
||||
s.runQuickScanChild()
|
||||
s.withStatsLock(func() {
|
||||
s.quickScanTime = int(time.Since(startTime).Seconds())
|
||||
})
|
||||
s.quickScanTime = int(time.Since(startTime).Seconds())
|
||||
} else {
|
||||
// Full scan: walk filesystem recursively, read all directory contents
|
||||
s.runFullScanChild()
|
||||
s.withStatsLock(func() {
|
||||
s.fullScanTime = int(time.Since(startTime).Seconds())
|
||||
})
|
||||
s.fullScanTime = int(time.Since(startTime).Seconds())
|
||||
s.updateComplexity()
|
||||
}
|
||||
|
||||
s.withStatsLock(func() {
|
||||
s.lastScanned = time.Now()
|
||||
})
|
||||
s.lastScanned = time.Now()
|
||||
}
|
||||
|
||||
func (s *Scanner) runFullScanChild() {
|
||||
@@ -277,10 +246,8 @@ func (s *Scanner) runFullScanChild() {
|
||||
}
|
||||
|
||||
// Reset counters and initialize state for full scan
|
||||
s.withStatsLock(func() {
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
})
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.idx.mu.Lock()
|
||||
if s.idx.scanSessionStartTime == 0 {
|
||||
@@ -290,9 +257,7 @@ func (s *Scanner) runFullScanChild() {
|
||||
|
||||
s.processedInodes = make(map[uint64]struct{})
|
||||
s.foundHardLinks = make(map[string]uint64)
|
||||
s.withStatsLock(func() {
|
||||
s.filesChanged = false
|
||||
})
|
||||
s.filesChanged = false
|
||||
|
||||
batchSize := s.idx.db.BatchSize
|
||||
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
|
||||
@@ -332,9 +297,7 @@ func (s *Scanner) runFullScanChild() {
|
||||
|
||||
func (s *Scanner) runQuickScanChild() {
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.withStatsLock(func() {
|
||||
s.filesChanged = false
|
||||
})
|
||||
s.filesChanged = false
|
||||
|
||||
// Get folders in this scanner's scope from folderSizes map
|
||||
s.idx.folderSizesMu.RLock()
|
||||
@@ -488,10 +451,7 @@ func (s *Scanner) getTopLevelDirs() []string {
|
||||
|
||||
// calculateSleepTime determines how long to wait before the next scan
|
||||
func (s *Scanner) calculateSleepTime() time.Duration {
|
||||
var sleepTime time.Duration
|
||||
s.withStatsRLock(func() {
|
||||
sleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
|
||||
})
|
||||
sleepTime := scanSchedule[s.currentSchedule] + s.smartModifier
|
||||
if s.idx.Config.IndexingInterval > 0 {
|
||||
sleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
|
||||
}
|
||||
@@ -501,8 +461,6 @@ func (s *Scanner) calculateSleepTime() time.Duration {
|
||||
|
||||
// updateSchedule adjusts the scanner's schedule based on whether files changed
|
||||
func (s *Scanner) updateSchedule() {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
|
||||
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
|
||||
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
|
||||
@@ -548,9 +506,6 @@ func (s *Scanner) updateSchedule() {
|
||||
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
|
||||
// 0: unknown
|
||||
func (s *Scanner) updateComplexity() {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
|
||||
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
|
||||
|
||||
// Set smartModifier based on complexity level
|
||||
@@ -609,10 +564,8 @@ func (s *Scanner) syncStatsWithDB() {
|
||||
return
|
||||
}
|
||||
|
||||
s.withStatsLock(func() {
|
||||
s.numDirs = dirs
|
||||
s.numFiles = files
|
||||
})
|
||||
s.numDirs = dirs
|
||||
s.numFiles = files
|
||||
}
|
||||
|
||||
// checkFolderModtime checks if a folder's modtime changed since last scan
|
||||
@@ -637,9 +590,7 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
|
||||
|
||||
if modtimeChanged {
|
||||
// Modtime changed - read directory contents and update database
|
||||
s.withStatsLock(func() {
|
||||
s.filesChanged = true
|
||||
})
|
||||
s.filesChanged = true
|
||||
|
||||
dir, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -116,9 +116,7 @@ func (idx *Index) markFilesChanged() {
|
||||
idx.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
scanner.withStatsLock(func() {
|
||||
scanner.filesChanged = true
|
||||
})
|
||||
scanner.filesChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,9 +145,7 @@ func (idx *Index) incrementScannerDirsUnlocked() {
|
||||
}
|
||||
scanner, exists := idx.scanners[activePath]
|
||||
if exists {
|
||||
scanner.withStatsLock(func() {
|
||||
scanner.numDirs++
|
||||
})
|
||||
scanner.numDirs++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,9 +162,7 @@ func (idx *Index) incrementScannerFiles() {
|
||||
idx.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
scanner.withStatsLock(func() {
|
||||
scanner.numFiles++
|
||||
})
|
||||
scanner.numFiles++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,15 +252,13 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
rootScanner := idx.createScanner("/")
|
||||
if persistedScanners != nil {
|
||||
if rootInfo, ok := persistedScanners["/"]; ok {
|
||||
rootScanner.withStatsLock(func() {
|
||||
rootScanner.complexity = rootInfo.Complexity
|
||||
rootScanner.currentSchedule = rootInfo.CurrentSchedule
|
||||
rootScanner.quickScanTime = rootInfo.QuickScanTime
|
||||
rootScanner.fullScanTime = rootInfo.FullScanTime
|
||||
rootScanner.numDirs = rootInfo.NumDirs
|
||||
rootScanner.numFiles = rootInfo.NumFiles
|
||||
rootScanner.lastScanned = rootInfo.LastScanned
|
||||
})
|
||||
rootScanner.complexity = rootInfo.Complexity
|
||||
rootScanner.currentSchedule = rootInfo.CurrentSchedule
|
||||
rootScanner.quickScanTime = rootInfo.QuickScanTime
|
||||
rootScanner.fullScanTime = rootInfo.FullScanTime
|
||||
rootScanner.numDirs = rootInfo.NumDirs
|
||||
rootScanner.numFiles = rootInfo.NumFiles
|
||||
rootScanner.lastScanned = rootInfo.LastScanned
|
||||
}
|
||||
}
|
||||
idx.mu.Lock()
|
||||
@@ -284,15 +276,13 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
// Restore persisted stats for child scanner if available (and DB is not new)
|
||||
if persistedScanners != nil {
|
||||
if childInfo, ok := persistedScanners[dirPath]; ok {
|
||||
childScanner.withStatsLock(func() {
|
||||
childScanner.complexity = childInfo.Complexity
|
||||
childScanner.currentSchedule = childInfo.CurrentSchedule
|
||||
childScanner.quickScanTime = childInfo.QuickScanTime
|
||||
childScanner.fullScanTime = childInfo.FullScanTime
|
||||
childScanner.numDirs = childInfo.NumDirs
|
||||
childScanner.numFiles = childInfo.NumFiles
|
||||
childScanner.lastScanned = childInfo.LastScanned
|
||||
})
|
||||
childScanner.complexity = childInfo.Complexity
|
||||
childScanner.currentSchedule = childInfo.CurrentSchedule
|
||||
childScanner.quickScanTime = childInfo.QuickScanTime
|
||||
childScanner.fullScanTime = childInfo.FullScanTime
|
||||
childScanner.numDirs = childInfo.NumDirs
|
||||
childScanner.numFiles = childInfo.NumFiles
|
||||
childScanner.lastScanned = childInfo.LastScanned
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,25 +318,22 @@ func (idx *Index) GetScannerStatus() map[string]interface{} {
|
||||
activePath := idx.getActiveScannerPathUnlocked()
|
||||
status["activeScanner"] = activePath
|
||||
status["isScanning"] = activePath != ""
|
||||
status["status"] = idx.Status
|
||||
|
||||
// Individual scanner stats
|
||||
scannerStats := make([]map[string]interface{}, 0, len(idx.scanners))
|
||||
for path, scanner := range idx.scanners {
|
||||
scannerInfo := map[string]interface{}{
|
||||
"path": path,
|
||||
"path": path,
|
||||
"lastScanned": scanner.lastScanned.Format(time.RFC3339),
|
||||
"complexity": scanner.complexity,
|
||||
"currentSchedule": scanner.currentSchedule,
|
||||
"quickScanTime": scanner.quickScanTime,
|
||||
"fullScanTime": scanner.fullScanTime,
|
||||
"numDirs": scanner.numDirs,
|
||||
"numFiles": scanner.numFiles,
|
||||
"filesChanged": scanner.filesChanged,
|
||||
"smartModifier": scanner.smartModifier.String(),
|
||||
}
|
||||
scanner.withStatsRLock(func() {
|
||||
scannerInfo["lastScanned"] = scanner.lastScanned.Format(time.RFC3339)
|
||||
scannerInfo["complexity"] = scanner.complexity
|
||||
scannerInfo["currentSchedule"] = scanner.currentSchedule
|
||||
scannerInfo["quickScanTime"] = scanner.quickScanTime
|
||||
scannerInfo["fullScanTime"] = scanner.fullScanTime
|
||||
scannerInfo["numDirs"] = scanner.numDirs
|
||||
scannerInfo["numFiles"] = scanner.numFiles
|
||||
scannerInfo["filesChanged"] = scanner.filesChanged
|
||||
scannerInfo["smartModifier"] = scanner.smartModifier.String()
|
||||
})
|
||||
scannerStats = append(scannerStats, scannerInfo)
|
||||
}
|
||||
status["scanners"] = scannerStats
|
||||
|
||||
@@ -128,11 +128,7 @@ func TestMultiScannerMutex(t *testing.T) {
|
||||
|
||||
// Verify markFilesChanged works
|
||||
idx.markFilesChanged()
|
||||
var changed bool
|
||||
scanner2.withStatsRLock(func() {
|
||||
changed = scanner2.filesChanged
|
||||
})
|
||||
if !changed {
|
||||
if !scanner2.filesChanged {
|
||||
t.Error("Expected scanner2.filesChanged=true")
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user