Compare commits

..
Author SHA1 Message Date
Graham Steffaniak e4907225a1 fix download link that was incorrect for testing 2026-02-27 16:46:32 -06:00
Graham Steffaniak cc136043f9 Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-27 16:42:45 -06:00
Graham SteffaniakandStephan Paternotte 611dd8ea71 Merge v1.2.1 stable (#2044)
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-02-27 16:37:25 -06:00
Graham Steffaniak 41e9ff27fa Merge branch 'main' into dev/v1.3.0 2026-02-27 16:33:33 -06:00
Graham Steffaniak 2eb352c2d3 fix delete item issue while viewing (#2043) 2026-02-27 16:29:49 -06:00
Graham Steffaniak f11d8df7e8 Updated screenshots mechanism (#2042) 2026-02-27 15:52:12 -06:00
Graham Steffaniak 43bb4a36b4 stable/v1.2.1 (#2041) 2026-02-27 13:13:18 -06:00
Graham Steffaniak 3fd20bbee1 add jwt login support (#2036) 2026-02-27 10:34:02 -06:00
Graham Steffaniak 86a031be1d OIDC ldap user group (#2035) 2026-02-26 17:22:47 -06:00
d1b5a18c83 Resizable prompts (#2020)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-02-25 17:21:55 -06:00
Graham Steffaniak d9fc8c0275 add more prompts -- threejs and office debug (#2033) 2026-02-25 16:13:51 -06:00
Stephan Paternotte c67abefc7b Update nl.json (#2032) 2026-02-25 12:26:39 -06:00
Graham Steffaniak 6cad10afb5 Reorganize swagger (#2030) 2026-02-25 11:09:23 -06:00
Graham Steffaniak 3a53b13b3d Merge branch 'main' into dev/v1.3.0 2026-02-24 15:05:12 -06:00
Graham Steffaniak 3fca886136 Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-24 15:04:20 -06:00
Kevin B. 16994cae79 Make source in navigation mode like links (#2017) 2026-02-24 14:40:40 -05:00
Graham Steffaniak 2ecb9036aa Beta/v1.2.7 (#2027) 2026-02-24 12:55:04 -06:00
Graham Steffaniak 7eb9f1b0fa Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-24 10:23:16 -06:00
Graham Steffaniak d81be14d12 allow more progress bar customization (#2022) 2026-02-24 10:16:19 -06:00
Graham Steffaniak 884a5fa221 Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-23 12:55:05 -06:00
Graham Steffaniak bbcf472fe9 Add loading spinner new api (#2012) 2026-02-22 14:04:20 -06:00
Graham Steffaniak 49075d85df Many style changes (#2010) 2026-02-22 12:15:50 -06:00
Kevin B. b95311dff8 Sidebar tree navigation (#2006) 2026-02-22 11:58:05 -05:00
Graham Steffaniak 7d3aa0201f added faster api for simple file/folder lists (#2005) 2026-02-21 20:18:51 -06:00
Graham Steffaniak 1048b528eb fix cve 2026-02-21 17:45:00 -06:00
Graham Steffaniak 6d4c74b883 updated translation for polish 2026-02-21 10:49:45 -06:00
Graham Steffaniak dbcfba993b Beta/v1.2.6 (#2004) 2026-02-21 10:39:04 -06:00
Graham Steffaniakandzarev e4d6f4fa51 Update preview thumbnails (#2003)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-20 19:21:30 -06:00
Graham Steffaniak 2aa766000b Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-20 15:48:46 -06:00
Graham Steffaniakandzarev 315af7aa6e Archive creation feature (#2002)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-20 15:48:19 -06:00
Graham Steffaniak 9399392956 Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-20 11:05:09 -06:00
Graham Steffaniakandzarev 2a17ccb2a9 Add sharing icon (#2001)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-20 11:04:57 -06:00
Graham Steffaniak c56b8cd6dc Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-20 08:50:25 -06:00
Graham Steffaniakandzarev 28ce27397f Merge v1.2.5 v1.3.0 (#2000)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-20 08:49:53 -06:00
Graham Steffaniak 312e434bb5 Merge branch 'main' into dev/v1.3.0 2026-02-19 18:37:24 -06:00
Graham Steffaniak ed22ce9421 Beta/v1.2.5 (#1999) 2026-02-19 16:35:55 -06:00
Graham Steffaniak cfa1a8c120 updated with ldap support (#1994) 2026-02-18 18:00:26 -06:00
Graham Steffaniak 57487f7523 3d model viewing support (#1981) 2026-02-18 08:51:24 -06:00
Graham Steffaniak 7a80433b2c make update (#1989) 2026-02-16 18:46:35 -06:00
Joel Godfrey 30a00d59ef Update delete api endpoints to DELETE request method instead of POST against dev/1.3.0 (#1987)
Signed-off-by: Joel Godfrey <m.joelgodfrey@gmail.com>
2026-02-16 14:30:04 -06:00
Graham Steffaniak 1b08b968db updated with fixes for file watcher (#1980) 2026-02-13 18:30:45 -06:00
Graham Steffaniak fcce47203f Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-13 16:27:49 -06:00
Graham Steffaniakandzarev 4de432e8ce Merge v1.2.4 v1.3.0 (#1979)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-13 16:21:33 -06:00
Graham Steffaniakandzarev 8caa6d6a60 Beta/v1.2.4 (#1978)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-13 16:08:20 -06:00
Graham Steffaniak fc42b6fbc0 Merge tag 'v1.2.4-beta' into dev/v1.3.0 2026-02-13 16:07:13 -06:00
Graham Steffaniak 5c2e2f6af5 ensure folder sizes get updated (#1977) 2026-02-13 15:00:42 -06:00
Graham Steffaniak 5121a6fa82 added screenshots test (#1976) 2026-02-13 11:58:43 -06:00
Graham Steffaniak dc8d71ddb0 Fix image selection buttons (#1972) 2026-02-12 18:17:43 -06:00
Graham Steffaniak 961bdd475f fix new user name (#1971) 2026-02-12 17:19:02 -06:00
Kevin B. 34b957be0f fix share and user delete prompts (#1970) 2026-02-12 15:57:40 -06:00
zarevandGraham Steffaniak 86c41db9c5 Corrections (#1969)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-02-12 15:21:56 -06:00
Graham Steffaniak 89d99090ec fix otp settings update (#1967) 2026-02-12 13:14:18 -06:00
Kevin B. 0dde7cbe59 fix quick jump in previews (#1954) 2026-02-12 13:14:04 -06:00
Graham Steffaniak dfe217d020 filename rename fix (#1966) 2026-02-12 08:54:18 -06:00
zarevandGraham Steffaniak 1d2ac2ab06 Update fr.json (#1965)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-02-12 08:21:33 -06:00
Graham Steffaniak 87923add8a updated with dutch language (#1961) 2026-02-11 18:10:37 -06:00
Graham Steffaniak be7df9000a Fix subtitles (#1960) 2026-02-11 17:22:46 -06:00
Graham Steffaniak 1126fe7d95 Fix encoding issues (#1959) 2026-02-11 14:02:54 -06:00
Graham Steffaniak 4476d97f6e use generic prompt where possible (#1958) 2026-02-11 10:06:24 -06:00
Graham Steffaniak 888cad356e adjusted appearence of cards (#1953) 2026-02-10 10:16:06 -06:00
Kevin B. 18bd22ee95 Fix disabled toggle in create api token prompt (#1951) 2026-02-09 16:01:28 -06:00
Graham Steffaniak ae2fabfae6 Update prompt styling (#1952) 2026-02-09 16:01:18 -06:00
Graham Steffaniak 1e891452bc updated token api descriptions (#1949) 2026-02-08 11:55:00 -06:00
Graham Steffaniak dc177b634d updated with improved webdav (#1946) 2026-02-07 10:15:22 -06:00
Graham Steffaniak 1b0f53dead updated webdav to support permissions 2026-02-06 17:53:40 -06:00
Graham Steffaniak 19c19f70d7 Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-06 14:29:45 -06:00
Graham Steffaniak bf11fc43f3 updated with filebrowser user default (#1942) 2026-02-06 14:29:40 -06:00
Graham Steffaniak 7b39fcc9ac Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-06 14:29:22 -06:00
Graham Steffaniak fb1af87048 Beta/v1.2.3 (#1940) 2026-02-06 13:52:38 -06:00
Graham Steffaniak 683c837d0c updated readme (#1937) 2026-02-05 12:07:18 -06:00
Graham Steffaniak b95ce79d50 updated favicon logic more 2026-02-05 11:48:12 -06:00
Graham Steffaniak a0c6b123de updated share apis (#1936) 2026-02-05 10:38:46 -06:00
Graham Steffaniak 5832a2ebfd updated with some api changes 2026-02-05 10:19:07 -06:00
Graham Steffaniak a9d738d419 Make icon package (#1932) 2026-02-04 18:14:50 -06:00
Graham Steffaniak 952484cd3d fix motion preview sticking (#1931) 2026-02-04 13:13:51 -06:00
+4 f73ca51f94 Beta/v1.2.2 (#1928)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-02-03 18:07:51 -06:00
+4 ad839f92ac Beta/v1.2.1 (#1889)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-25 07:13:59 -06:00
289 changed files with 25353 additions and 10145 deletions

No files matched your search

+2
View File
@@ -1,4 +1,6 @@
backend/**.db
**.crt
**.key
backend/tmp
backend/dist
backend/backend*
+6
View File
@@ -80,6 +80,12 @@ 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
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/gtsteffaniak/playwright-base
tags: latest
tags: chromium
- name: Build and push playwright-base
uses: docker/build-push-action@v6
with:
+1
View File
@@ -18,6 +18,7 @@ frontend/tsconfig.tsbuildinfo
/frontend/test-results
/frontend/playwright-files
/frontend/screenshots
/frontend/generated
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/backend/vendor
+119
View File
@@ -2,6 +2,124 @@
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.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.1-stable
**BugFixes**:
- startup icon generation order issue (#2019)
- login button on shares not showing (#2028)
- removed file watcher link in context menu for shares (#2025)
- Direct download link for single-file shares generates incorrect URL (#2026)
- Deleting files causes error V1.2.0 #2039
## v1.2.7-beta
**BugFixes**:
- head > title > infinitely duplicating titles (#2016)
- Proxy auth not working with Nginx, stuck at logo (#2013)
## v1.2.6-beta
**Security**:
- resolves https://github.com/gtsteffaniak/filebrowser/security/advisories/GHSA-8vrh-3pm2-v4v6
## v1.2.5-beta
**Notes**:
- Dependency Updates for frontend and backend packages
- Upgrade to go 1.26
- file list prompt interaction behavior, single-click always selects, double-click navigates. (#1911)
**BugFixes**:
- .ssa and .ass subtitle files get their styling removed during conversion (#1933)
- custom icons issue
- new database message for noauth (#1935)
## v1.2.4-beta
**Notes**:
- Changed `/api/media/subtitles` api endpoint to better support subtitles.
- Correcting some errors in French language (#1947)
- add Dutch `nl.json` for frontend i18n -- thanks @Stephan-P (#1957)
- share banner and icon images automatically serve a scaled down 1024x1024
**BugFixes**:
- Can't access directories with space (and possibly other special characters) when browsing a public link share (#1956)
- External ASS subtitles are not usable in web preview (#1933)
- Filename blocking rename/move (#1950)
- enforcedOtp: true blocks all user settings updates with HTTP 400 (#1962)
- Share favicon URL not showing up and Share description causing endless loading (#1911)
- folder sizes not updated for changes to filesystem outside of filebrowser (#1974)
## v1.2.3-beta
**Notes**:
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
**BugFixes**:
- fix stuck motion preview popup window issue
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
- share delete with user scope issue
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
- delete prompt icons show image previews
## v1.2.2-beta
**New Features**:
@@ -35,6 +153,7 @@ All notable changes to this project will be documented in this file. For commit
- added 75MB filesize limit for image previews
- optimized concurrency for large vs small images
## v1.2.1-beta
**New Features**:
-2
View File
@@ -6,7 +6,6 @@ WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev upx
ENV CGO_ENABLED=1
ENV GOEXPERIMENT=greenteagc
RUN go build -tags mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
@@ -44,7 +43,6 @@ 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 \
+2 -1
View File
@@ -1,4 +1,5 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps firefox
RUN npx playwright install --with-deps chromium
+14
View File
@@ -0,0 +1,14 @@
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
+14
View File
@@ -0,0 +1,14 @@
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" ]
+17
View File
@@ -0,0 +1,17 @@
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
+15
View File
@@ -0,0 +1,15 @@
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
-2
View File
@@ -4,7 +4,6 @@ ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
ENV GOEXPERIMENT=greenteagc
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
@@ -36,7 +35,6 @@ 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 \
+35 -1
View File
@@ -1,4 +1,19 @@
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
@@ -7,11 +22,30 @@ 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/config.yaml"
- "./src/proxy/backend/config.yaml:/home/filebrowser/data/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile.slim
+48
View File
@@ -0,0 +1,48 @@
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
+66
View File
@@ -0,0 +1,66 @@
# 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;
}
}
+101
View File
@@ -0,0 +1,101 @@
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()
}
}
+3
View File
@@ -0,0 +1,3 @@
module test
go 1.25.6
@@ -0,0 +1,66 @@
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
},
},
],
});
+33
View File
@@ -0,0 +1,33 @@
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
office: true
highQuality: true
permissions:
download: true
create: true
@@ -0,0 +1,54 @@
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"] },
},
],
});
@@ -0,0 +1,10 @@
server:
port: 8080
baseURL: "/files/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright"
config:
defaultEnabled: true
@@ -0,0 +1,69 @@
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,
},
],
});
+118 -41
View File
@@ -2,7 +2,6 @@ package files
import (
"context"
"encoding/base64"
"fmt"
"io"
"os"
@@ -37,6 +36,18 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
metadataCount++
}
}
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
// Refresh directory to update folder sizes
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true)
err := idx.RefreshDirectory(indexPath, false)
if err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
// Set hasMetadata flag if there are files with potential metadata
if metadataCount > 0 {
@@ -50,9 +61,6 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
// Create a single shared FFmpegService instance for all files to coordinate concurrency
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService != nil {
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
for i := range response.Files {
fileItem := &response.Files[i]
@@ -86,10 +94,10 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
}
}
// Wait for all goroutines to complete
wg.Wait()
}
}
// Wait for all goroutines to complete
wg.Wait()
}
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
@@ -108,35 +116,35 @@ func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileIn
}
}
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
func CheckPermissions(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
if access == nil {
return response, fmt.Errorf("access not provided")
return "", "", fmt.Errorf("access not provided")
}
if user == nil {
return response, fmt.Errorf("user not provided")
return "", "", fmt.Errorf("user not provided")
}
if opts.Path == "" {
return response, fmt.Errorf("path not provided")
return "", "", fmt.Errorf("path not provided")
}
if opts.Source == "" {
return response, fmt.Errorf("source not provided")
return "", "", fmt.Errorf("source not provided")
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
return "", "", fmt.Errorf("could not get index: %v ", opts.Source)
}
// Resolve user scope
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
if scopeErr != nil || userScope == "" {
return response, fmt.Errorf("user has no access to source: %v", opts.Source)
return "", "", fmt.Errorf("user has no access to source: %v", opts.Source)
}
safePath, err := utils.SanitizeUserPath(opts.Path)
if err != nil {
return response, errors.ErrAccessDenied
return "", "", errors.ErrAccessDenied
}
// Combine scope + sanitized path
@@ -144,17 +152,76 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.
// Layer 1: USER ACCESS CONTROL
// Quick check: Does THIS user have permission?
if !access.Permitted(idx.Path, indexPath, user.Username) {
return response, errors.ErrAccessDenied
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)
}
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
}
func FileInfoFaster(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,
IsRoutineScan: false, // API call
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
Expand: opts.Expand,
SkipExtendedAttrs: opts.SkipExtendedAttrs,
})
if err != nil {
return response, err // Path excluded by index rules OR doesn't exist
@@ -168,25 +235,35 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.
// Layer 3: FILTER CHILDREN (user access)
// Remove child items THIS user can't access
if info.Type == "directory" {
if err := access.CheckChildItemAccess(response, idx, user.Username); err != nil {
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
return response, err
}
}
defer finalizeResponse(response, info, response.RealPath, user, userScope)
if opts.SkipExtendedAttrs {
return response, nil
}
if share != nil && user.Permissions.Share {
for i := range response.Files {
file := &response.Files[i]
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path)
}
for i := range response.Folders {
folder := &response.Folders[i]
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path)
}
response.IsShared = share.IsShared(response.Path, idx.Path)
}
// 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
}
@@ -211,17 +288,16 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
}
// Handle subtitles if requested
if opts.ExtractEmbeddedSubtitles {
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
if exists {
info.DetectSubtitles(parentInfo)
err := info.LoadSubtitleContent()
if err != nil {
logger.Debug("failed to load subtitle content: " + err.Error())
}
}
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
if exists {
info.GetSubtitles(parentInfo)
}
if opts.ExtractEmbeddedSubtitles {
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
info.Subtitles = append(info.Subtitles, subtitles...)
}
return
}
@@ -236,7 +312,8 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
} else {
// Copy metadata to ExtendedFileInfo
info.Metadata = extItem.Metadata
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
info.HasMetadata = true
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
}
return
}
@@ -323,11 +400,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
// Extract album art and encode as base64 with strict size limits
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
if picture := m.Picture(); picture != nil && picture.Data != nil {
// More aggressive size limit to prevent memory issues (max 5MB)
// Size limit to prevent memory issues (max 5MB)
if len(picture.Data) <= 5*1024*1024 {
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
item.Metadata.AlbumArt = picture.Data
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
}
@@ -29,3 +29,16 @@ 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,3 +33,18 @@ 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
}
+48 -17
View File
@@ -1,34 +1,65 @@
package auth
import (
"fmt"
"net/http"
"sync"
"time"
"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)
}
func IsRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
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 RevokeAPIKey(key string) {
revokeMu.Lock()
defer revokeMu.Unlock()
revokedApiKeyList[key] = struct{}{}
// 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
}
+134
View File
@@ -0,0 +1,134 @@
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
}
+221
View File
@@ -0,0 +1,221 @@
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)
}
}
+1 -1
View File
@@ -189,7 +189,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
logger.Warningf("Invalid TOTP code for user %s", user.Username)
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" {
if totpSecret != "" || !user.OtpEnabled {
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
user.OtpEnabled = true // Enable OTP for the user
+14 -3
View File
@@ -19,6 +19,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/icons"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -181,7 +182,7 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
cacheDir := settings.Config.Server.CacheDir
numWorkers := settings.Config.Server.NumImageProcessors
ffmpeg.SetFFmpegPaths()
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
embeddedAssets := fbhttp.GetEmbeddedAssets()
@@ -193,14 +194,24 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
} else {
fileutils.InitAssetFS(nil, false)
}
// setup disk cache
// Start preview service
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
logger.Fatalf("Error starting preview service: %v", err)
}
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
}
+23 -4
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"reflect"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -40,6 +41,9 @@ 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
@@ -119,7 +123,7 @@ func updateShowFirstLogin(user *users.User) bool {
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
if user.Version == 1 {
if user.Version >= 1 {
return false
}
updateUser := true
@@ -165,7 +169,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = 1
user.Version = 2
if updateUser {
createBackup = true
}
@@ -197,7 +201,7 @@ func updateSidebarLinks(user *users.User) bool {
validSourceLinksCount := 0
for _, link := range user.SidebarLinks {
if link.Category == "source" {
if strings.HasPrefix(link.Category, "source") {
sourceLinksCount++
// Check if this source still exists
if link.SourceName != "" {
@@ -220,7 +224,7 @@ func updateSidebarLinks(user *users.User) bool {
// Remove all existing source links
newLinks := []users.SidebarLink{}
for _, link := range user.SidebarLinks {
if link.Category != "source" {
if !strings.HasPrefix(link.Category, "source") {
newLinks = append(newLinks, link)
}
}
@@ -246,3 +250,18 @@ 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
}
+180 -17
View File
@@ -6,8 +6,11 @@ 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"
)
@@ -29,6 +32,18 @@ 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"`
@@ -44,6 +59,8 @@ 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 {
@@ -55,10 +72,8 @@ type PasswordAuthConfig struct {
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"` // create user if not exists
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
AuthCommon `json:",inline"`
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
}
type Recaptcha struct {
@@ -69,19 +84,65 @@ type Recaptcha struct {
// OpenID OAuth2.0
type OidcConfig struct {
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
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
}
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
@@ -133,3 +194,105 @@ 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
}
+221
View File
@@ -0,0 +1,221 @@
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)
}
})
}
+43 -30
View File
@@ -6,6 +6,7 @@ import (
"io"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
@@ -55,7 +56,7 @@ func Initialize(configFile string) {
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
setupUrls()
setupFrontend(false)
setupMedia()
setupMedia(false)
}
func setupServer() {
@@ -278,11 +279,9 @@ func setupFrontend(generate bool) {
}
// Load custom favicon if configured
loadCustomFavicon()
// Generate PWA icons from custom favicon if needed
generatePWAIcons()
}
func setupMedia() {
func setupMedia(generate bool) {
// Save user's explicit config before applying defaults
userImageConfig := make(map[ImagePreviewType]*bool)
for k, v := range Config.Integrations.Media.Convert.ImagePreview {
@@ -316,6 +315,19 @@ func setupMedia() {
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) {
@@ -338,11 +350,7 @@ func setupSources(generate bool) {
if name == "\\" {
name = strings.Split(realPath, ":")[0]
}
if generate {
source.Path = generatorPath // use placeholder path
} else {
source.Path = realPath // use absolute path
}
source.Path = realPath // use absolute path
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
if ok {
@@ -351,6 +359,10 @@ 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 == "" {
@@ -422,20 +434,34 @@ 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 {
@@ -624,6 +650,11 @@ 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 {
@@ -699,7 +730,6 @@ func setDefaults(generate bool) Settings {
Download: boolPtr(true), // defaults to true
},
Preview: UserDefaultsPreview{
HighQuality: boolPtr(true),
Image: boolPtr(true),
Video: boolPtr(true),
MotionVideoPreview: boolPtr(true),
@@ -788,23 +818,6 @@ func loadCustomFavicon() {
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
}
// generatePWAIcons sets up PWA icon paths (actual generation happens after preview service starts)
func generatePWAIcons() {
// Set PWA icons directory in cache
Env.PWAIconsDir = filepath.Join(Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
Env.PWAIcon192 = "icons/pwa-icon-192.png"
Env.PWAIcon256 = "icons/pwa-icon-256.png"
Env.PWAIcon512 = "icons/pwa-icon-512.png"
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", Env.PWAIconsDir, err)
}
}
func loadLoginIcon() {
const imageName = "login icon"
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
+219 -68
View File
@@ -14,6 +14,7 @@ import (
"sort"
"strings"
"golang.org/x/tools/go/packages"
"gopkg.in/yaml.v3"
)
@@ -111,74 +112,93 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
return comments, secrets, deprecated, nil
}
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
absDir, err := filepath.Abs(dir)
if err != nil {
return nil, nil, nil, err
}
var files []*ast.File
cfg := &packages.Config{
Mode: packages.NeedSyntax | packages.NeedFiles,
Dir: absDir,
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
return parser.ParseFile(fset, filename, src, parser.ParseComments)
},
}
pkgs, err := packages.Load(cfg, ".")
if err == nil && len(pkgs) > 0 {
for _, pkg := range pkgs {
files = append(files, pkg.Syntax...)
}
}
if len(files) == 0 {
// Fallback for directories outside a module (e.g. test temp dirs): parse .go files directly
files, err = parseDirGoFiles(absDir)
if err != nil || len(files) == 0 {
return nil, nil, nil, err
}
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
for _, file := range files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
name := field.Names[0].Name
var parts []string
var fullComment string
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
}
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
}
}
}
@@ -187,6 +207,29 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
return comments, secrets, deprecated, nil
}
// parseDirGoFiles parses all .go files in dir and returns their ASTs. Used when the directory
// is not inside a Go module (e.g. test temp dirs) so packages.Load would fail.
func parseDirGoFiles(dir string) ([]*ast.File, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
var files []*ast.File
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
continue
}
path := filepath.Join(dir, e.Name())
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
files = append(files, f)
}
return files, nil
}
// PathToTypeField represents a mapping from YAML path to Go type and field info
type PathToTypeField struct {
TypeName string
@@ -518,6 +561,130 @@ 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)
@@ -532,8 +699,6 @@ 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]
@@ -712,25 +877,11 @@ 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()
setupMedia(true)
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
-1
View File
@@ -88,7 +88,6 @@ 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.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
+4 -2
View File
@@ -65,7 +65,7 @@ type Server struct {
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,6 +102,7 @@ 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 {
@@ -272,6 +273,7 @@ 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
}
@@ -301,7 +303,7 @@ 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"` // use high quality thumbnails
HighQuality *bool `json:"highQuality"` // deprecated: always true in v1.3.0+
Image *bool `json:"image"` // show thumbnails for image files
Video *bool `json:"video"` // show thumbnails for video files
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
+18 -10
View File
@@ -52,23 +52,31 @@ 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
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
}
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
// Rule 1: Do Not Use User Input in File Paths (without validation)
func SanitizeUserPath(userPath string) (string, error) {
// Clean resolves . and .. segments
clean := filepath.Clean(userPath)
// Reject absolute paths or attempts to escape scope
if strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("invalid path")
// Split the path into segments to check for path traversal attempts
// We check if ".." appears as a complete path segment (not just in a filename)
segments := strings.Split(clean, string(filepath.Separator))
for _, segment := range segments {
// Check if any segment is exactly ".." (path traversal attempt)
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
if segment == ".." {
return "", fmt.Errorf("invalid path: path traversal detected")
}
}
return clean, nil
+43
View File
@@ -0,0 +1,43 @@
package utils
import (
"errors"
"os"
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
}
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
// validate the size is not too large
info, err := os.Stat(subtitlePath)
if err != nil {
return "", err
}
if info.Size() > 1024*1024*50 { // 50MB
return "", errors.New("subtitle file is too large")
}
// First check if it's a text file using the consolidated validation logic
isText, err := IsTextFile(subtitlePath)
if err != nil {
return "", err
}
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
// read the file content
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
-1
View File
@@ -8,4 +8,3 @@ userDefaults:
image: true
popup: true
video: true
highQuality: true
-1
View File
@@ -25,7 +25,6 @@ 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)
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
+96 -13
View File
@@ -37,8 +37,10 @@ type SourceRuleMap map[string]RuleMap
type StringSet map[string]struct{}
type dbStorage struct {
AllRules SourceRuleMap `json:"all_rules"`
Groups GroupMap `json:"groups"`
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
}
// RuleSet groups users and groups for allow/deny lists.
@@ -72,21 +74,26 @@ type GroupMap map[string]StringSet
// Storage manages access rules and group membership.
type Storage struct {
mux sync.RWMutex
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
DB *storm.DB // Optional: DB for persistence
Users *users.Storage // Reference to users storage
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
}
// 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,
AllRules: s.AllRules,
Groups: s.Groups,
RevokedTokens: s.RevokedTokens,
HashedTokens: s.HashedTokens,
})
if err != nil {
return err
@@ -97,6 +104,8 @@ 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()
}
@@ -123,6 +132,18 @@ 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
}
@@ -136,10 +157,12 @@ 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),
DB: db,
Users: usersStore,
AllRules: make(SourceRuleMap),
Groups: make(GroupMap),
RevokedTokens: make(map[string]struct{}),
HashedTokens: make(map[string]uint),
DB: db,
Users: usersStore,
}
return s
}
@@ -1236,3 +1259,63 @@ 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()
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, index *indexing.Index, username string) error {
func (s *Storage) CheckChildItemAccess(response *iteminfo.FileInfo, index *indexing.Index, username string) error {
// Collect all item names to check
allItemNames := make([]string, 0, len(response.Folders)+len(response.Files))
+2 -2
View File
@@ -43,8 +43,8 @@ type CommonShare struct {
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HasPassword bool `json:"hasPassword,omitempty"`
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
DisableLoginOption bool `json:"disableLoginOption,omitempty"` // disable login option in share (true = hide, false = show)
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)
}
type CreateBody struct {
CommonShare
+183 -142
View File
@@ -56,86 +56,179 @@ func (c *crudBackend) DeleteByID(id any) error {
// Storage is a share storage using generics.
type Storage struct {
Generic *crud.Storage[Link]
back StorageBackend
shareCache map[string]*Link
mu sync.RWMutex
users *users.Storage
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
}
// NewStorage creates a share links storage from a backend.
// 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.
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
return &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareCache: make(map[string]*Link),
users: usersStore,
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,
}
s.loadShareCache()
return s
}
// All wraps StorageBackend.All and handles expiry.
func (s *Storage) All() ([]*Link, error) {
// 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 {
return nil, err
if err != nil && err != errors.ErrNotExist {
return
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
if links == nil {
links = []*Link{}
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
for _, link := range links {
if link == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
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()
return filtered, nil
}
// 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
}
// 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).
func (s *Storage) All() ([]*Link, error) {
s.mu.Lock()
for i, l := range filtered {
result := make([]*Link, 0, len(s.shareByHash))
for _, l := range s.shareByHash {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
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)
}
s.mu.Unlock()
return filtered, nil
return result, nil
}
// FindByUserID returns non-expired shares for the user from the cache.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
s.mu.Lock()
result := make([]*Link, 0)
for _, l := range s.shareByHash {
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)
}
s.mu.Unlock()
return result, 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.shareCache[hash]; ok && link != nil {
if link, ok := s.shareByHash[hash]; ok && link != nil {
s.mu.RUnlock()
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
s.deleteFromCache(hash)
return nil, errors.ErrNotExist
}
return link, nil
@@ -154,9 +247,7 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
// Initialize UserDownloads map
link.InitUserDownloads()
s.mu.Lock()
s.shareCache[hash] = link
s.mu.Unlock()
s.setCache(link)
return link, nil
}
@@ -164,68 +255,69 @@ 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.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
s.setCache(l)
}
return l, err
}
// Gets wraps StorageBackend.Gets and handles expiry.
// Gets returns shares for the given path, source, and user from the cache.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
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 existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
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)
}
s.mu.Unlock()
return filtered, nil
return result, nil
}
// GetBySourcePath wraps StorageBackend.GetBySourcePath and handles expiry.
// GetBySourcePath returns shares for the given path and source from the cache.
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 {
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]
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
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)
}
s.mu.Unlock()
return filtered, nil
return result, nil
}
// IsShared returns whether the given path and source have any shares in the cache.
func (s *Storage) IsShared(path, source string) bool {
links, _ := s.GetBySourcePath(path, source)
return len(links) > 0
}
// 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.back.All()
links, err := s.All()
if err != nil && err != errors.ErrNotExist {
logger.Error("failed to list shares", "error", err)
return 0, err
@@ -247,7 +339,6 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = newPath
@@ -256,22 +347,9 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
return updated, err
}
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()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
s.setCacheAfterMove(l, oldSource, oldPath)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
@@ -290,11 +368,7 @@ func (s *Storage) UpdateSharePath(hash, newPath string) error {
return err
}
s.mu.Lock()
if existing, ok := s.shareCache[hash]; ok && existing != nil {
existing.Path = newPath
}
s.mu.Unlock()
s.setCacheAfterMove(link, link.Source, oldPath)
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
return nil
@@ -305,9 +379,7 @@ func (s *Storage) Save(l *Link) error {
if err := s.back.Save(l); err != nil {
return err
}
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
s.setCache(l)
return nil
}
@@ -316,47 +388,16 @@ func (s *Storage) Delete(hash string) error {
if err := s.back.Delete(hash); err != nil {
return err
}
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
s.deleteFromCache(hash)
return nil
}
// Flush persists the current in-memory state of all shares to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
// 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.
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)
+1 -1
View File
@@ -541,7 +541,7 @@ func (db *IndexDB) ExplainQueryPlan(query string, args ...interface{}) (string,
if err := rows.Scan(&id, &parent, &notused, &detail); err != nil {
return "", err
}
plan.WriteString(fmt.Sprintf("%d|%d|%s\n", id, parent, detail))
fmt.Fprintf(&plan, "%d|%d|%s\n", id, parent, detail)
}
return plan.String(), rows.Err()
}
+2 -9
View File
@@ -9,7 +9,6 @@ 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"
@@ -77,7 +76,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !user.OtpEnabled {
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
return errors.ErrNoTotpConfigured
}
if user.LoginMethod == "" {
@@ -158,12 +157,6 @@ 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
}
@@ -257,7 +250,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", "apikeys", "totpsecret", "totpnonce":
case "id", "username", "password", "tokens", "totpsecret", "totpnonce":
// Skip these fields
continue
}
+2
View File
@@ -36,6 +36,8 @@ 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 {
+2 -2
View File
@@ -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 link.Category == "source" {
if strings.HasPrefix(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 link.Category == "source" {
if strings.HasPrefix(link.Category, "source") {
if link.SourceName == "" {
continue
}
+15 -14
View File
@@ -26,8 +26,8 @@ type Store interface {
Save(user *User, changePass bool, disableScopeChange bool) error
Delete(id interface{}) error
LastUpdate(id uint) int64
AddApiKey(username uint, name string, key AuthToken) error
DeleteApiKey(username uint, name string) error
AddApiToken(userID uint, name string, tokenString string, metadata AuthToken) error
DeleteApiToken(userID uint, name string) error
}
// crudBackend implements crud.CrudBackend[User] for users storage.
@@ -114,17 +114,18 @@ func (s *Storage) Update(user *User, adminIActor bool, fields ...string) error {
return nil
}
func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
func (s *Storage) AddApiToken(userID uint, name string, tokenString string, metadata AuthToken) error {
user, err := s.Get(userID)
if err != nil {
return err
}
// Initialize the ApiKeys map if it is nil
if user.ApiKeys == nil {
user.ApiKeys = make(map[string]AuthToken)
// Initialize the TokenHashes map if it is nil
if user.Tokens == nil {
user.Tokens = make(map[string]AuthToken)
}
user.ApiKeys[name] = key
err = s.Update(user, true, "ApiKeys")
metadata.Token = tokenString
user.Tokens[name] = metadata
err = s.Update(user, true, "Tokens")
if err != nil {
return err
}
@@ -132,17 +133,17 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
return nil
}
func (s *Storage) DeleteApiKey(userID uint, name string) error {
func (s *Storage) DeleteApiToken(userID uint, name string) error {
user, err := s.Get(userID)
if err != nil {
return err
}
// Initialize the ApiKeys map if it is nil
if user.ApiKeys == nil {
user.ApiKeys = make(map[string]AuthToken)
// Initialize the Tokens map if it is nil
if user.Tokens == nil {
user.Tokens = make(map[string]AuthToken)
}
delete(user.ApiKeys, name)
err = s.Update(user, true, "ApiKeys")
delete(user.Tokens, name)
err = s.Update(user, true, "Tokens")
if err != nil {
return err
}
+12 -7
View File
@@ -15,13 +15,18 @@ 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"`
Key string `json:"key,omitempty"` // for backward compatibility
Token string `json:"token,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"`
}
@@ -49,7 +54,6 @@ 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
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
@@ -70,15 +74,16 @@ type User struct {
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
Tokens map[string]AuthToken `json:"tokens,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"`
Version int `json:"version"`
ShowFirstLogin bool `json:"showFirstLogin"`
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
}
type SourceScope struct {
@@ -129,7 +134,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", "share", "tool", "custom", etc.
Category string `json:"category"` // Category type: "source", "source-link", "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
+4 -3
View File
@@ -3,11 +3,12 @@ package ffmpeg
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-cache/cache"
)
var (
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
MediaCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
)
+6 -2
View File
@@ -11,12 +11,16 @@ 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) {
// Use exiftool to get orientation information
cmd := exec.Command("exiftool", "-Orientation", "-s3", imagePath)
exiftoolPath := settings.Config.Integrations.Media.ExiftoolPath
if exiftoolPath == "" {
return "Horizontal (normal)", nil
}
cmd := exec.Command(exiftoolPath, "-Orientation", "-s3", imagePath)
var out bytes.Buffer
var errOut bytes.Buffer
cmd.Stdout = &out
+33 -136
View File
@@ -10,20 +10,10 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// SubtitleTrack represents a subtitle track (embedded or external file)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language,omitempty"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Content string `json:"content,omitempty"` // subtitle content
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
}
// FFProbeOutput represents the JSON output from ffprobe
type FFProbeOutput struct {
Streams []struct {
@@ -36,7 +26,7 @@ type FFProbeOutput struct {
}
// DetectAllSubtitles finds both embedded and external subtitle tracks
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []SubtitleTrack {
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []utils.SubtitleTrack {
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
// Check cache first
@@ -44,7 +34,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
return cached
}
var allSubtitles []SubtitleTrack
var allSubtitles []utils.SubtitleTrack
// First, get embedded subtitles
embeddedSubs := detectEmbeddedSubtitles(videoPath)
@@ -60,9 +50,25 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
return allSubtitles
}
// DetectEmbeddedSubtitles detects embedded subtitle streams using ffprobe.
// This is the public API that can be called from other packages.
// Returns empty array if ffprobe is not available or fails.
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
// Check cache first
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
if cached, ok := MediaCache.Get(key); ok {
return cached
}
// Detect embedded subtitles
subtitles := detectEmbeddedSubtitles(videoPath)
// Cache the results
MediaCache.Set(key, subtitles)
return subtitles
}
// detectEmbeddedSubtitles uses ffprobe to find embedded subtitle tracks
// Always runs ffprobe - results are cached for performance
func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-print_format", "json",
@@ -82,13 +88,13 @@ func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
return nil
}
var subtitles []SubtitleTrack
var subtitles []utils.SubtitleTrack
for _, stream := range probeOutput.Streams {
if stream.CodecType == "subtitle" {
track := SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
IsFile: false, // This is an embedded subtitle
track := utils.SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
Embedded: true, // This is an embedded subtitle
}
// Extract language and title from tags
@@ -117,8 +123,8 @@ func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
}
// detectExternalSubtitles finds external subtitle files in the same directory
func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack {
var subtitles []SubtitleTrack
func detectExternalSubtitles(videoPath string, parentDir string) []utils.SubtitleTrack {
var subtitles []utils.SubtitleTrack
// Get the base name of the video (without extension)
videoBaseName := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
@@ -131,9 +137,9 @@ func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack
subtitlePath := filepath.Join(parentDir, videoBaseName+ext)
if _, err := os.Stat(subtitlePath); err == nil {
// File exists
track := SubtitleTrack{
Name: filepath.Base(subtitlePath),
IsFile: true, // This is an external file
track := utils.SubtitleTrack{
Name: filepath.Base(subtitlePath),
Embedded: false, // This is an external file
}
// Try to infer language from filename patterns like "video.en.srt"
@@ -170,120 +176,11 @@ func ExtractSubtitleContent(videoPath string, streamIndex int) (string, error) {
return string(output), nil
}
// LoadAndConvertSubtitleFile loads a subtitle file and converts it to WebVTT format
func LoadAndConvertSubtitleFile(subtitlePath string) (string, error) {
ext := strings.ToLower(filepath.Ext(subtitlePath))
if ext == ".vtt" {
// Already WebVTT, just read it
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
if ext == ".srt" {
// Convert SRT to WebVTT using ffmpeg
cmd := exec.Command("ffmpeg",
"-i", subtitlePath,
"-c:s", "webvtt",
"-f", "webvtt",
"-") // output to stdout
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to convert SRT to WebVTT: %v", err)
}
return string(output), nil
}
// For other formats, try to read as plain text and hope for the best
// This is a fallback - ideally you'd want format-specific converters
// LoadSubtitleFile loads a subtitle file and returns its raw content
func LoadSubtitleFile(subtitlePath string) (string, error) {
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
// Basic conversion wrapper for non-VTT formats
vttHeader := "WEBVTT\n\n"
return vttHeader + string(content), nil
}
// ExtractSingleSubtitle extracts content for a specific subtitle track by array index
func ExtractSingleSubtitle(videoPath string, parentDir string, trackIndex int, modtime time.Time) (SubtitleTrack, error) {
// Get all subtitle tracks
allTracks := DetectAllSubtitles(videoPath, parentDir, modtime)
if trackIndex >= len(allTracks) {
return SubtitleTrack{}, fmt.Errorf("subtitle track %d not found (only %d tracks available)", trackIndex, len(allTracks))
}
track := allTracks[trackIndex]
// Load content based on type
if track.IsFile {
// Load external subtitle file
subtitlePath := filepath.Join(parentDir, track.Name)
content, err := LoadAndConvertSubtitleFile(subtitlePath)
if err != nil {
return SubtitleTrack{}, fmt.Errorf("failed to load external subtitle: %v", err)
}
track.Content = content
} else {
// Extract embedded subtitle content
if track.Index == nil {
return SubtitleTrack{}, fmt.Errorf("embedded subtitle track has no stream index")
}
content, err := ExtractSubtitleContent(videoPath, *track.Index)
if err != nil {
return SubtitleTrack{}, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
track.Content = content
}
return track, nil
}
// LoadAllSubtitleContent loads the actual content for all detected subtitle tracks
func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime time.Time) error {
for idx := range subtitles {
subtitle := &subtitles[idx]
// Check if content is already cached
contentKey := fmt.Sprintf("subtitle_content:%s:%d:%s", videoPath, idx, modtime.Format(time.RFC3339))
if cached, ok := SubtitleContentCache.Get(contentKey); ok {
subtitle.Content = cached
continue
}
var content string
var err error
if subtitle.IsFile {
// Load external subtitle file content and convert to WebVTT
subtitlePath := filepath.Join(filepath.Dir(videoPath), subtitle.Name)
content, err = LoadAndConvertSubtitleFile(subtitlePath)
if err != nil {
logger.Debug("failed to read/convert subtitle file " + subtitlePath + ": " + err.Error())
continue
}
} else {
// Load embedded subtitle content
if subtitle.Index == nil {
logger.Debug("embedded subtitle has no stream index")
continue
}
content, err = ExtractSubtitleContent(videoPath, *subtitle.Index)
if err != nil {
logger.Debug("failed to extract embedded subtitle: " + err.Error())
continue
}
}
subtitle.Content = content
// Cache the content for future requests
SubtitleContentCache.Set(contentKey, content)
}
return nil
return string(content), nil
}
+37 -34
View File
@@ -9,6 +9,7 @@ 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.12
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
@@ -16,21 +17,22 @@ require (
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.0.2
github.com/kovidgoyal/imaging v1.8.19
github.com/mattn/go-sqlite3 v1.14.33
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.47.0
golang.org/x/image v0.35.0
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.40.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.50.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.44.3
modernc.org/sqlite v1.45.0
)
require (
@@ -48,20 +50,22 @@ 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
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.64.2 // indirect
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
github.com/air-verse/air v1.64.5 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.0.1 // indirect
github.com/alexkohler/prealloc v1.0.2 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -71,7 +75,7 @@ require (
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.3.0 // indirect
github.com/bombsimon/wsl/v5 v5.6.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
@@ -83,7 +87,7 @@ require (
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
@@ -97,7 +101,7 @@ require (
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ebitengine/purego v0.10.0-alpha.4 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -105,7 +109,8 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.18 // 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
@@ -128,10 +133,10 @@ require (
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.1 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20260129164528-943061e2742c // indirect
@@ -140,9 +145,9 @@ require (
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.8.0 // indirect
github.com/golangci/golines v0.14.0 // indirect
github.com/golangci/misspell v0.7.0 // indirect
github.com/golangci/golangci-lint/v2 v2.10.1 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
@@ -164,7 +169,7 @@ require (
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.5.1 // indirect
github.com/jupiterrider/ffi v0.6.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
@@ -194,7 +199,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.13.0 // indirect
github.com/mgechev/revive v1.14.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
@@ -203,7 +208,7 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.21.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -228,8 +233,8 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.22.11 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/securego/gosec/v2 v2.23.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.4.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
@@ -262,23 +267,21 @@ require (
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.11.1 // indirect
go.augendre.info/arangolint v0.3.1 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.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.6.1 // indirect
modernc.org/libc v1.67.7 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.68.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+100 -83
View File
@@ -59,6 +59,8 @@ 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=
@@ -73,18 +75,18 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ=
github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.64.2 h1:H49xKRa4r3uFUycmCCA+JectVuZLNMPGdypLFpoHVcY=
github.com/air-verse/air v1.64.2/go.mod h1:SOwv08NTvKextanAY819AK0FK6XzQO5/tC6s1R+rk8o=
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
@@ -94,16 +96,20 @@ 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.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE=
github.com/alexkohler/prealloc v1.0.1/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
github.com/alexkohler/prealloc v1.0.2/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
@@ -154,8 +160,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM=
github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -182,8 +188,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
@@ -242,8 +248,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.0-alpha.4 h1:JzPbdf+cqbyT9sZtP4xnqelwUXwf7LvD8xKS6+ofTds=
github.com/ebitengine/purego v0.10.0-alpha.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -273,8 +279,10 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
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.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo=
github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
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=
@@ -291,6 +299,8 @@ 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.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
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=
@@ -355,8 +365,8 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@@ -365,8 +375,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA=
github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8=
github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM=
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -432,12 +442,12 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint/v2 v2.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA=
github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI=
github.com/golangci/golines v0.14.0 h1:xt9d3RKBjhasA3qpoXs99J2xN2t6eBlpLHt0TrgyyXc=
github.com/golangci/golines v0.14.0/go.mod h1:gf555vPG2Ia7mmy2mzmhVQbVjuK8Orw0maR1G4vVAAQ=
github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
@@ -471,8 +481,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -518,6 +528,18 @@ 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=
@@ -543,8 +565,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
@@ -622,12 +644,12 @@ 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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/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.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM=
github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0=
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -659,8 +681,8 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po=
github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY=
github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8=
github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@@ -671,10 +693,10 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
@@ -698,8 +720,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
@@ -759,8 +779,8 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg=
github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw=
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
@@ -769,8 +789,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
@@ -802,7 +822,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
@@ -853,6 +872,8 @@ github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pH
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
@@ -878,8 +899,8 @@ go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
go.augendre.info/arangolint v0.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo=
go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g=
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
@@ -890,8 +911,6 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
@@ -909,8 +928,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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
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=
@@ -921,16 +940,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
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.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
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=
@@ -956,8 +975,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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
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=
@@ -1000,16 +1019,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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
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.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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=
@@ -1071,7 +1090,6 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1080,8 +1098,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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=
@@ -1098,8 +1116,8 @@ 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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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=
@@ -1155,8 +1173,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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
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=
@@ -1262,7 +1280,6 @@ gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1272,22 +1289,22 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
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.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
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.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
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=
@@ -1296,8 +1313,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.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
modernc.org/sqlite v1.45.0/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=
+111 -161
View File
@@ -3,90 +3,52 @@ 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-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
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
// 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
// @Accept json
// @Produce json
// @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"
// @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"
// @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 [put]
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// @Router /api/auth/token [post]
func createApiTokenHandler(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 := r.URL.Query().Get("minimal") == "true"
minimal := permissionsStr == ""
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api keys")
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api tokens")
}
if name == "" {
return http.StatusBadRequest, fmt.Errorf("api name must be valid")
if name == "" || strings.HasPrefix(name, "WEB_TOKEN") {
return http.StatusBadRequest, fmt.Errorf("api token name must be valid")
}
if durationStr == "" {
return http.StatusBadRequest, fmt.Errorf("api duration must be valid")
return http.StatusBadRequest, fmt.Errorf("api token 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,
@@ -108,156 +70,144 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Here we assume the duration is in seconds; convert to time.Duration
duration := time.Duration(durationInt) * time.Hour * 24
// get request body like:
token, err := makeSignedTokenAPI(d.user, name, duration, permissions, minimal)
tokenString, authToken, err := auth.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: token.Key,
Token: tokenString,
}
return renderJSON(w, r, response)
}
// deleteApiKeyHandler deletes an API key for the user.
// @Summary Delete API key
// @Description Delete an API key with specified name.
// @Tags API Keys
// deleteApiTokenHandler deletes an API token for the user.
// @Summary Delete API token
// @Description Delete an API token with specified name.
// @Tags Auth
// @Accept json
// @Produce json
// @Param name query string true "Name of the API key to delete"
// @Success 200 {object} HttpResponse "API key deleted successfully"
// @Param name query string true "Name of the API token to delete"
// @Success 200 {object} HttpResponse "API token 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 deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
func deleteApiTokenHandler(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 keys")
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api tokens")
}
keyInfo, ok := d.user.ApiKeys[name]
tokenInfo, ok := d.user.Tokens[name]
if !ok {
return http.StatusNotFound, fmt.Errorf("api key not found")
return http.StatusNotFound, fmt.Errorf("api token not found")
}
// Perform the user update
err := store.Users.DeleteApiKey(d.user.ID, name)
err := store.Users.DeleteApiToken(d.user.ID, name)
if err != nil {
return http.StatusNotFound, err
}
auth.RevokeAPIKey(keyInfo.Key) // add to blacklist
// 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)
}
response := HttpResponse{
Message: "successfully deleted api key from user",
Message: "successfully deleted api token from user",
}
return renderJSON(w, r, response)
}
type AuthTokenMin struct {
Key string `json:"key"`
type AuthTokenFrontend struct {
Token string `json:"token"`
Name string `json:"name"`
Created int64 `json:"created"`
Expires int64 `json:"expires"`
IssuedAt int64 `json:"issuedAt"`
ExpiresAt int64 `json:"expiresAt"`
Permissions users.Permissions `json:"Permissions,omitempty"`
Minimal bool `json:"minimal"`
}
// 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
// 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
// @Accept json
// @Produce json
// @Param name query string false "Name of the API to retrieve details"
// @Success 200 {object} AuthTokenMin "List of API keys or specific key details"
// @Success 200 {array} AuthTokenFrontend "List of API tokens"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/tokens [get]
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
key := r.URL.Query().Get("name")
// @Router /api/auth/token/list [get]
func listApiTokensHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api keys")
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api tokens")
}
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)
if len(d.user.Tokens) == 0 {
return http.StatusNotFound, fmt.Errorf("no api tokens found")
}
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
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, modifiedList)
return renderJSON(w, r, AuthTokensFrontend)
}
// 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")
}
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)
}
+862
View File
@@ -0,0 +1,862 @@
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.
// @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 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")
}
estimatedSize, err := computeArchiveSize(req.FromSource, req.Paths, d)
if err != nil {
return http.StatusInternalServerError, err
}
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)
}
}
var createErr error
if format == "zip" {
createErr = createZip(d, req.FromSource, destRealPath, itemPaths...)
} else {
createErr = createTarGzWithLevel(d, req.FromSource, destRealPath, 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.
// @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 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, fmt.Errorf("invalid path: %v", 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)
}
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 d.share == nil && store.Access != nil {
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
return nil // Silently skip this file/folder
}
}
// Verify file exists
_, err := files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
FollowSymlinks: true,
}, store.Access, d.user, store.Share)
if err != nil {
return err
}
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
}
// 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)
}
var userScope string
var err error
if d.share == nil {
userScope, err = d.user.GetScopeForSourceName(source)
if err != nil {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
}
for _, path := range fileList {
var fullPath string
if d.share == nil {
fullPath = utils.JoinPathAsUnix(userScope, path)
if store.Access != nil && !store.Access.Permitted(idx.Path, fullPath, d.user.Username) {
continue
}
} else {
fullPath = path
}
realPath, isDir, err := idx.GetRealPath(fullPath)
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
}
// createZip writes a ZIP archive to tmpPath containing the given paths; access rules apply.
func createZip(d *requestContext, source string, tmpPath string, filenames ...string) error {
file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
for _, fname := range filenames {
err := addFile(source, fname, d, nil, zipWriter, false)
if err != nil {
logger.Errorf("Failed to add %s to ZIP: %v", fname, 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 to tmpPath containing the given paths; access rules apply.
func createTarGz(d *requestContext, source string, tmpPath string, filenames ...string) error {
file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
defer file.Close()
gzWriter := gzip.NewWriter(file)
tarWriter := tar.NewWriter(gzWriter)
for _, fname := range filenames {
err := addFile(source, fname, d, tarWriter, nil, false)
if err != nil {
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, 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 with the given gzip compression level (0=default, 1-9).
func createTarGzWithLevel(d *requestContext, source string, destPath string, level int, filenames ...string) error {
file, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
defer file.Close()
var gzWriter *gzip.Writer
if level >= 1 && level <= 9 {
gzWriter, err = gzip.NewWriterLevel(file, level)
if err != nil {
return err
}
} else {
gzWriter = gzip.NewWriter(file)
}
defer gzWriter.Close()
tarWriter := tar.NewWriter(gzWriter)
defer tarWriter.Close()
for _, fname := range filenames {
err := addFile(source, fname, d, tarWriter, nil, false)
if err != nil {
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, 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 and max archive size. 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) {
firstFilePath := fileList[0]
var userscope string
var err error
if d.share == nil {
userscope, err = d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
}
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
realPath, isDir, err := idx.GetRealPath(firstFilePath)
if err != nil {
return http.StatusInternalServerError, err
}
estimatedSize, err := computeArchiveSize(source, fileList, d)
if err != nil {
return http.StatusInternalServerError, err
}
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)
}
}
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)
}
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
}
fd, err := os.Open(archiveData)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
fileInfo, err := fd.Stat()
if err != nil {
os.Remove(archiveData)
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)
}
setContentDisposition(w, r, originalFileName)
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
w.Header().Set("Content-Type", "application/octet-stream")
var reader io.Reader = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
limit := rate.Limit(d.share.MaxBandwidth * 1024)
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
_, err = io.Copy(w, reader)
os.Remove(archiveData)
if err != nil {
logger.Errorf("Failed to copy archive data to response: %v", err)
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
}
+129 -112
View File
@@ -8,11 +8,9 @@ import (
libError "errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
@@ -88,25 +86,22 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
if err.Error() != "the resource does not exist" {
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")
// 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 data.user.LoginMethod != users.LoginMethodProxy {
@@ -122,6 +117,99 @@ 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.).
@@ -137,16 +225,13 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
// @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) // Pass the data object
return printToken(w, r, d.user)
}
// logoutHandler handles user logout
@@ -158,7 +243,9 @@ 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) {
defer auth.RevokeAPIKey(d.token)
if err := auth.RevokeApiToken(store.Access, d.token); err != nil {
logger.Errorf("Failed to revoke token on logout: %v", err)
}
// 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
@@ -171,7 +258,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
Value: "",
Domain: strings.Split(host, ":")[0],
Path: "/",
SameSite: http.SameSiteLaxMode,
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0), // Expire immediately
MaxAge: -1, // Delete cookie
}
@@ -188,6 +275,16 @@ 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")
@@ -258,7 +355,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/renew [post]
// @Router /api/auth/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)
@@ -266,7 +363,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)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
tokenString, _, err := auth.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
@@ -278,90 +375,16 @@ 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, signed.Key, expiresTime)
setSessionCookie(w, r, tokenString, expiresTime)
// Still return token in body for backward compatibility and state management
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte(signed.Key)); err != nil {
if _, err := w.Write([]byte(tokenString)); 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
@@ -398,10 +421,6 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
}
password := r.Header.Get("X-SHARE-PASSWORD")
password, err := url.QueryUnescape(password)
if err != nil {
return http.StatusUnauthorized, err
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
@@ -423,10 +442,8 @@ 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.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
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)
}
+279
View File
@@ -0,0 +1,279 @@
package http
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/time/rate"
)
// throttledReadSeeker is a wrapper around an io.ReadSeeker that throttles the reading speed.
type throttledReadSeeker struct {
rs io.ReadSeeker
limiter *rate.Limiter
ctx context.Context
}
// newThrottledReadSeeker creates a new throttledReadSeeker.
func newThrottledReadSeeker(rs io.ReadSeeker, limit rate.Limit, burst int, ctx context.Context) *throttledReadSeeker {
return &throttledReadSeeker{
rs: rs,
limiter: rate.NewLimiter(limit, burst),
ctx: ctx,
}
}
func (r *throttledReadSeeker) Read(p []byte) (n int, err error) {
n, err = r.rs.Read(p)
if n > 0 {
if waitErr := r.limiter.WaitN(r.ctx, n); waitErr != nil {
// The original error (like io.EOF) is potentially more important
// than the context error.
if err == nil {
err = waitErr
}
}
}
return
}
func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.rs.Seek(offset, whence)
}
// toASCIIFilename converts a filename to ASCII-safe format by replacing non-ASCII characters with underscores
func toASCIIFilename(fileName string) string {
var result strings.Builder
for _, r := range fileName {
if r > 127 {
// Replace non-ASCII characters with underscore
result.WriteRune('_')
} else {
result.WriteRune(r)
}
}
return result.String()
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
dispositionType := "attachment"
if r.URL.Query().Get("inline") == "true" {
dispositionType = "inline"
}
// standard: ASCII-only safe fallback
asciiFileName := toASCIIFilename(fileName)
// RFC 5987: UTF-8 encoded
encodedFileName := url.PathEscape(fileName)
// Always set both filename (ASCII) and filename* (UTF-8) for maximum compatibility (RFC 6266)
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
// @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:**
// @Description - The Content-Disposition header will always include both:
// @Description 1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
// @Description 2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
// @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 Resources
// @Accept json
// @Param source query string true "Source name for the files (required)"
// @Param file query []string true "File path (can be repeated for multiple files)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Modify permissions required"
// @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) {
source := r.URL.Query().Get("source")
fileList := r.URL.Query()["file"]
// Rule 1: Validate all user-provided file paths to prevent path traversal
for i, filePath := range fileList {
cleanPath, err := utils.SanitizeUserPath(filePath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
}
fileList[i] = cleanPath
}
return rawFilesHandler(w, r, d, source, fileList)
}
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")
}
if len(fileList) == 0 && d.share == nil {
return http.StatusBadRequest, fmt.Errorf("no files specified")
}
firstFilePath := fileList[0]
var err error
var userscope string
fileName := filepath.Base(firstFilePath)
// Check if this is an OnlyOffice file early for error logging
isOnlyOffice := isOnlyOfficeCompatibleFile(fileName) && config.Integrations.OnlyOffice.Url != ""
var documentId string
var logContext *OnlyOfficeLogContext
if d.share == nil {
userscope, err = d.user.GetScopeForSourceName(source)
if err != nil {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
// Try to get document ID for error logging
idx := indexing.GetIndex(source)
if idx != nil {
tempPath := utils.JoinPathAsUnix(userscope, firstFilePath)
if realPath, _, realErr := idx.GetRealPath(tempPath); realErr == nil {
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - source not available: %s - %v", firstFilePath, err))
}
}
}
}
}
return http.StatusForbidden, err
}
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
}
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(source)
if idx == nil {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - source index not available: %s", source))
}
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
realPath, isDir, err := idx.GetRealPath(firstFilePath)
if err != nil {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not resolve path: %s - %v", firstFilePath, err))
}
}
}
return http.StatusInternalServerError, err
}
// Compute estimated download size (for single-file branch and archive size check)
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
if isOnlyOffice {
documentId, _ = getOnlyOfficeId(realPath)
if documentId != "" {
logContext = getOnlyOfficeLogContext(documentId)
}
}
// Verify access control before opening the file (direct rule check)
if d.share == nil && store.Access != nil {
if !store.Access.Permitted(idx.Path, firstFilePath, d.user.Username) {
logger.Debugf("user %s denied access to path %s", d.user.Username, firstFilePath)
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - access denied by rule: %s", firstFilePath))
}
return http.StatusForbidden, fmt.Errorf("access denied to path %s", firstFilePath)
}
}
fd, err2 := os.Open(realPath)
if err2 != nil {
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not open file: %s - %v", firstFilePath, err2))
}
return http.StatusInternalServerError, err2
}
defer fd.Close()
// Get file size
fileInfo, err2 := fd.Stat()
if err2 != nil {
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not get file info: %s - %v", firstFilePath, err2))
}
return http.StatusInternalServerError, err2
}
// Send success log for OnlyOffice downloads
if isOnlyOffice && logContext != nil {
logger.Infof("OnlyOffice Server is downloading file: %s (documentId: %s)",
firstFilePath, documentId)
sendOnlyOfficeLogEvent(logContext, "INFO", "download",
fmt.Sprintf("OnlyOffice Server downloading file: %s", firstFilePath))
}
// Set headers
setContentDisposition(w, r, fileName)
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
var reader io.ReadSeeker = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
http.ServeContent(w, r, fileName, fileInfo.ModTime(), reader)
return 200, nil
}
// ** Archive (ZIP/TAR.GZ) handling ** — delegate to archive package
return BuildAndStreamArchive(w, r, d, source, fileList)
}
// isOnlyOfficeCompatibleFile checks if a file extension is supported by OnlyOffice
func isOnlyOfficeCompatibleFile(fileName string) bool {
return iteminfo.IsOnlyOffice(fileName)
}
+8 -11
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -134,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 Duplicates
// @Tags Tools
// @Accept json
// @Produce json
// @Param source query string true "Source name for the desired source"
@@ -143,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/duplicates [get]
// @Router /api/tools/duplicateFinder [get]
func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
opts, err := prepDuplicatesOptions(r, d)
if err != nil {
@@ -556,7 +555,10 @@ func mergeGroupsByChecksum(groups []duplicateGroupWithChecksums) []duplicateGrou
func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptions, error) {
source := r.URL.Query().Get("source")
scope := r.URL.Query().Get("scope")
scope, err := utils.SanitizeUserPath(r.URL.Query().Get("scope"))
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
}
minSizeMbStr := r.URL.Query().Get("minSizeMb")
// First pass uses filename matching for speed, then checksums verify final groups
@@ -565,19 +567,14 @@ func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptio
// Default minimum size: 1MB
minSizeMb := int64(1)
if minSizeMbStr != "" {
if _, err := fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
if _, err = fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
return nil, fmt.Errorf("invalid minSizeMb parameter: %w", err)
}
}
// Convert MB to bytes
minSize := minSizeMb * 1024 * 1024
unencodedScope, err := url.PathUnescape(scope)
if err != nil {
return nil, fmt.Errorf("invalid path encoding: %v", err)
}
searchScope := strings.TrimPrefix(unencodedScope, ".")
searchScope := strings.TrimPrefix(scope, ".")
index := indexing.GetIndex(source)
if index == nil {
+80 -79
View File
@@ -26,10 +26,9 @@ 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
Size int64 `json:"size"` // File size in bytes (for directories, total size of all files)
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
@@ -123,7 +122,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/watch [get]
// @Router /api/tools/fileWatcher [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") != "" {
@@ -137,8 +136,12 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
var err error
path, err = utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
@@ -186,55 +189,42 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
// Verify it's a file, not a directory
// Get file/directory info
info, err := os.Stat(realPath)
if err != nil {
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)
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
}
// Get MIME type from the index if available
mimeType := "application/octet-stream"
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
if exists && reducedInfo.Type != "" {
mimeType = reducedInfo.Type
}
response := fileWatchResponse{
IsText: isText,
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(),
}
if isText {
// Read the last N lines for text files
content, err := readLastNLines(realPath, lines)
if !info.IsDir() {
// Handle regular file
// Check if file is a text file
isText, err := utils.IsTextFile(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error reading file: %v", err)
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
}
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,
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
}
}
@@ -261,7 +251,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/watch/sse [get]
// @Router /api/tools/fileWatcher/sse [get]
func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Check realtime permissions
if !(d.user.Permissions.Realtime) {
@@ -282,6 +272,13 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
@@ -335,16 +332,13 @@ 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("file not found: %v", err)
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
}
// Verify it's a file, not a directory
// Get file/directory info
info, err := os.Stat(realPath)
if err != nil {
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")
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
}
// Set up SSE headers
@@ -375,10 +369,13 @@ 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 != "" && reducedInfo.Type != "directory" {
if exists && reducedInfo.Type != "" {
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() {
@@ -386,14 +383,14 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
defer ticker.Stop()
// Send initial update
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
sendFileWatchUpdate(username, realPath, path, source, lines, mimeType, isDir)
for {
select {
case <-stopTicker:
return
case <-ticker.C:
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
sendFileWatchUpdate(username, realPath, path, source, lines, mimeType, isDir)
}
}
}()
@@ -432,54 +429,58 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
}
// 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)
// 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)
info, err := os.Stat(realPath)
if err != nil {
// File no longer exists
errorMsg, _ := json.Marshal(map[string]interface{}{"status": "error", "error": "file not found"})
// Path no longer exists
errorMsg, _ := json.Marshal(map[string]interface{}{"status": "error", "error": "path not found"})
events.SendToUsers("fileWatch", string(errorMsg), []string{username})
return
}
// Check if file is a text file
isText, err := utils.IsTextFile(realPath)
if err != nil {
// Error checking file, skip this update
return
}
// Build the SSE event
sseEvent := fileWatchSSEEvent{
IsText: isText,
}
sseEvent := fileWatchSSEEvent{}
if isText {
// Read the last N lines for text files
var content string
content, err = readLastNLines(realPath, lines)
if err != nil {
// Error reading, skip this update
return
}
sseEvent.Contents = content
if isDir {
// Handle directory - just return metadata, no content
sseEvent.IsText = false
sseEvent.Metadata = &fileWatchMetadata{
Name: info.Name(),
Size: info.Size(),
Type: mimeType,
Size: 0, // Directories don't have a meaningful size
Type: "directory",
Modified: info.ModTime(),
Path: path,
}
} else {
// For non-text files, send metadata
// Handle regular file
// Check if file is a text file
var isText bool
isText, err = utils.IsTextFile(realPath)
if err != nil {
// Error checking file, skip this update
return
}
sseEvent.IsText = isText
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
}
// For non-text files, Contents stays empty - frontend displays metadata
}
// Serialize and send via events system
+98 -70
View File
@@ -74,48 +74,67 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
devMode: settings.Env.IsDevMode,
}
// Core routing
router := http.NewServeMux()
// API group routing
api := http.NewServeMux()
// Public group routing (new structure)
publicRoutes := http.NewServeMux()
publicApi := http.NewServeMux()
// User routes
// ========================================
// User Routes - /api/users/ (with public 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))
api.HandleFunc("GET /health", healthHandler)
publicApi.HandleFunc("GET /users", withUser(userGetHandler))
// Auth routes
// ========================================
// Auth Routes - /api/auth/
// ========================================
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("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("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("GET /auth/oidc/callback", wrapHandler(oidcCallbackHandler))
api.HandleFunc("GET /auth/oidc/login", wrapHandler(oidcLoginHandler))
// Resources routes
// ========================================
// Resources Routes - /api/resources/ (with public 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("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("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("GET /access", withAdmin(accessGetHandler))
api.HandleFunc("POST /access", withAdmin(accessPostHandler))
api.HandleFunc("PATCH /access", withAdmin(accessPatchHandler))
@@ -124,87 +143,96 @@ 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 management routes (require permission)
api.HandleFunc("GET /shares", withPermShare(shareListHandler))
// ========================================
// Share Routes - /api/share/
// ========================================
api.HandleFunc("GET /share/list", 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))
// 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
// ========================================
// Settings Routes - /api/settings/
// ========================================
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
api.HandleFunc("GET /settings/config", withAdmin(settingsConfigHandler))
api.HandleFunc("GET /settings/sources", withUser(getSourceInfoHandler))
// 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))
// ========================================
// 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))
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
api.HandleFunc("GET /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
// ========================================
// Media Routes - /api/media/
// ========================================
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
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))
// ========================================
// 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))
// Mount the route groups
// ========================================
// 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
// ========================================
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 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,
}))
// 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))
}
// Frontend share route redirect (DEPRECATED - maintain for backwards compatibility)
// Playwright tests need updating to remove this redirect.
// TODO: Playwright tests need updating to remove this redirect
router.HandleFunc(fmt.Sprintf("GET %vshare/", config.Server.BaseURL), withOrWithoutUser(redirectToShare))
// New frontend share route handler - handle share page and any subpaths
// New frontend share route handler
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
// Static and index file handlers
// Static assets
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))
// redirect to baseUrl if not root
// Base URL redirect (non-root deployments)
if config.Server.BaseURL != "/" {
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Server.BaseURL, http.StatusMovedPermanently)
-33
View File
@@ -1,33 +0,0 @@
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)
}
+263
View File
@@ -0,0 +1,263 @@
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
}
+157
View File
@@ -0,0 +1,157 @@
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)
}
})
}
}
+55 -26
View File
@@ -4,24 +4,26 @@ import (
"bytes"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// subtitlesHandler handles subtitle extraction requests
// @Summary Extract embedded subtitles
// @Description Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content
// @Tags Subtitles
// 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
// @Accept json
// @Produce text/vtt
// @Produce text/plain
// @Param path query string true "Index path to the video file"
// @Param source query string true "Source name for the desired source"
// @Param index query int false "Stream index for embedded subtitle extraction, defaults to 0"
// @Success 200 {string} string "Raw WebVTT subtitle content"
// @Param name query string true "Subtitle track name (filename for external, descriptive name for embedded)"
// @Param embedded query bool false "Whether this is an embedded stream (true) or external file (false), defaults to false"
// @Success 200 {string} string "Raw subtitle content in original format"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
@@ -30,10 +32,11 @@ import (
func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
indexParam := r.URL.Query().Get("index")
name := r.URL.Query().Get("name")
embedded := r.URL.Query().Get("embedded") == "true"
if indexParam == "" {
indexParam = "0" // default to first subtitle stream
if name == "" {
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
}
userscope, err := d.user.GetScopeForSourceName(source)
@@ -45,28 +48,54 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, err := idx.GetRealPath(userscope, path)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
metadata, exists := idx.GetMetadataInfo(userscope, true, false)
if !exists {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
parentDir := filepath.Dir(realPath)
var content string
if !embedded {
content, err = utils.GetSubtitleSidecarContent(filepath.Join(parentDir, name))
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
}
} else {
// For embedded subtitles, we need to find the stream index by name
// Get file modification time for caching
fileInfo, err := os.Stat(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to stat file: %v", err)
}
// Detect embedded subtitles
embeddedSubs := ffmpeg.DetectEmbeddedSubtitles(realPath, fileInfo.ModTime())
// Find the subtitle track by name
var streamIndex *int
for _, sub := range embeddedSubs {
if sub.Name == name {
streamIndex = sub.Index
break
}
}
if streamIndex == nil {
return http.StatusNotFound, fmt.Errorf("embedded subtitle track '%s' not found", name)
}
content, err = ffmpeg.ExtractSubtitleContent(realPath, *streamIndex)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
}
index, err := strconv.Atoi(indexParam)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid index parameter: %v", err)
}
parentDir := filepath.Dir(realPath)
subtitle, err := ffmpeg.ExtractSingleSubtitle(realPath, parentDir, index, metadata.ModTime)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract subtitle: %v", err)
}
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
// Return raw content with appropriate content type
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, fmt.Sprintf("%s-%d.vtt", subtitle.Name, index),
time.Now(), bytes.NewReader([]byte(subtitle.Content)))
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
return http.StatusOK, nil
}
+175 -59
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"runtime"
"slices"
"strings"
@@ -26,6 +25,7 @@ import (
type requestContext struct {
user *users.User
shareUser *users.User
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
@@ -51,12 +51,7 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
func withHashFileHelper(fn handleFunc) handleFunc {
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
path := r.URL.Query().Get("path")
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
@@ -103,23 +98,27 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if link.DisableFileViewer || reachedDownloadsLimit {
getContent = false
}
shareCreatedByUser, err := store.Users.Get(link.UserID)
data.shareUser, err = store.Users.Get(link.UserID)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
}
// get user scope path from share
userScope, err := shareCreatedByUser.GetScopeForSourceName(source.Name)
userScope, err := data.shareUser.GetScopeForSourceName(source.Name)
if err != nil {
return http.StatusForbidden, err
}
// so trim user scope from link.Path
userScopedPath := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
if !strings.HasSuffix(userScopedPath, "/") {
userScopedPath = userScopedPath + "/"
pathWithoutUserScope := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
if !strings.HasSuffix(pathWithoutUserScope, "/") {
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") {
return fn(w, r, data)
}
data.IndexPath = userScopedPath
file, err := FileInfoFasterFunc(utils.FileOptions{
Path: userScopedPath,
Path: pathWithoutUserScope,
Source: source.Name,
Expand: true,
Content: getContent,
@@ -128,7 +127,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ShowHidden: link.ShowHidden,
FollowSymlinks: true,
}, store.Access, shareCreatedByUser)
}, store.Access, data.shareUser, store.Share)
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")
@@ -313,16 +312,48 @@ 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) {
// This middleware is used when no user authentication is required
// Call the actual handler function with the updated context
// 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
}
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)
}
}
}
}
}
// No valid admin token - proceed with username/password authentication
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
// Try LDAP first if enabled; on success set d.user and continue to handler
if config.Auth.Methods.LdapAuth.Enabled {
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 {
@@ -331,14 +362,16 @@ 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 fn(w, r, d)
return withUserHelper(fn)(w, r, d)
}
}
@@ -358,58 +391,75 @@ 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
}
tokenString, err := extractToken(r)
if err != nil && !isProxyUser {
return http.StatusUnauthorized, err
if data.token == "" {
var err error
data.token, err = extractToken(r)
if err != nil && !isProxyUser {
return http.StatusUnauthorized, err
}
}
data.token = tokenString
var tk users.AuthToken
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
token, err := jwt.ParseWithClaims(data.token, &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("error processing token, %v", err)
return http.StatusUnauthorized, fmt.Errorf("invalid token: %v", err)
}
if !token.Valid {
return http.StatusUnauthorized, fmt.Errorf("invalid token")
}
if auth.IsRevokedApiKey(data.token) {
if isProxyUser {
return getProxyUser(w, r, data, fn, proxyUser)
}
return http.StatusUnauthorized, fmt.Errorf("token revoked")
if auth.IsRevokedApiToken(store.Access, data.token) {
return http.StatusUnauthorized, fmt.Errorf("token is expired or revoked")
}
// ExpiresAt should always be set in valid tokens created by our system
// If it's nil, the token is invalid
if tk.ExpiresAt == nil {
return http.StatusUnauthorized, fmt.Errorf("invalid token: missing expiration")
// JWT library populates RegisteredClaims.ExpiresAt
if tk.RegisteredClaims.ExpiresAt == nil {
return http.StatusUnauthorized, fmt.Errorf("token is invalid or revoked")
}
// Check if token is about to expire for renewal header
if tk.ExpiresAt.Unix() < time.Now().Add(time.Minute*30).Unix() {
if tk.RegisteredClaims.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 {
tk.BelongsTo, err = getUserFromApiToken(data.token)
if err != nil {
return http.StatusUnauthorized, err
// 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 = 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 expire := tk.ExpiresAt; expire != nil {
setSessionCookie(w, r, tokenString, expire.Time)
if tk.RegisteredClaims.ExpiresAt != nil {
setSessionCookie(w, r, data.token, tk.RegisteredClaims.ExpiresAt.Time)
}
setUserInResponseWriter(w, data.user)
if data.user.Username == "" {
@@ -423,6 +473,48 @@ 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)
@@ -437,12 +529,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)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
tokenString, _, err := auth.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 = signed.Key
data.token = tokenString
}
// Call the handler function, passing in the context (or return OK if no handler)
if fn == nil {
@@ -464,16 +556,10 @@ 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{}
data := &requestContext{
ctx: r.Context(),
}
// Call the actual handler function and get status code and error
status, err := fn(w, r, data)
@@ -485,10 +571,6 @@ func wrapHandlerOpts(fn handleFunc, opts wrapperOpts) 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)
@@ -515,6 +597,21 @@ func wrapHandlerOpts(fn handleFunc, opts wrapperOpts) 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 {
@@ -524,11 +621,30 @@ 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))
}
+4 -3
View File
@@ -8,6 +8,7 @@ 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"
@@ -52,7 +53,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) (*iteminfo.ExtendedFileInfo, error) {
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
return &iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
Path: opts.Path,
@@ -109,7 +110,7 @@ func TestWithAdminHelper(t *testing.T) {
data := &requestContext{
user: tc.user,
}
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm, false)
tokenString, _, err := auth.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)
}
@@ -126,7 +127,7 @@ func TestWithAdminHelper(t *testing.T) {
}
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: token.Key,
Value: tokenString,
})
// Call the handler with the test request and mock context
+47 -30
View File
@@ -11,6 +11,7 @@ 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"
@@ -71,7 +72,7 @@ func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
// oidcLoginHandler initiates OIDC login.
// @Summary OIDC login
// @Description Initiates OIDC login flow.
// @Tags OIDC
// @Tags Auth
// @Accept json
// @Produce json
// @Success 302 {string} string "Redirect to OIDC provider"
@@ -105,7 +106,7 @@ func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// oidcCallbackHandler handles OIDC callback.
// @Summary OIDC callback
// @Description Handles OIDC login callback.
// @Tags OIDC
// @Tags Auth
// @Accept json
// @Produce json
// @Param code query string false "OIDC code"
@@ -157,6 +158,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
logger.Errorf("failed to exchange token: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to exchange token: %v", err)
}
fmt.Println("token", token)
rawIDToken, ok := token.Extra("id_token").(string)
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
@@ -243,11 +245,29 @@ 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 config.Auth.Methods.OidcAuth.AdminGroup != "" {
if slices.Contains(groups, config.Auth.Methods.OidcAuth.AdminGroup) {
if oidcCfg.AdminGroup != "" {
if slices.Contains(groups, oidcCfg.AdminGroup) {
isAdmin = true // User is in the admin group, grant admin privileges
logger.Debugf("User %s is in admin group %s, granting admin privileges.", username, config.Auth.Methods.OidcAuth.AdminGroup)
logger.Debugf("User %s is in admin group %s, granting admin privileges.", username, oidcCfg.AdminGroup)
}
}
logger.Debugf("Successfully authenticated OIDC username: %s isAdmin: %v", username, isAdmin)
@@ -257,32 +277,29 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
if config.Auth.Methods.OidcAuth.CreateUser {
if config.Auth.Methods.OidcAuth.AdminGroup == "" {
isAdmin = config.UserDefaults.Permissions.Admin
}
user = &users.User{
Username: username,
LoginMethod: users.LoginMethodOidc,
}
settings.ApplyUserDefaults(user)
if isAdmin {
user.Permissions.Admin = true
}
err = storage.CreateUser(*user, 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)
// 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
}
} else {
// update user admin perms
if isAdmin != user.Permissions.Admin && config.Auth.Methods.OidcAuth.AdminGroup != "" {
if isAdmin != user.Permissions.Admin && oidcCfg.AdminGroup != "" {
user.Permissions.Admin = isAdmin
err = store.Users.Update(user, true, "Permissions")
if err != nil {
@@ -301,7 +318,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
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
tokenString, _, err2 := auth.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") {
@@ -322,7 +339,7 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
}
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: signed.Key,
Value: tokenString,
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
+46
View File
@@ -77,6 +77,43 @@ 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 {
@@ -98,3 +135,12 @@ 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")
}
}
+55 -6
View File
@@ -65,6 +65,21 @@ 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")
@@ -80,6 +95,14 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, providedPath)
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
providedPath = cleanPath
themeMode := utils.Ternary(d.user.DarkMode, "dark", "light")
var sourceInfo *settings.Source
var ok bool
@@ -102,12 +125,13 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
// Build file info based on whether this is a share or regular request
// Regular user request
logger.Debugf("OnlyOffice user request: request path=%s", path)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
FollowSymlinks: true,
}, store.Access, d.user)
}, store.Access, d.user, store.Share)
if err != nil {
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, path, err)
return errToStatus(err), err
@@ -256,7 +280,7 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
}
escapedPath := url.QueryEscape(path)
downloadURL := fmt.Sprintf("%s/api/raw?file=%s&auth=%s",
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token)
if hash != "" {
downloadURL = downloadURL + "&hash=" + hash
@@ -309,7 +333,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/onlyoffice/callback?%s",
callbackURL = fmt.Sprintf("%s/public/api/office/callback?%s",
strings.TrimSuffix(baseURL, "/"), params.Encode())
} else {
// Regular callback URL - include source for non-share requests
@@ -318,7 +342,7 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
params.Set("path", path)
params.Set("auth", token)
callbackURL = fmt.Sprintf("%s/api/onlyoffice/callback?%s",
callbackURL = fmt.Sprintf("%s/api/office/callback?%s",
strings.TrimSuffix(baseURL, "/"), params.Encode())
}
@@ -336,6 +360,15 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
return returnOnlyOfficeError(w, r, 400, "missing required parameters: path + source/hash are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
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 != "" {
@@ -510,7 +543,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
_, err = files.FileInfoFaster(utils.FileOptions{
Source: source,
Path: path,
}, store.Access, d.user)
}, store.Access, d.user, store.Share)
if err != nil {
logger.Errorf("OnlyOffice callback: original file no longer exists at path=%s: %v",
path, err)
@@ -557,6 +590,22 @@ 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
+80 -72
View File
@@ -16,7 +16,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
@@ -54,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/preview [get]
// @Router /api/resources/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")
@@ -72,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.Access, d.user, store.Share)
if err != nil {
logger.Errorf("error getting file info: %v", err)
return errToStatus(err), err
@@ -87,10 +86,6 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
idx := indexing.GetIndex(file.Source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
}
fd, err := os.Open(file.RealPath)
if err != nil {
return http.StatusInternalServerError, err
@@ -104,105 +99,118 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
return 0, nil
}
// getDirectoryPreview finds a valid preview file within a directory.
func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.ExtendedFileInfo, error) {
var lastErr error
// 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
for _, item := range d.fileInfo.Files {
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
continue
}
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
}
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
previewableNames = append(previewableNames, item.Name)
}
// 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)
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]
return nil, fmt.Errorf("no previewable files found in directory")
source := d.fileInfo.Source
path := utils.JoinPathAsUnix(d.fileInfo.Path, 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 + name
}
fileInfo, err := files.FileInfoFaster(
utils.FileOptions{
Path: path,
Source: source,
AlbumArt: true,
Metadata: true,
}, store.Access, d.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)
}
}
return nil, previewErr
}
return fileInfo, nil
}
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
previewSize := r.URL.Query().Get("size")
if !(previewSize == "large" || previewSize == "original") {
if !(previewSize == "large" || previewSize == "original" || previewSize == "xlarge") {
previewSize = "small"
}
if !d.fileInfo.HasPreview {
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" {
// Get extended file info of first previewable item in directory
fileInfo, err := getDirectoryPreview(r, d)
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)
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)
isImage := strings.HasPrefix(d.fileInfo.Type, "image")
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
resizable := iteminfo.ResizableImageTypes[ext]
if (!resizable || config.Server.DisableResize) && isImage {
// 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 {
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/raw?file=%s&source=%s", url.QueryEscape(d.fileInfo.Path), url.QueryEscape(d.fileInfo.Source))
pathUrl := fmt.Sprintf("/api/resources/download?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
+103 -61
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
@@ -19,14 +18,14 @@ import (
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
)
// publicRawHandler serves the raw content of a file, multiple files, or directory via a public share.
// publicDownloadHandler 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 Public Shares
// @Tags Shares
// @Accept json
// @Produce octet-stream
// @Param hash query string true "Share hash for authentication"
@@ -39,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/raw [get]
func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// @Router /public/api/resources/download [get]
func publicDownloadHandler(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")
}
@@ -77,9 +76,9 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
// Get all "file" parameter values (supports repeated params)
encodedFiles := r.URL.Query()["file"]
if len(encodedFiles) == 0 {
return http.StatusBadRequest, fmt.Errorf("no files specified")
files := r.URL.Query()["file"]
if len(files) == 0 {
files = []string{"/"}
}
// Get the actual source name from the share's source mapping
@@ -89,16 +88,17 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
actualSourceName := sourceInfo.Name
// Decode and process each file path
// Process each file path
fileList := []string{}
for _, encodedFile := range encodedFiles {
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
file, decodeErr := url.PathUnescape(encodedFile)
if decodeErr != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", decodeErr)
for _, file := range files {
// Rule 1: Validate each file path to prevent path traversal
cleanFile, err := utils.SanitizeUserPath(file)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
}
// Join the share path with the requested path
filePath := utils.JoinPathAsUnix(d.share.Path, file)
filePath := utils.JoinPathAsUnix(d.share.Path, cleanFile)
fileList = append(fileList, filePath)
}
@@ -107,8 +107,8 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err == errors.ErrDownloadNotAllowed {
return http.StatusForbidden, errors.ErrDownloadNotAllowed
}
logger.Errorf("public share handler: error processing filelist: %v with error %v", encodedFiles, err)
return status, fmt.Errorf("error processing filelist: %v", encodedFiles)
logger.Errorf("public share handler: error processing filelist: %v with error %v", files, err)
return status, fmt.Errorf("error processing filelist: %v", files)
}
return status, nil
}
@@ -116,7 +116,7 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// 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 Public Shares
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -139,11 +139,11 @@ 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 Public Shares
// @Tags Shares
// @Accept multipart/form-data
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param targetPath query string true "Target path within the share to upload to. Must be relative to share root."
// @Param path query string true "path within the share to upload to. Must be relative to share root."
// @Param override query bool false "If true, overwrite existing files/folders. Defaults to false."
// @Param action query string false "Upload action: 'override' to replace files, 'rename' to auto-rename"
// @Param file formData file true "File to upload"
@@ -159,18 +159,15 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if d.share.ShareType != "upload" && !d.share.AllowCreate {
return http.StatusForbidden, fmt.Errorf("uploading is disabled for this share")
}
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
}
// Go automatically decodes query params
path := r.URL.Query().Get("targetPath")
fullPath := filepath.Join(d.share.Path, path)
source := config.Server.SourceMap[d.share.Source].Name
// adjust query params to match resourcePostHandler
q := r.URL.Query()
q.Set("source", source)
q.Set("path", fullPath)
q.Set("path", d.IndexPath)
r.URL.RawQuery = q.Encode()
status, err := resourcePostHandler(w, r, d)
if err != nil {
@@ -184,7 +181,6 @@ 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"
@@ -201,7 +197,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 Public Shares
// @Tags Shares
// @Accept json
// @Produce image/jpeg
// @Param hash query string true "Share hash for authentication"
@@ -212,7 +208,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/preview [get]
// @Router /public/api/resources/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")
@@ -232,7 +228,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 Public Shares
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -259,7 +255,14 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
// Go automatically decodes query params
path := r.URL.Query().Get("path")
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
err = files.WriteFile(source, resolvedPath, r.Body)
// hide the error
if err != nil {
@@ -269,43 +272,33 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusOK, nil
}
// deprecated -- see publicBulkDeleteHandler
func publicDeleteHandler(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")
}
path := r.URL.Query().Get("path")
indexPath := utils.JoinPathAsUnix(d.share.Path, path)
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: indexPath,
Source: source,
Expand: false,
ShowHidden: d.share.ShowHidden,
}, store.Access, d.user)
Path: d.IndexPath,
Source: d.share.Source,
}, store.Access, d.shareUser, store.Share)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
err = files.DeleteFiles(d.share.Source, fileInfo.RealPath, fileInfo.Type == "directory")
if err != nil {
logger.Errorf("public delete handler: error deleting resource with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
}
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
return http.StatusOK, nil
}
// 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 Public Shares
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -315,15 +308,15 @@ 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 [post]
// @Router /public/api/resources/bulk [delete]
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")
}
// hide the error
status, err := resourceBulkDeleteHandler(w, r, d)
if err != nil {
logger.Errorf("public bulk delete handler: error deleting resources with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occurred while processing the request")
}
return status, nil
@@ -332,7 +325,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 Public Shares
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -398,15 +391,15 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return status, err
}
// getShareImage serves banner or favicon files for shares
// @Summary Get share image (banner or favicon)
// @Description Serves the banner or favicon file for a share
// @Tags Public Shares
// @Produce octet-stream
// 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
// @Produce image/jpeg
// @Param hash query string true "Share hash"
// @Param banner query bool false "Request banner file"
// @Param favicon query bool false "Request favicon file"
// @Success 200 {file} binary "Image file content"
// @Success 200 {file} file "Preview image content (JPEG)"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 403 {object} map[string]string "Permission denied"
// @Failure 404 {object} map[string]string "Asset not found"
@@ -439,7 +432,7 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
Metadata: false,
ShowHidden: false,
FollowSymlinks: true,
}, store.Access, shareCreatedByUser)
}, store.Access, shareCreatedByUser, store.Share)
if err != nil {
logger.Errorf("error accessing share asset: source=%v path=%v error=%v", sourceName, assetPath, err)
@@ -451,13 +444,27 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusBadRequest, fmt.Errorf("invalid file type, must be image")
}
// Serve the file
http.ServeFile(w, r, fileInfo.RealPath)
return 0, nil
// Set file info in request context for preview generation
d.fileInfo = *fileInfo
q := r.URL.Query()
if isBanner {
q.Set("size", "xlarge")
} else {
q.Set("size", "small")
}
r.URL.RawQuery = q.Encode()
// Use the preview helper to generate and serve a resized preview
status, err := previewHelperFunc(w, r, d)
if err != nil {
logger.Errorf("error generating preview for share asset: source=%v path=%v error=%v", sourceName, assetPath, err)
return http.StatusNotFound, fmt.Errorf("preview not available for this asset")
}
return status, err
}
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
// Get the asset query string from share
var assetQueryString string
if isBanner {
@@ -481,3 +488,38 @@ func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string,
return sourceName, assetPath, nil
}
// 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")
}
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 err != nil {
if err == errors.ErrAccessDenied {
return http.StatusForbidden, err
}
return http.StatusInternalServerError, err
}
return renderJSON(w, r, items)
}
-610
View File
@@ -1,610 +0,0 @@
package http
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"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/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/time/rate"
)
// throttledReadSeeker is a wrapper around an io.ReadSeeker that throttles the reading speed.
type throttledReadSeeker struct {
rs io.ReadSeeker
limiter *rate.Limiter
ctx context.Context
}
// newThrottledReadSeeker creates a new throttledReadSeeker.
func newThrottledReadSeeker(rs io.ReadSeeker, limit rate.Limit, burst int, ctx context.Context) *throttledReadSeeker {
return &throttledReadSeeker{
rs: rs,
limiter: rate.NewLimiter(limit, burst),
ctx: ctx,
}
}
func (r *throttledReadSeeker) Read(p []byte) (n int, err error) {
n, err = r.rs.Read(p)
if n > 0 {
if waitErr := r.limiter.WaitN(r.ctx, n); waitErr != nil {
// The original error (like io.EOF) is potentially more important
// than the context error.
if err == nil {
err = waitErr
}
}
}
return
}
func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.rs.Seek(offset, whence)
}
// toASCIIFilename converts a filename to ASCII-safe format by replacing non-ASCII characters with underscores
func toASCIIFilename(fileName string) string {
var result strings.Builder
for _, r := range fileName {
if r > 127 {
// Replace non-ASCII characters with underscore
result.WriteRune('_')
} else {
result.WriteRune(r)
}
}
return result.String()
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
dispositionType := "attachment"
if r.URL.Query().Get("inline") == "true" {
dispositionType = "inline"
}
// standard: ASCII-only safe fallback
asciiFileName := toASCIIFilename(fileName)
// RFC 5987: UTF-8 encoded
encodedFileName := url.PathEscape(fileName)
// Always set both filename (ASCII) and filename* (UTF-8) for maximum compatibility (RFC 6266)
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q; filename*=utf-8''%s", dispositionType, asciiFileName, encodedFileName))
}
// 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:**
// @Description - The Content-Disposition header will always include both:
// @Description 1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
// @Description 2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
// @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 Resources
// @Accept json
// @Param source query string true "Source name for the files (required)"
// @Param file query []string true "File path (can be repeated for multiple files)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Modify permissions required"
// @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/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"]
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
}
}
// Verify file exists
_, err := files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
FollowSymlinks: true,
}, store.Access, d.user)
if err != nil {
return err
}
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")
}
if len(fileList) == 0 {
return http.StatusBadRequest, fmt.Errorf("no files specified")
}
firstFilePath := fileList[0]
var err error
var userscope string
fileName := filepath.Base(firstFilePath)
// Check if this is an OnlyOffice file early for error logging
isOnlyOffice := isOnlyOfficeCompatibleFile(fileName) && config.Integrations.OnlyOffice.Url != ""
var documentId string
var logContext *OnlyOfficeLogContext
if d.share == nil {
userscope, err = d.user.GetScopeForSourceName(source)
if err != nil {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
// Try to get document ID for error logging
idx := indexing.GetIndex(source)
if idx != nil {
tempPath := utils.JoinPathAsUnix(userscope, firstFilePath)
if realPath, _, realErr := idx.GetRealPath(tempPath); realErr == nil {
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - source not available: %s - %v", firstFilePath, err))
}
}
}
}
}
return http.StatusForbidden, err
}
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
}
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(source)
if idx == nil {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - source index not available: %s", source))
}
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
realPath, isDir, err := idx.GetRealPath(firstFilePath)
if err != nil {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not resolve path: %s - %v", firstFilePath, err))
}
}
}
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
if isOnlyOffice {
documentId, _ = getOnlyOfficeId(realPath)
if documentId != "" {
logContext = getOnlyOfficeLogContext(documentId)
}
}
// Verify access control before opening the file (direct rule check)
if d.share == nil && store.Access != nil {
if !store.Access.Permitted(idx.Path, firstFilePath, d.user.Username) {
logger.Debugf("user %s denied access to path %s", d.user.Username, firstFilePath)
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - access denied by rule: %s", firstFilePath))
}
return http.StatusForbidden, fmt.Errorf("access denied to path %s", firstFilePath)
}
}
fd, err2 := os.Open(realPath)
if err2 != nil {
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not open file: %s - %v", firstFilePath, err2))
}
return http.StatusInternalServerError, err2
}
defer fd.Close()
// Get file size
fileInfo, err2 := fd.Stat()
if err2 != nil {
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not get file info: %s - %v", firstFilePath, err2))
}
return http.StatusInternalServerError, err2
}
// Send success log for OnlyOffice downloads
if isOnlyOffice && logContext != nil {
logger.Infof("OnlyOffice Server is downloading file: %s (documentId: %s)",
firstFilePath, documentId)
sendOnlyOfficeLogEvent(logContext, "INFO", "download",
fmt.Sprintf("OnlyOffice Server downloading file: %s", firstFilePath))
}
// Set headers
setContentDisposition(w, r, fileName)
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
var reader io.ReadSeeker = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
http.ServeContent(w, r, fileName, fileInfo.ModTime(), reader)
return 200, nil
}
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.Errorf("Failed to copy archive data to response: %v", 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)
}
var userScope string
var err error
if d.share == nil {
userScope, err = d.user.GetScopeForSourceName(source)
if err != nil {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
}
for _, path := range fileList {
var fullPath string
if d.share == nil {
fullPath = utils.JoinPathAsUnix(userScope, path)
// 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, fullPath, d.user.Username) {
continue // Skip this file and continue with the next one
}
} else {
fullPath = path
}
// For shares, the path is already correctly resolved by publicRawHandler
realPath, isDir, err := idx.GetRealPath(fullPath)
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 _, fname := range filenames {
err := addFile(source, fname, 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", fname, 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 _, fname := range filenames {
err := addFile(source, fname, d, tarWriter, nil, false)
if err != nil {
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, 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
func isOnlyOfficeCompatibleFile(fileName string) bool {
return iteminfo.IsOnlyOffice(fileName)
}
+112 -62
View File
@@ -61,6 +61,7 @@ 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"
@@ -74,6 +75,7 @@ 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,
@@ -83,7 +85,8 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
Metadata: getMetadata,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
}, store.Access, d.user)
SkipExtendedAttrs: skipExtendedAttrs,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
}
@@ -137,7 +140,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
Source: source,
Expand: false,
ShowHidden: d.user.ShowHidden,
}, store.Access, d.user)
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
}
@@ -201,8 +204,12 @@ 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 [post]
// @Router /api/resources/bulk [delete]
func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
filePermUser := d.user
if d.share != nil {
filePermUser = d.shareUser
}
// Check permissions - either user delete permission or share delete permission
if d.share == nil {
if !d.user.Permissions.Delete {
@@ -248,18 +255,29 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
if d.share != nil {
indexPath := utils.JoinPathAsUnix(d.share.Path, item.Path)
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
// get user scope path from share
userScope, err := d.shareUser.GetScopeForSourceName(source)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: "user does not have access",
})
continue
}
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: indexPath,
Source: source,
ShowHidden: true,
}, store.Access, d.user)
}, store.Access, filePermUser, store.Share)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
@@ -289,7 +307,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
// Check user scope for this source
_, err := d.user.GetScopeForSourceName(item.Source)
_, err := filePermUser.GetScopeForSourceName(item.Source)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -315,7 +333,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
Path: idx.MakeIndexPath(item.Path, false),
Source: item.Source,
ShowHidden: true,
}, store.Access, d.user)
}, store.Access, filePermUser, store.Share)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -354,7 +372,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory"
// @Param path query string true "url encoded destination path where to place the files inside the destination source"
// @Param source query string true "Name for the desired filebrowser destination source name, default is used if not provided"
// @Param override query bool false "Override existing file if true"
// @Param isDir query bool false "Explicitly specify if the resource is a directory"
@@ -367,22 +385,22 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
var err error
accessStore := store.Access
// if share is not nil, then set accessStore to nil
if d.share != nil {
accessStore = nil
} else {
// Go automatically decodes query params - no need for QueryUnescape
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Path is now handled by FileInfoFaster which will apply user scope
}
// Determine if this is a directory based on isDir query param or trailing slash (for backwards compatibility)
isDirParam := r.URL.Query().Get("isDir")
isDir := isDirParam == "true" || strings.HasSuffix(path, "/")
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
if d.share == nil && !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
filePermUser := d.user
if d.share != nil {
filePermUser = d.shareUser
}
isDir := r.URL.Query().Get("isDir") == "true"
fileOpts := utils.FileOptions{
Path: path,
Source: source,
@@ -394,10 +412,21 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(path)
userscope, err := filePermUser.GetScopeForSourceName(source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
// get scoped path
realPath, _, _ := idx.GetRealPath(fullIndexPath)
// Check access control for the target path
if accessStore != nil && !accessStore.Permitted(idx.Path, path, d.user.Username) {
if !store.Access.Permitted(idx.Path, path, filePermUser.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
@@ -416,14 +445,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Directories creation on POST.
if isDir {
// Get user scope to resolve full index path for directory creation
var userScope string
userScope, err = d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
// Create a new FileOptions with the full index path
dirOpts := fileOpts
dirOpts.Path = fullIndexPath
@@ -469,7 +490,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore, d.user)
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser, store.Share)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -519,23 +540,16 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// close file before moving
outFile.Close()
// Move the completed file from the temp location to the final destination
err = fileutils.MoveFile(tempFilePath, realPath)
err = files.MoveResource(false, source, source, tempFilePath, realPath, store.Share, store.Access)
if err != nil {
logger.Debugf("could not move file from %v to %v: %v", tempFilePath, realPath, err)
return http.StatusInternalServerError, fmt.Errorf("could not move file from chunked folder to destination: %v", err)
}
// Refresh index with user scope
userScope, scopeErr := d.user.GetScopeForSourceName(source)
if scopeErr == nil {
fullIndexPath := utils.JoinPathAsUnix(userScope, fileOpts.Path)
go files.RefreshIndex(source, fullIndexPath, false, false) //nolint:errcheck
}
}
return http.StatusOK, nil
}
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore, d.user)
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser, store.Share)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -545,13 +559,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
preview.DelThumbs(r.Context(), *fileInfo)
}
// 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)
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
if err != nil {
logger.Debugf("error writing file: %v", err)
@@ -578,6 +585,13 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
source := r.URL.Query().Get("source")
path := r.URL.Query().Get("path")
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
// Only allow PUT for files.
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
@@ -645,6 +659,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// Process each item
for _, item := range req.Items {
// Validate all fields are provided
if item.FromSource == "" || item.FromPath == "" || item.ToSource == "" || item.ToPath == "" {
item.Message = "fromSource, fromPath, toSource, and toPath are required"
@@ -657,26 +672,26 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
response.Failed = append(response.Failed, item)
continue
}
// Check for root path modifications
if item.ToPath == "/" || item.FromPath == "/" {
item.Message = "cannot modify root directory"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
cleanFromPath, err := utils.SanitizeUserPath(item.FromPath)
if err != nil {
item.Message = fmt.Sprintf("invalid fromPath: %v", err)
response.Failed = append(response.Failed, item)
continue
}
cleanToPath, err := utils.SanitizeUserPath(item.ToPath)
if err != nil {
item.Message = fmt.Sprintf("invalid toPath: %v", err)
response.Failed = append(response.Failed, item)
continue
}
item.FromPath = cleanFromPath
item.ToPath = cleanToPath
// Get user scopes for both sources
// For shares, paths are already absolute, so use empty scope
userscopeSrc := ""
userscopeDst := ""
if d.share == nil {
var err error
userscopeSrc, err = d.user.GetScopeForSourceName(item.FromSource)
if err != nil {
item.Message = "source not available"
@@ -722,6 +737,11 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// Build full index paths for access control
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
if fullDstIndexPath == "/" || fullSrcIndexPath == "/" {
item.Message = "source or destination is the root or unautharized directory"
response.Failed = append(response.Failed, item)
continue
}
// Check access control for both source and destination paths
if !store.Access.Permitted(srcIdx.Path, fullSrcIndexPath, d.user.Username) {
@@ -910,7 +930,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.Access, params.d.user, store.Share)
if err != nil {
return err
@@ -948,3 +968,33 @@ 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)
}
+9 -4
View File
@@ -42,11 +42,11 @@ type searchOptions struct {
//
// Example request (single source):
//
// GET api/search?query=myfile&source=mysource
// GET api/tools/search?query=myfile&source=mysource
//
// Example request (multiple sources):
//
// GET api/search?query=myfile&sources=source1,source2
// GET api/tools/search?query=myfile&sources=source1,source2
//
// Example response:
// [
@@ -66,7 +66,7 @@ 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 Search
// @Tags Tools
// @Accept json
// @Produce json
// @Param query query string true "Search query"
@@ -76,7 +76,7 @@ type searchOptions struct {
// @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/search [get]
// @Router /api/tools/search [get]
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
searchOptions, err := prepSearchOptions(r, d)
if err != nil {
@@ -122,6 +122,11 @@ 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"
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
}
scope = cleanScope
var sources []string
if sourcesParam != "" {
+20
View File
@@ -6,6 +6,8 @@ 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.
@@ -95,3 +97,21 @@ 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)
}
+15 -17
View File
@@ -48,9 +48,6 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
// 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
@@ -84,7 +81,7 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
// @Produce json
// @Success 200 {array} share.Link "List of share links"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/shares [get]
// @Router /api/share/list [get]
func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
var err error
var shares []*share.Link
@@ -116,13 +113,8 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/share [get]
func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
sourceName := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
sourceInfo, ok := config.Server.NameToSource[sourceName] // backend source is path
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", sourceName)
@@ -159,7 +151,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/shares [delete]
// @Router /api/share [delete]
func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
@@ -363,7 +355,14 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusForbidden, err
}
providedPath := body.Path
body.Path = utils.JoinPathAsUnix(userscope, providedPath)
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
body.Path = utils.AddTrailingSlashIfNotExists(body.Path)
// validate path exists as file or folder
_, _, err = idx.GetRealPath(body.Path)
@@ -559,7 +558,7 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool, token stri
if config.Server.ExternalUrl != "" {
if isDirectDownload {
shareURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s%s", config.Server.ExternalUrl, config.Server.BaseURL, hash, tokenParam)
shareURL = fmt.Sprintf("%s%spublic/api/resources/download?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)
}
@@ -582,7 +581,7 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool, token stri
scheme = getScheme(r)
}
if isDirectDownload {
shareURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s%s", scheme, host, config.Server.BaseURL, hash, tokenParam)
shareURL = fmt.Sprintf("%s://%s%spublic/api/resources/download?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)
}
@@ -599,7 +598,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/shareinfo [get]
// @Router /public/api/share/info [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)
@@ -608,8 +607,7 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
commonShare := shareLink.CommonShare
commonShare.DownloadURL = getShareURL(r, hash, true, shareLink.Token)
commonShare.ShareURL = getShareURL(r, hash, false, shareLink.Token)
commonShare.ShareURL = getShareURL(r, hash, false, "")
_, _, err = getShareImagePartsHelper(shareLink, true)
if err == nil {
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
+51 -17
View File
@@ -13,6 +13,7 @@ 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
@@ -78,7 +79,12 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
disableSidebar := false
// Use custom favicon if configured and validated, otherwise fall back to default
favicon := staticURL + "/favicon"
// Determine the correct favicon extension based on type
faviconExt := ".svg" // Default to SVG
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
faviconExt = ".png"
}
favicon := staticURL + "/favicon" + faviconExt
shareHash := ""
data := make(map[string]interface{})
disableNavButtons := settings.Config.Frontend.DisableNavButtons
@@ -105,7 +111,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
if d.share.DisableSidebar {
disableSidebar = true
}
if strings.HasPrefix(d.share.Banner, "http") {
if strings.HasPrefix(strings.ToLower(d.share.Banner), "http") {
banner = d.share.Banner
} else {
_, _, err := getShareImagePartsHelper(d.share, true)
@@ -113,7 +119,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
}
}
if strings.HasPrefix(d.share.Favicon, "http") {
if strings.HasPrefix(strings.ToLower(d.share.Favicon), "http") {
favicon = d.share.Favicon
} else {
_, _, err := getShareImagePartsHelper(d.share, false)
@@ -193,6 +199,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"pwaIcon192": pwaIcon192,
"pwaIcon256": pwaIcon256,
"pwaIcon512": pwaIcon512,
"manifestURL": staticURL + "/site.webmanifest",
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
@@ -212,7 +219,9 @@ 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,
@@ -223,6 +232,7 @@ 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
@@ -270,6 +280,16 @@ func setContentType(w http.ResponseWriter, path string) {
}
}
// manifestHandler serves the cached PWA manifest
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 {
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
}
}
// staticAssetHandler serves static assets exactly as the frontend build produces them
func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
const maxAge = 86400 // 1 day
@@ -285,25 +305,30 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
// Handle special routes that need path mapping
var assetPath string
switch path {
case "favicon.svg", "favicon":
// Handle custom favicon from filesystem
if settings.Env.FaviconIsCustom {
http.ServeFile(w, r, settings.Env.FaviconPath)
return
}
// Use embedded default
assetPath = settings.Env.FaviconEmbeddedPath
case "pwa-icon.svg":
// Serve custom SVG favicon for PWA if configured
case "site.webmanifest":
manifestHandler(w, r)
return
case "favicon.svg":
// For custom SVG favicons, serve the SVG directly
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
http.ServeFile(w, r, settings.Env.FaviconPath)
return
}
// Fall back to default favicon SVG
// Use embedded default SVG
assetPath = settings.Env.FaviconEmbeddedPath
case "favicon-16x16.png", "favicon-32x32.png",
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
"apple-touch-icon.png", "mstile-256x256.png":
case "favicon.png":
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
}
// 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":
// Serve generated icons from cache directory
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
if _, err := os.Stat(iconPath); err == nil {
@@ -312,6 +337,15 @@ 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":
// 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 {
http.ServeFile(w, r, iconPath)
return
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "loginIcon":
// Handle custom login icon from filesystem
if settings.Env.LoginIconIsCustom {
+2 -2
View File
@@ -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 OTP
// @Tags Auth
// @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 OTP
// @Tags Auth
// @Accept json
// @Produce json
// @Security ApiKeyAuth
+23
View File
@@ -5,13 +5,16 @@ 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 {
@@ -87,6 +90,7 @@ 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 = ""
@@ -306,10 +310,29 @@ 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
}
+287 -62
View File
@@ -1,81 +1,306 @@
package http
import (
"context"
"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"
"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"
)
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")
}
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
// fileInfoWrapper wraps iteminfo.ItemInfo to implement os.FileInfo
type fileInfoWrapper struct {
iteminfo.ItemInfo
}
virtualPath := utils.JoinPathAsUnix(userscope, fullPath)
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 }
idx := indexing.GetIndex(source)
if idx == nil {
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(virtualPath)
scopePath, _, _ := idx.GetRealPath(userscope)
// 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
}
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
}
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)
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)
}
},
}
wd.ServeHTTP(writer, request)
return 0, nil // errors and responses (XML-formatted) are handled by webdav handler
func (f *fileInfoWrapper) mode() os.FileMode {
if f.IsDir() {
return os.ModeDir | fileutils.PermDir
}
return fileutils.PermFile
}
func userHasReadWriteAccess(d *requestContext) bool {
return d.user.Permissions.Create && d.user.Permissions.Delete && d.user.Permissions.Modify && d.user.Permissions.Download
// 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
}
// getCachedFileInfo retrieves cached FileInfoFaster result, or calls FileInfoFaster and caches it
func (ffs *filteredFileSystem) getCachedFileInfo(permissionPath string, expand bool) (*iteminfo.ExtendedFileInfo, error) {
// Initialize cache if needed
if ffs.fileInfoCache == nil {
ffs.fileInfoCache = make(map[string]*iteminfo.ExtendedFileInfo)
}
// Check cache - first try exact match
cacheKey := permissionPath
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 := permissionPath + ":expand"
if cached, found := ffs.fileInfoCache[expandedKey]; found {
return cached, nil
}
}
// Call FileInfoFaster
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: permissionPath,
Source: ffs.source,
Expand: expand,
ShowHidden: ffs.user.ShowHidden,
SkipExtendedAttrs: true,
}, store.Access, ffs.user, store.Share)
if err != nil {
return nil, err
}
// Cache result
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 := permissionPath
if !strings.HasSuffix(dirPath, "/") && dirPath != "" {
dirPath += "/"
}
// 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
}
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
}
// filteredFile wraps a webdav.File and filters Readdir results based on FileInfoFaster
type filteredFile struct {
webdav.File
fs *filteredFileSystem
dirPath string // The directory path this file represents (relative to scope root)
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)
}
// For directories, use FileInfoFaster to get filtered contents
// Convert dirPath (relative to scope root) to permission path (relative to userscope)
// Handle root path specially
var permissionPath string
if ff.dirPath == "" || ff.dirPath == "/" {
permissionPath = ff.fs.userscope
} else {
permissionPath = utils.JoinPathAsUnix(ff.fs.userscope, ff.dirPath)
}
// Use cached FileInfoFaster result if available
fileInfo, err := ff.fs.getCachedFileInfo(permissionPath, true)
if err != nil {
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")
}
return ffs.fs.Mkdir(ctx, name, perm)
}
func (ffs *filteredFileSystem) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
file, err := ffs.fs.OpenFile(ctx, name, 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
return &filteredFile{
File: file,
fs: ffs,
dirPath: name,
isDir: stat.IsDir(),
}, nil
}
func (ffs *filteredFileSystem) RemoveAll(ctx context.Context, name string) error {
if !ffs.user.Permissions.Delete {
return fmt.Errorf("delete permission required")
}
return ffs.fs.RemoveAll(ctx, name)
}
func (ffs *filteredFileSystem) Rename(ctx context.Context, oldName, newName string) error {
if !ffs.user.Permissions.Modify {
return fmt.Errorf("modify permission required")
}
return ffs.fs.Rename(ctx, oldName, newName)
}
func (ffs *filteredFileSystem) Stat(ctx context.Context, name string) (os.FileInfo, error) {
// Root path "/" should always be allowed
if name == "" || name == "/" {
return ffs.fs.Stat(ctx, name)
}
// Check permission before stat using cached result if available
permissionPath := utils.JoinPathAsUnix(ffs.userscope, name)
_, err := ffs.getCachedFileInfo(permissionPath, false)
if err != nil {
return nil, err
}
return ffs.fs.Stat(ctx, name)
}
// webDAVHandler serves WebDAV requests.
func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.PathValue("path")
source := r.PathValue("source")
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
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")
}
logger.Debugf("webdav: method=%s, request=%s, source=%s, path=%s", r.Method, r.URL.Path, source, path)
indexPath, userScope, err := files.CheckPermissions(utils.FileOptions{
FollowSymlinks: false,
Path: path,
Source: source,
ShowHidden: d.user.ShowHidden,
}, store.Access, d.user)
if err != nil {
logger.Debugf("error checking file permissions for path %s: %v", path, err)
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 {
logger.Debugf("error resolving scope path: %v", err)
return http.StatusNotFound, err
}
// Construct the WebDAV prefix from BaseURL
webDavPrefix := config.Server.BaseURL + "dav"
prefix := webDavPrefix + "/" + source
logger.Debugf("webdav: virtual_path=%s, indexPath=%s, scope_path=%s", path, indexPath, scopePath)
// Wrap the filesystem to filter directory listings using FileInfoFaster
// This prevents items the user can't access from appearing in listings,
// which stops clients from repeatedly trying to access them
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 && !strings.Contains(err.Error(), "no such file or directory") {
logger.Errorf("webdav handler failed on path %s: %s", req.URL.Path, err)
}
},
}
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
}
+261
View File
@@ -0,0 +1,261 @@
package icons
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
// sourceType represents the type of favicon source
type sourceType int
const (
sourceCustomSVG sourceType = iota
sourceCustomRaster
sourceDefault
)
// iconSize defines an icon size to generate
type iconSize struct {
size int
name string
env *string
}
// determineSourceType determines which type of favicon source we have
func determineSourceType(faviconPath string) sourceType {
if !settings.Env.FaviconIsCustom {
return sourceDefault
}
ext := strings.ToLower(filepath.Ext(faviconPath))
switch ext {
case ".svg":
return sourceCustomSVG
default:
return sourceCustomRaster
}
}
// loadDefaultFavicon loads the embedded default favicon PNG
func loadDefaultFavicon() ([]byte, error) {
assetFs := fileutils.GetAssetFS()
if assetFs == nil {
return nil, errors.New("asset filesystem not initialized")
}
pngPath := "img/icons/favicon.png"
sourceData, err := fs.ReadFile(assetFs, pngPath)
if err != nil {
return nil, fmt.Errorf("failed to read default favicon PNG: %w", err)
}
logger.Debug("Loaded default embedded favicon")
return sourceData, nil
}
// findRasterCompanion looks for a raster companion file for an SVG favicon
// Returns the companion data and path, or error if not found
func findRasterCompanion(svgPath string) ([]byte, string, error) {
basePath := svgPath[:len(svgPath)-len(filepath.Ext(svgPath))]
rasterExts := []string{".png", ".jpg", ".jpeg", ".webp", ".gif"}
for _, ext := range rasterExts {
companionPath := basePath + ext
if _, err := os.Stat(companionPath); err == nil {
data, readErr := os.ReadFile(companionPath)
if readErr != nil {
continue
}
return data, companionPath, nil
}
}
return nil, "", fmt.Errorf("no raster companion found at %s.{png,jpg,webp,gif}", basePath)
}
// convertRasterToPNG decodes any raster format and re-encodes as PNG
func convertRasterToPNG(rasterData []byte, sourceExt string) ([]byte, error) {
img, err := imaging.Decode(bytes.NewReader(rasterData))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
var buf bytes.Buffer
if err := imaging.Encode(&buf, img, imaging.PNG); err != nil {
return nil, fmt.Errorf("failed to convert to PNG: %w", err)
}
logger.Debugf("Converted %s to PNG for icon generation", sourceExt)
return buf.Bytes(), nil
}
// loadFaviconSource loads and prepares the favicon source data as PNG
// Returns PNG data ready for resizing into icon sizes
func loadFaviconSource() ([]byte, error) {
sourceType := determineSourceType(settings.Env.FaviconPath)
switch sourceType {
case sourceCustomSVG:
logger.Debugf("Processing custom SVG favicon: %s", settings.Env.FaviconPath)
return handleSVGFavicon(settings.Env.FaviconPath)
case sourceCustomRaster:
logger.Debugf("Processing custom favicon: %s", settings.Env.FaviconPath)
return handleRasterFavicon(settings.Env.FaviconPath)
case sourceDefault:
logger.Debug("Using default embedded favicon")
return loadDefaultFavicon()
default:
return nil, errors.New("unknown icon source type")
}
}
// handleSVGFavicon processes an SVG favicon by finding its raster companion
func handleSVGFavicon(svgPath string) ([]byte, error) {
logger.Debug("Looking for companion for SVG favicon")
companionData, companionPath, err := findRasterCompanion(svgPath)
if err != nil {
logger.Warningf("SVG favicon has no companion: %v", err)
logger.Warning("Falling back to default embedded favicon")
logger.Warning("For best Apple/iOS compatibility, provide a PNG/JPG companion alongside your SVG")
return loadDefaultFavicon()
}
logger.Debugf("Found companion: %s", companionPath)
return convertRasterToPNG(companionData, filepath.Ext(companionPath))
}
// handleRasterFavicon processes a raster favicon (PNG, JPEG, GIF, WebP, etc.)
func handleRasterFavicon(faviconPath string) ([]byte, error) {
sourceData, err := os.ReadFile(faviconPath)
if err != nil {
return nil, fmt.Errorf("failed to read favicon: %w", err)
}
return convertRasterToPNG(sourceData, filepath.Ext(faviconPath))
}
// getIconSizesToGenerate returns all icon sizes that need to be generated
func getIconSizesToGenerate() []iconSize {
return []iconSize{
// Browser favicon (32x32 PNG for Safari/older browsers)
{32, "favicon-32x32.png", nil},
// PWA icons (PNG required for Apple PWA compatibility)
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
}
}
// generateIconSizes generates all required icon sizes from source PNG data
func generateIconSizes(sourceData []byte) error {
previewService := preview.GetService()
if previewService == nil {
return errors.New("preview service not initialized")
}
iconSizes := getIconSizesToGenerate()
allSuccess := true
generatedCount := 0
for _, icon := range iconSizes {
if err := generateSingleIcon(previewService, sourceData, icon); err != nil {
logger.Warningf("Failed to generate %s: %v", icon.name, err)
allSuccess = false
continue
}
// Update environment variable if specified
if icon.env != nil {
*icon.env = filepath.Join("icons", icon.name)
}
generatedCount++
}
if allSuccess {
logger.Debugf("Successfully generated %d icon sizes", generatedCount)
} else {
logger.Warningf("Generated %d/%d icon sizes (some failed)", generatedCount, len(iconSizes))
}
return nil
}
// generateSingleIcon generates a single icon file at the specified size
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
// Create output file
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer outFile.Close()
// Resize image using the preview service's Resize method
err = previewService.Resize(
bytes.NewReader(sourceData),
outFile,
preview.ResizeOptions{
Width: icon.size,
Height: icon.size,
ResizeMode: preview.ResizeModeFill,
Quality: preview.QualityHigh,
Format: preview.FormatPng,
},
)
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("resize failed: %w", err)
}
return nil
}
// GeneratePWAIcons generates all PWA and platform icon sizes from favicon source
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
func GeneratePWAIcons() error {
logger.Info("Generating PWA icons...")
// Set PWA icons directory in cache
settings.Env.PWAIconsDir = filepath.Join(settings.Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
settings.Env.PWAIcon192 = "icons/pwa-icon-192.png"
settings.Env.PWAIcon256 = "icons/pwa-icon-256.png"
settings.Env.PWAIcon512 = "icons/pwa-icon-512.png"
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(settings.Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", settings.Env.PWAIconsDir, err)
}
// Load and prepare source data as PNG
sourceData, err := loadFaviconSource()
if err != nil {
return fmt.Errorf("failed to load favicon source: %w", err)
}
// Generate all icon sizes
if err := generateIconSizes(sourceData); err != nil {
return fmt.Errorf("failed to generate icon sizes: %w", err)
}
return nil
}
+96
View File
@@ -0,0 +1,96 @@
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)
}
+12 -6
View File
@@ -17,6 +17,7 @@ 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 (
@@ -85,6 +86,7 @@ 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
@@ -113,6 +115,8 @@ 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 (
@@ -386,6 +390,7 @@ 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",
@@ -421,11 +426,12 @@ 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
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
}
// resolvePathContext resolves all path characteristics in a SINGLE stat call
@@ -553,7 +559,7 @@ func (idx *Index) getDirInfoFromContext(ctx *PathContext, isViewable, isIndexabl
Recursive: false,
CheckViewable: true,
IsRoutineScan: req.IsRoutineScan,
SkipExtendedAttrs: !isIndexable, // Only fetch extended attrs if indexable
SkipExtendedAttrs: req.SkipExtendedAttrs || !isIndexable,
FollowSymlinks: req.FollowSymlinks,
ShowHidden: req.ShowHidden,
}
+90 -1
View File
@@ -247,6 +247,60 @@ var compressedFile = map[string]bool{
".zstd": true,
}
// Raw camera image: extension -> MIME type (single source for IsRawImage + type detection)
var rawImageMimeType = map[string]string{
".cr2": "image/x-canon-cr2",
".cr3": "image/x-canon-cr3",
".nef": "image/x-nikon-nef",
".nrw": "image/x-nikon-nef",
".arw": "image/x-sony-arw",
".srf": "image/x-sony-arw",
".sr2": "image/x-sony-arw",
".orf": "image/x-olympus-orf",
".rw2": "image/x-panasonic-rw2",
".raw": "image/x-panasonic-raw",
".dng": "image/x-adobe-dng",
".raf": "image/x-fuji-raf",
".pef": "image/x-pentax-pef",
".ptx": "image/x-pentax-pef",
".rwl": "image/x-leica-rwl",
".3fr": "image/x-hasselblad-3fr",
".fff": "image/x-hasselblad-fff",
".erf": "image/x-epson-erf",
".mrw": "image/x-minolta-mrw",
".dcr": "image/x-kodak-dcr",
".kdc": "image/x-kodak-dcr",
".dc2": "image/x-kodak-dc2",
".x3f": "image/x-sigma-x3f",
".iiq": "image/x-phaseone-iiq",
".nkc": "image/x-kodak-nkc",
".r3d": "image/x-red-r3d",
}
// 3D model extension -> MIME type (single source for Is3DModel + type detection)
var model3DMimeType = map[string]string{
".gltf": "model/gltf+json",
".glb": "model/gltf-binary",
".obj": "model/obj",
".stl": "model/stl",
".ply": "model/ply",
".dae": "model/vnd.collada+xml",
".3mf": "model/3mf",
".3ds": "model/3ds",
".usdz": "model/vnd.usdz+zip",
".usd": "model/vnd.usd+zip",
".usda": "model/vnd.usd+zip",
".usdc": "model/vnd.usd+zip",
".amf": "model/x-amf",
".vrml": "model/vrml",
".wrl": "model/vrml",
".vtk": "model/vtk",
".vtp": "model/vtk",
".pcd": "model/pcd",
".xyz": "model/xyz",
".vox": "model/vox",
}
var audioMetadataTypes = map[string]bool{
".mp3": true,
".flac": true,
@@ -298,6 +352,28 @@ func IsArchive(extension string) bool {
return compressedFile[extension]
}
// IsRawImage reports whether the extension is a known raw camera image format.
func IsRawImage(extension string) bool {
_, ok := rawImageMimeType[extension]
return ok
}
// RawImageMimeType returns the MIME type for a raw image extension, or "" if not raw.
func RawImageMimeType(extension string) string {
return rawImageMimeType[extension]
}
// Is3DModel reports whether the extension is a known 3D model format.
func Is3DModel(extension string) bool {
_, ok := model3DMimeType[extension]
return ok
}
// Model3DMimeType returns the MIME type for a 3D model extension, or "" if not a 3D model.
func Model3DMimeType(extension string) string {
return model3DMimeType[extension]
}
func IsOnlyOffice(name string) bool {
extention := filepath.Ext(name)
return onlyOfficeSupported[extention]
@@ -353,7 +429,17 @@ func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
name := i.Name
ext := strings.ToLower(filepath.Ext(name))
// Attempt MIME detection by file extension
// Raw image and 3D model types from single source (no duplicate extension lists)
if m := RawImageMimeType(ext); m != "" {
i.Type = m
return
}
if m := Model3DMimeType(ext); m != "" {
i.Type = m
return
}
// Other extension-based MIME detection
switch ext {
case ".md":
i.Type = "text/markdown"
@@ -361,6 +447,9 @@ func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
case ".heic", ".heif":
i.Type = "image/heic"
return
case ".kmz":
i.Type = "application/vnd.google-earth.kmz"
return
}
i.Type = strings.Split(mime.TypeByExtension(ext), ";")[0]
+19 -18
View File
@@ -3,16 +3,17 @@ package iteminfo
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
)
type ItemInfo struct {
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
HasPreview bool `json:"hasPreview"` // whether the file has a thumbnail preview
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
HasPreview bool `json:"hasPreview"` // whether the file has a thumbnail preview
IsShared bool `json:"isShared,omitempty"` // whether the file or folder is shared
}
// ExtendedItemInfo extends ItemInfo with optional metadata that's only populated on-demand
@@ -40,21 +41,21 @@ type MediaMetadata struct {
Genre string `json:"genre,omitempty"` // music/video genre
Track int `json:"track,omitempty"` // track number
Duration int `json:"duration,omitempty"` // duration in seconds
AlbumArt string `json:"albumArt,omitempty"` // base64 encoded album art / video thumbnail
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
}
// for efficiency, a response will be a pointer to the data
// extra calculated fields can be added here
type ExtendedFileInfo struct {
FileInfo
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []ffmpeg.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
Metadata *MediaMetadata `json:"metadata,omitempty"` // media metadata for audio/video files (includes duration)
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []utils.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
Metadata *MediaMetadata `json:"metadata,omitempty"` // media metadata for audio/video files (includes duration)
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
}
+46 -21
View File
@@ -8,19 +8,9 @@ import (
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language,omitempty"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
IsFile bool `json:"isFile"` // true for external files, false for embedded
}
type FFProbeOutput struct {
Streams []struct {
Index int `json:"index"`
@@ -31,20 +21,55 @@ type FFProbeOutput struct {
} `json:"streams"`
}
// detects subtitles for video files.
func (i *ExtendedFileInfo) DetectSubtitles(parentInfo *FileInfo) {
// GetSubtitles detects external subtitle files for video files.
// Embedded subtitles should be detected by ffmpeg and passed as embeddedSubs parameter.
func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
if !strings.HasPrefix(i.Type, "video") {
logger.Debug("subtitles are not supported for this file : " + i.Name)
return
}
// Use unified subtitle detection that finds both embedded and external
parentDir := filepath.Dir(i.RealPath)
i.Subtitles = ffmpeg.DetectAllSubtitles(i.RealPath, parentDir, i.ModTime)
}
ext := filepath.Ext(i.Name)
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
// LoadSubtitleContent loads the actual content for all detected subtitle tracks
func (i *ExtendedFileInfo) LoadSubtitleContent() error {
return ffmpeg.LoadAllSubtitleContent(i.RealPath, i.Subtitles, i.ModTime)
// Collect external subtitle files
var externalSubs []utils.SubtitleTrack
if parentInfo != nil && parentInfo.Files != nil {
for _, f := range parentInfo.Files {
fileExt := filepath.Ext(f.Name)
fileBase := strings.TrimSuffix(f.Name, fileExt)
// Check if this file has the same base name and a subtitle extension
if fileBase == baseWithoutExt && SubtitleExts[strings.ToLower(fileExt)] {
track := utils.SubtitleTrack{
Name: f.Name,
Embedded: false,
}
// Try to infer language from filename patterns like "video.en.srt"
parts := strings.Split(fileBase, ".")
if len(parts) > 1 {
lastPart := parts[len(parts)-1]
if len(lastPart) == 2 || len(lastPart) == 3 {
track.Language = lastPart
}
}
externalSubs = append(externalSubs, track)
}
}
}
// Sort external subtitles alphabetically for consistent ordering
sort.Slice(externalSubs, func(i, j int) bool {
return externalSubs[i].Name < externalSubs[j].Name
})
i.Subtitles = append(i.Subtitles, externalSubs...)
// Sort all subtitles for consistent ordering
sort.Slice(i.Subtitles, func(a, b int) bool {
return strings.ToLower(i.Subtitles[a].Name) < strings.ToLower(i.Subtitles[b].Name)
})
// Note: Content is NOT loaded here. Frontend will call /api/media/subtitles for each track
}
func (info *FileInfo) SortItems() {
+14 -2
View File
@@ -304,7 +304,7 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
}
_, ok = utils.DiskUsageCache.Get(cacheKey)
if !ok {
// Only fetch disk total if not cached (this is expensive, so we cache it)
// Only fetch disk total and used if not cached (this is expensive, so we cache it)
totalBytes, err := fileutils.GetPartitionSize(sourcePath)
if err != nil {
idx.mu.Lock()
@@ -314,6 +314,17 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
}
idx.SetUsage(totalBytes)
// Also fetch OS-reported partition used space (total - free)
usedAlt, err := fileutils.GetPartitionUsed(sourcePath)
if err != nil {
logger.Errorf("Failed to get partition used space for index %s: %v", sourceName, err)
usedAlt = 0
}
idx.mu.Lock()
idx.UsedAlt = usedAlt
idx.mu.Unlock()
utils.DiskUsageCache.Set(cacheKey, true)
}
@@ -335,13 +346,14 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
})
}
reducedIdx := idx.ReducedIndex
// Compute DiskUsed from database (total size of all files)
// Compute DiskUsed from database (total size of all files indexed)
diskUsed, err := idx.db.GetTotalSize(idx.Name)
if err != nil {
logger.Errorf("Failed to get total size for index %s: %v", sourceName, err)
diskUsed = 0
}
reducedIdx.DiskUsed = diskUsed
reducedIdx.UsedAlt = idx.UsedAlt
reducedIdx.DiskTotal = idx.DiskTotal
reducedIdx.Scanners = scannerInfos
reducedIdx.NumDirs = idx.getNumDirsUnlocked()
-1
View File
@@ -28,7 +28,6 @@ userDefaults:
disableSettings: false # disable the user from viewing the settings page
preview:
disableHideSidebar: true # disable the hide sidebar preview for previews and editors
highQuality: true # generate high quality thumbnail preview images
image: true # show thumbnail preview image for image files
video: true # show thumbnail preview image for video files
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
+92
View File
@@ -0,0 +1,92 @@
package preview
import (
"context"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
// hasEmbeddedPreview reports whether we should try exiftool for an embedded
// preview. Only true for types that often have one and lack a cheap native decode
func hasEmbeddedPreview(fileType string, fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
// Raw camera formats: we can't decode natively; exiftool is the main way to get a preview.
if iteminfo.IsRawImage(ext) {
return true
}
// HEIC/HEIF: often have embedded preview; we have FFmpeg fallback but exiftool can be faster.
if strings.HasPrefix(fileType, "image/hei") {
return true
}
return false
}
// ExtractEmbeddedPreview runs exiftool on the given file to extract an embedded
// preview image (e.g. JpgFromRaw, PreviewImage, ThumbnailImage). Returns the first
// non-empty result, or nil if exiftool is unavailable or no embedded preview exists.
// fileType is the MIME type (e.g. "image/x-canon-cr2", "image/heic", "video/quicktime").
func ExtractEmbeddedPreview(ctx context.Context, realPath, fileType string) ([]byte, error) {
if realPath == "" {
return nil, nil
}
path := settings.Config.Integrations.Media.ExiftoolPath
if path == "" {
return nil, nil
}
ext := strings.ToLower(filepath.Ext(realPath))
// Tag order: raw often has JpgFromRaw/PreviewImage; HEIC/video use PreviewImage; JPEG has ThumbnailImage.
tags := []string{"PreviewImage", "JpgFromRaw", "ThumbnailImage"}
if iteminfo.IsRawImage(ext) {
tags = []string{"JpgFromRaw", "PreviewImage", "ThumbnailImage"}
}
for _, tag := range tags {
out, err := runExiftoolTag(ctx, path, realPath, tag)
if err != nil {
continue
}
if len(out) > 0 {
return out, nil
}
}
return nil, nil
}
// GetOrientation returns the EXIF orientation string for the file (e.g. "Rotate 90 CW", "Horizontal (normal)").
// Uses exiftool -Orientation -s3. Returns empty string if exiftool is unavailable or orientation cannot be read.
func GetOrientation(ctx context.Context, realPath string) string {
if realPath == "" {
return ""
}
path := settings.Config.Integrations.Media.ExiftoolPath
if path == "" {
return ""
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, path, "-Orientation", "-s3", realPath)
out, err := cmd.CombinedOutput()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func runExiftoolTag(ctx context.Context, exiftoolPath, realPath, tag string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, exiftoolPath, "-b", "-"+tag, realPath)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, err
}
return out, nil
}
+44
View File
@@ -1,6 +1,7 @@
package preview
import (
"bytes"
"fmt"
"image"
"image/jpeg"
@@ -174,6 +175,10 @@ func (s *Service) ResizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
if opts.Format == FormatHeic {
opts.Format = FormatJpeg
}
// Ensure JPEG quality is set based on quality setting
if opts.JpegQuality == 0 {
opts.JpegQuality = opts.Quality.jpegQuality()
}
// Try to use embedded EXIF thumbnail for JPEGs (only for low quality to keep it simple)
if opts.Format == FormatJpeg && opts.Quality == QualityLow {
@@ -230,6 +235,45 @@ func (s *Service) ResizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
return imaging.Encode(out, img, opts.Format.toImaging())
}
// applyOrientationToPreviewBytes applies EXIF orientation to image bytes using the imaging library.
// orientation is the string from exiftool -Orientation -s3 (e.g. "Rotate 90 CW", "Horizontal (normal)").
// Returns rotated JPEG bytes or the original bytes on error/unknown orientation. No FFmpeg required.
func applyOrientationToPreviewBytes(imageBytes []byte, orientation string) []byte {
if len(imageBytes) < 100 {
return imageBytes
}
img, err := imaging.Decode(bytes.NewReader(imageBytes))
if err != nil {
return imageBytes
}
var out image.Image
switch orientation {
case "Rotate 90 CW", "Right-top", "6":
out = imaging.Rotate270(img)
case "Rotate 180", "Bottom-right", "3":
out = imaging.Rotate180(img)
case "Rotate 270 CW", "Left-bottom", "8":
out = imaging.Rotate90(img)
case "Mirror horizontal", "Top-right", "2":
out = imaging.FlipH(img)
case "Mirror vertical", "Bottom-left", "4":
out = imaging.FlipV(img)
case "Mirror horizontal and rotate 270 CW", "Right-bottom", "7":
out = imaging.Transverse(img)
case "Mirror horizontal and rotate 90 CW", "Left-top", "5":
out = imaging.Transpose(img)
case "Horizontal (normal)", "Top-left", "1", "":
return imageBytes
default:
return imageBytes
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, out, &jpeg.Options{Quality: 90}); err != nil {
return imageBytes
}
return buf.Bytes()
}
func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
// Optimize memory: only read enough bytes for EXIF parsing (max 64KB)
// EXIF data is typically in the first 64KB of JPEG files
+8 -3
View File
@@ -489,12 +489,17 @@ func TestCreatePreviewFromReader_UsesConcurrencyLimit(t *testing.T) {
data, err := io.ReadAll(source)
require.NoError(t, err)
out, err := svc.CreatePreviewFromReader(bytes.NewReader(data), "small")
// Get options for small preview
options, err := getPreviewOptions("small")
require.NoError(t, err)
// Create preview from reader
out, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
require.NoError(t, err)
require.NotEmpty(t, out)
// Same via CreatePreview (bytes) for parity
out2, err := svc.CreatePreview(data, "small")
// Same via another call for parity
out2, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
require.NoError(t, err)
require.Equal(t, out, out2)
}
+72
View File
@@ -0,0 +1,72 @@
package preview
import (
"context"
"sync/atomic"
)
// MemoryTracker wraps the existing FFmpegService to add memory tracking
type MemoryTracker struct {
maxMemoryBytes int64
usedMemoryBytes int64
semaphore chan struct{}
}
// NewMemoryTracker creates a memory-aware concurrency limiter
func NewMemoryTracker(maxConcurrent int, maxMemoryMB int) *MemoryTracker {
if maxConcurrent < 1 {
maxConcurrent = 1
}
if maxMemoryMB < 50 {
maxMemoryMB = 500 // Default 500MB
}
return &MemoryTracker{
maxMemoryBytes: int64(maxMemoryMB) * 1024 * 1024,
semaphore: make(chan struct{}, maxConcurrent),
}
}
// TryAcquire attempts to acquire resources for processing an image
// Returns false if memory limit would be exceeded
func (mt *MemoryTracker) TryAcquire(ctx context.Context, estimatedBytes int64) bool {
// Check if we have memory headroom
currentUsed := atomic.LoadInt64(&mt.usedMemoryBytes)
if currentUsed+estimatedBytes > mt.maxMemoryBytes {
return false
}
// Try to acquire semaphore slot (non-blocking check first)
select {
case mt.semaphore <- struct{}{}:
// Successfully acquired slot, now add memory
atomic.AddInt64(&mt.usedMemoryBytes, estimatedBytes)
return true
case <-ctx.Done():
return false
default:
return false
}
}
// Acquire waits to acquire resources
func (mt *MemoryTracker) Acquire(ctx context.Context, estimatedBytes int64) error {
// Wait for semaphore slot
select {
case mt.semaphore <- struct{}{}:
atomic.AddInt64(&mt.usedMemoryBytes, estimatedBytes)
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Release frees up resources
func (mt *MemoryTracker) Release(estimatedBytes int64) {
<-mt.semaphore
atomic.AddInt64(&mt.usedMemoryBytes, -estimatedBytes)
}
// GetMemoryUsageMB returns current memory usage in MB
func (mt *MemoryTracker) GetMemoryUsageMB() int64 {
return atomic.LoadInt64(&mt.usedMemoryBytes) / (1024 * 1024)
}
+268 -327
View File
@@ -4,12 +4,10 @@ import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
@@ -22,12 +20,14 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
const minPreviewSize = 100
var (
ErrUnsupportedFormat = errors.New("preview is not available for provided file format")
ErrUnsupportedMedia = errors.New("unsupported media type")
ErrPreviewTooSmall = errors.New("generated image is too small, likely an error occurred")
service *Service
)
@@ -164,10 +164,6 @@ func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
logger.Errorf("WARNING: StartPreviewGenerator called multiple times! This will create multiple semaphores!")
}
service = NewPreviewGenerator(concurrencyLimit, cacheDir)
// Generate PWA icons after service is initialized
GeneratePWAIcons()
return nil
}
@@ -176,153 +172,6 @@ func GetService() *Service {
return service
}
// GeneratePWAIcons generates all icon sizes from favicon.svg or favicon.png
func GeneratePWAIcons() {
if service == nil {
logger.Warning("Preview service not initialized, skipping icon generation")
return
}
// Determine source icon - prefer custom, fallback to default
var sourceIcon string
var sourceData []byte
var err error
var isSVG bool
if settings.Env.FaviconIsCustom {
sourceIcon = settings.Env.FaviconPath
logger.Debugf("Generating icons from custom favicon: %s", sourceIcon)
isSVG = strings.ToLower(filepath.Ext(sourceIcon)) == ".svg"
// Read the source file
sourceData, err = os.ReadFile(sourceIcon)
if err != nil {
logger.Warningf("Failed to read custom favicon: %v", err)
return
}
// If it's not a raster format that imaging library can decode, handle separately
if isSVG {
// SVG: Set PWA icons to use SVG directly, look for PNG for raster generation
logger.Debug("Source is SVG - will use directly where supported")
settings.Env.PWAIcon192 = "pwa-icon.svg"
settings.Env.PWAIcon256 = "pwa-icon.svg"
settings.Env.PWAIcon512 = "pwa-icon.svg"
// Look for PNG in same directory with same base name
basePath := sourceIcon[:len(sourceIcon)-len(filepath.Ext(sourceIcon))]
pngPath := basePath + ".png"
sourceData, err = os.ReadFile(pngPath)
if err != nil {
logger.Warningf("SVG favicon provided without PNG version at %s. For best compatibility across all browsers and platforms, provide a PNG version alongside your SVG.", pngPath)
return
}
logger.Debugf("Using PNG version (%s) for generating raster icons", pngPath)
} else {
// For any raster format (JPEG, PNG, GIF, WebP, etc.), decode and convert to PNG
// This creates a standardized PNG source for all icon generation
img, decodeErr := imaging.Decode(bytes.NewReader(sourceData))
if decodeErr != nil {
logger.Warningf("Failed to decode favicon image: %v", decodeErr)
return
}
// Re-encode as PNG in memory for consistent processing
var buf bytes.Buffer
if encodeErr := imaging.Encode(&buf, img, imaging.PNG); encodeErr != nil {
logger.Warningf("Failed to convert favicon to PNG: %v", encodeErr)
return
}
sourceData = buf.Bytes()
logger.Debugf("Converted %s favicon to PNG for icon generation", filepath.Ext(sourceIcon))
}
} else {
// Use default embedded favicon - we have both SVG and PNG versions
logger.Debug("Generating icons from default favicon")
// For default, use the PNG version for raster generation
assetFs := fileutils.GetAssetFS()
if assetFs == nil {
logger.Warning("Asset filesystem not initialized, skipping default icon generation")
return
}
pngPath := "img/icons/favicon.png"
sourceData, err = fs.ReadFile(assetFs, pngPath)
if err != nil {
logger.Warningf("Failed to read default favicon PNG: %v", err)
return
}
// Set PWA icons to use SVG for modern browsers
settings.Env.PWAIcon192 = "pwa-icon.svg"
settings.Env.PWAIcon256 = "pwa-icon.svg"
settings.Env.PWAIcon512 = "pwa-icon.svg"
}
// Define all icon sizes we need to generate
// Format: {size, filename, envVar pointer (optional)}
iconSizes := []struct {
size int
name string
env *string
}{
// Browser favicon
{32, "favicon-32x32.png", nil},
// PWA icons
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256},
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen
{256, "mstile-256x256.png", nil}, // Windows tile
}
allSuccess := true
generatedCount := 0
for _, iconSize := range iconSizes {
outputPath := filepath.Join(settings.Env.PWAIconsDir, iconSize.name)
// Create output file
outFile, err := os.Create(outputPath)
if err != nil {
logger.Warningf("Failed to create icon %s: %v", iconSize.name, err)
allSuccess = false
continue
}
// Resize image using the service's Resize method
err = service.Resize(
bytes.NewReader(sourceData),
outFile,
ResizeOptions{
Width: iconSize.size,
Height: iconSize.size,
ResizeMode: ResizeModeFill,
Quality: QualityHigh,
Format: FormatPng,
},
)
outFile.Close()
if err != nil {
logger.Warningf("Failed to generate icon %s: %v", iconSize.name, err)
os.Remove(outputPath)
allSuccess = false
continue
}
// Update environment variable if specified
if iconSize.env != nil {
*iconSize.env = filepath.Join("icons", iconSize.name)
}
generatedCount++
}
if allSuccess {
logger.Debugf("Successfully generated %d icon sizes", generatedCount)
} else {
logger.Warningf("Generated %d/%d icon sizes (some failed)", generatedCount, len(iconSizes))
}
}
func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
if !file.HasPreview {
return nil, ErrUnsupportedMedia
@@ -335,10 +184,10 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
// Generate fast cache key based on file metadata
var cacheHash string
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
// For audio with album art, hash the album art content
hasher := md5.New()
_, _ = hasher.Write([]byte(file.Metadata.AlbumArt))
_, _ = hasher.Write(file.Metadata.AlbumArt)
cacheHash = hex.EncodeToString(hasher.Sum(nil))
} else {
// For all other files, use fast metadata-based hash
@@ -352,11 +201,194 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
if data, found, err := service.fileCache.Load(ctx, cacheKey); err != nil {
return nil, fmt.Errorf("failed to load from cache: %w", err)
} else if found {
if len(data) < minPreviewSize {
return nil, ErrPreviewTooSmall
}
return data, nil
}
return GeneratePreviewWithMD5(ctx, file, previewSize, url, seekPercentage, cacheHash)
}
// filePreviewType represents the type of preview generation needed
type filePreviewType int
const (
previewTypeDocument filePreviewType = iota
previewTypeOffice
previewTypeHEIC
previewTypeImage
previewTypeVideo
previewTypeAudio
previewTypeUnsupported
)
// determinePreviewType determines what type of preview generation is needed
func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType {
// Check document conversion first
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
return previewTypeDocument
}
// Check office files
if file.OnlyOfficeId != "" {
return previewTypeOffice
}
// Check HEIC with conversion enabled
if strings.HasPrefix(file.Type, "image/heic") &&
*settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] {
return previewTypeHEIC
}
// Check by MIME type prefix
switch {
case strings.HasPrefix(file.Type, "image"):
return previewTypeImage
case strings.HasPrefix(file.Type, "video"):
return previewTypeVideo
case strings.HasPrefix(file.Type, "audio") && file.Metadata != nil && len(file.Metadata.AlbumArt) > 0:
return previewTypeAudio
default:
return previewTypeUnsupported
}
}
// generateRawPreview generates the initial preview image bytes based on file type
func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, hash string) ([]byte, error) {
previewType := determinePreviewType(file)
switch previewType {
case previewTypeDocument:
return s.generateDocumentPreview(ctx, file, hash)
case previewTypeOffice:
return s.generateOfficeFilePreview(ctx, file, officeUrl)
case previewTypeHEIC:
return s.generateHEICPreview(ctx, file, previewSize)
case previewTypeImage:
return s.generateImagePreview(ctx, file, previewSize)
case previewTypeVideo:
return s.generateVideoPreviewBytes(ctx, file, seekPercentage)
case previewTypeAudio:
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
return file.Metadata.AlbumArt, nil
}
return nil, nil
case previewTypeUnsupported:
ext := strings.ToLower(filepath.Ext(file.Name))
return nil, fmt.Errorf("unsupported media type: %s", ext)
default:
return nil, errors.New("unknown preview type")
}
}
// generateDocumentPreview generates preview for PDF and document files
func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, hash string) ([]byte, error) {
tempFilePath := filepath.Join(s.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
return imageBytes, nil
}
// generateOfficeFilePreview generates preview for Office files via OnlyOffice
func (s *Service) generateOfficeFilePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, officeUrl string) ([]byte, error) {
imageBytes, err := s.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
}
return imageBytes, nil
}
// generateHEICPreview generates preview for HEIC files using FFmpeg
func (s *Service) generateHEICPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
imageBytes, err := s.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
}
return imageBytes, nil
}
// generateVideoPreviewBytes generates preview frame from video file
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, seekPercentage int) ([]byte, error) {
// Check if this video format is enabled for preview generation
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if !settings.CanConvertVideo(ext) {
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
}
videoSeekPercentage := seekPercentage
if videoSeekPercentage == 0 {
videoSeekPercentage = 10
}
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
file.Name, file.RealPath, videoSeekPercentage, err)
}
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
return imageBytes, nil
}
// generateImagePreview generates preview for regular image files
func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
// Stream from file instead of os.ReadFile so we don't load every image fully into memory
f, err := os.Open(file.RealPath)
if err != nil {
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, err)
return nil, fmt.Errorf("failed to open image file: %w", err)
}
defer f.Close()
options, err := getPreviewOptions(previewSize)
if err != nil {
return nil, err
}
imageBytes, err := s.CreatePreview(f, file.Size, options)
if err != nil {
// For JPEG files, try FFmpeg fallback if initial decode failed
if strings.HasPrefix(file.Type, "image/jpeg") {
return handleJPEGFallback(ctx, s, file, previewSize, err)
}
return nil, fmt.Errorf("failed to create image preview: %w", err)
}
return imageBytes, nil
}
// handleJPEGFallback attempts to use FFmpeg for problematic JPEG files
// This is preview-specific logic for user files, not used for icon generation
func handleJPEGFallback(ctx context.Context, s *Service, file iteminfo.ExtendedFileInfo, previewSize string, originalErr error) ([]byte, error) {
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", originalErr)
}
if s.ffmpegService == nil {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", originalErr)
}
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, originalErr)
imageBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
return imageBytes, nil
}
func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, fileMD5 string) ([]byte, error) {
// Note: fileMD5 is actually a cache hash (metadata-based), not a true file content MD5
// Validate that cache hash is not empty to prevent cache corruption
@@ -374,13 +406,11 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// Acquire global image processor semaphore for ALL operations
const largeFileSizeThreshold = 8 * 1024 * 1024 // 8MB
if file.Size >= largeFileSizeThreshold && service.imageLargeSem != nil {
// Large file path - use imageLargeSem
if err := service.acquireImageLargeSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageLargeSem()
} else {
// Small file path or fallback - use imageSem
if err := service.acquireImageSem(ctx); err != nil {
return nil, err
}
@@ -400,122 +430,58 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, errors.New(message)
}
ext := strings.ToLower(filepath.Ext(file.Name))
var (
err error
imageBytes []byte
)
// Generate thumbnail image from video
// Generate hash for temp file paths
hasher := md5.New()
_, _ = hasher.Write([]byte(CacheKey(fileMD5, previewSize, seekPercentage)))
hash := hex.EncodeToString(hasher.Sum(nil))
// Check if HEIC conversion is enabled (guaranteed non-nil after defaults)
convertHEIC := strings.HasPrefix(file.Type, "image/heic") && *settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview]
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
// Generate an image from office document
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
tempFilePath := filepath.Join(service.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err = service.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
// If this file type might have an embedded preview, try exiftool first before any type-specific path.
var imageBytes []byte
fromExiftool := false
if hasEmbeddedPreview(file.Type, file.Name) {
if exifBytes, _ := ExtractEmbeddedPreview(ctx, file.RealPath, file.Type); len(exifBytes) >= minPreviewSize {
imageBytes = exifBytes
fromExiftool = true
// Apply EXIF orientation from source file using exiftool + imaging (no FFmpeg).
if orient := GetOrientation(ctx, file.RealPath); orient != "" {
if corrected := applyOrientationToPreviewBytes(imageBytes, orient); len(corrected) >= minPreviewSize {
imageBytes = corrected
}
}
}
} else if file.OnlyOfficeId != "" {
imageBytes, err = service.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
}
if !fromExiftool {
var err error
imageBytes, err = service.generateRawPreview(ctx, file, previewSize, officeUrl, seekPercentage, hash)
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
return nil, err
}
} else if convertHEIC {
// HEIC files need FFmpeg conversion to JPEG with proper size/quality handling
imageBytes, err = service.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
}
// For HEIC files, we've already done the resize/conversion, so cache and return directly
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
}
if len(imageBytes) < minPreviewSize {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes - likely an error occurred",
file.Name, file.Type, len(imageBytes))
_ = service.fileCache.Store(ctx, cacheKey, []byte{})
return nil, ErrPreviewTooSmall
}
// When we got bytes from exiftool, we still need to resize to small/large/xlarge (original is never converted).
// When we got bytes from type-specific path, HEIC/Image are already resized; others need resize below.
previewType := determinePreviewType(file)
if !fromExiftool && previewType == previewTypeHEIC {
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache HEIC image: %v", err)
}
return imageBytes, nil
} else if strings.HasPrefix(file.Type, "image") {
// Stream from file instead of os.ReadFile so we don't load every image fully into memory.
// ResizeWithSize() holds the imaging semaphore and only reads in chunks / decodes one at a time.
f, openErr := os.Open(file.RealPath)
if openErr != nil {
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, openErr)
return nil, fmt.Errorf("failed to open image file: %w", openErr)
}
defer f.Close()
imageBytes, err = service.CreatePreviewFromReaderWithSize(f, previewSize, file.Size)
if err != nil {
// Check if this is an unsupported JPEG format and FFmpeg fallback is enabled
errMsg := err.Error()
if strings.HasPrefix(file.Type, "image/jpeg") &&
(strings.Contains(errMsg, "unrecognised marker") ||
strings.Contains(errMsg, "invalid JPEG format") ||
strings.Contains(errMsg, "Huffman")) {
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
if enableJPEGFallback && service.ffmpegService != nil {
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, err)
imageBytes, err = service.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache FFmpeg-converted image: %v", err)
}
return imageBytes, nil
}
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", err)
}
return nil, fmt.Errorf("failed to create image preview: %w", err)
}
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes", file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
}
if !fromExiftool && previewType == previewTypeImage {
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
}
return imageBytes, nil
} else if strings.HasPrefix(file.Type, "video") {
// Check if this video format is enabled for preview generation
ext = strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if !settings.CanConvertVideo(ext) {
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
}
videoSeekPercentage := seekPercentage
if videoSeekPercentage == 0 {
videoSeekPercentage = 10
}
imageBytes, err = service.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
file.Name, file.RealPath, videoSeekPercentage, err)
}
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
} else if strings.HasPrefix(file.Type, "audio") {
// Extract album artwork from audio files
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
imageBytes, err = base64.StdEncoding.DecodeString(file.Metadata.AlbumArt)
if err != nil {
return nil, fmt.Errorf("failed to decode album artwork: %w", err)
}
} else {
return nil, nil
}
} else {
return nil, fmt.Errorf("unsupported media type: %s", ext)
}
// Check if context was cancelled during processing
@@ -523,62 +489,38 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, ctx.Err()
}
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes - likely an error occurred",
file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
// Resize if needed (exiftool bytes, or videos, audio, documents). Original is never converted.
if previewSize != "original" {
// resize image
resizedBytes, err := service.CreatePreview(imageBytes, previewSize)
options, err := getPreviewOptions(previewSize)
if err != nil {
// Check if this is an unsupported JPEG format (extended sequential, etc.)
errMsg := err.Error()
if strings.HasPrefix(file.Type, "image/jpeg") &&
(strings.Contains(errMsg, "unrecognised marker") ||
strings.Contains(errMsg, "invalid JPEG format") ||
strings.Contains(errMsg, "Huffman")) {
// Check if JPEG FFmpeg conversion is enabled (guaranteed non-nil after defaults)
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
// Only attempt FFmpeg fallback if it's enabled and FFmpeg service is available
if enableJPEGFallback && service.ffmpegService != nil {
// Fall back to FFmpeg for problematic JPEG files
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, err)
resizedBytes, err = service.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
// Cache and return the FFmpeg-converted image
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
logger.Errorf("failed to cache FFmpeg-converted image: %v", err)
}
return resizedBytes, nil
}
// If FFmpeg fallback is disabled or not available, return the original error
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image: %w", err)
return nil, err
}
// Cache and return
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
resizedBytes, err := service.CreatePreview(bytes.NewReader(imageBytes), 0, options)
if err != nil {
// For JPEG files, try FFmpeg fallback if resize failed
if strings.HasPrefix(file.Type, "image/jpeg") {
resizedBytes, err = handleJPEGFallback(ctx, service, file, previewSize, err)
if err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("failed to resize preview image: %w", err)
}
}
// Cache and return resized image
if err := service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
logger.Errorf("failed to cache resized image: %v", err)
}
return resizedBytes, nil
} else {
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache original image: %v", err)
}
return imageBytes, nil
}
// Cache and return original size
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache original image: %v", err)
}
return imageBytes, nil
}
func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
@@ -591,41 +533,40 @@ func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previe
return GeneratePreviewWithMD5(ctx, file, previewSize, officeUrl, seekPercentage, cacheHash)
}
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
return s.CreatePreviewFromReader(bytes.NewReader(data), previewSize)
}
// CreatePreviewFromReader resizes an image from a reader (e.g. *os.File) without loading it fully into memory.
// Used for the image preview path so only concurrencyLimit decodes run at once.
func (s *Service) CreatePreviewFromReader(reader io.Reader, previewSize string) ([]byte, error) {
return s.CreatePreviewFromReaderWithSize(reader, previewSize, 0)
}
// CreatePreviewFromReaderWithSize resizes an image with file size information for semaphore selection.
func (s *Service) CreatePreviewFromReaderWithSize(reader io.Reader, previewSize string, fileSize int64) ([]byte, error) {
var options ResizeOptions
// getPreviewOptions returns resize options for the given preview size
func getPreviewOptions(previewSize string) (ResizeOptions, error) {
switch previewSize {
case "xlarge":
return ResizeOptions{
Width: 1024,
Height: 1024,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Format: FormatJpeg,
}, nil
case "large":
options = ResizeOptions{
return ResizeOptions{
Width: 640,
Height: 640,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Format: FormatJpeg,
}
}, nil
case "small":
options = ResizeOptions{
return ResizeOptions{
Width: 256,
Height: 256,
ResizeMode: ResizeModeFit,
Quality: QualityMedium,
Format: FormatJpeg,
}
}, nil
default:
return nil, ErrUnsupportedFormat
return ResizeOptions{}, ErrUnsupportedFormat
}
}
// CreatePreview resizes an image from a reader with the given options
func (s *Service) CreatePreview(reader io.Reader, fileSize int64, options ResizeOptions) ([]byte, error) {
output := &bytes.Buffer{}
if err := s.ResizeWithSize(reader, output, fileSize, options); err != nil {
return nil, err
+1304 -660
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+409 -436
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 289 files, more files were not shown because too many files have changed in this diff. Show more