Compare commits

...
67 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 f11d8df7e8 Updated screenshots mechanism (#2042) 2026-02-27 15:52:12 -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
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 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 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 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 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 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
271 changed files with 21948 additions and 8197 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
+57
View File
@@ -2,6 +2,63 @@
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**:
-1
View File
@@ -43,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
-1
View File
@@ -35,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,
},
],
});
+90 -21
View File
@@ -116,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
@@ -152,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
@@ -176,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
}
@@ -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)
}
}
+8 -4
View File
@@ -182,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()
@@ -194,7 +194,7 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
} else {
fileutils.InitAssetFS(nil, false)
}
// Start preview service
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
@@ -202,12 +202,16 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
}
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
logger.Debugf("Exiftool Enabled : %v", settings.Config.Integrations.Media.ExiftoolPath != "")
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
// Initialize PWA manifest after icons are generated
icons.InitializePWAManifest()
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+22 -3
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
@@ -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 -11
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() {
@@ -280,7 +281,7 @@ func setupFrontend(generate bool) {
loadCustomFavicon()
}
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 {
@@ -314,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) {
@@ -336,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 {
@@ -349,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 == "" {
@@ -420,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 {
@@ -622,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 {
@@ -697,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),
+125 -17
View File
@@ -561,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)
@@ -575,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]
@@ -755,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
+8 -6
View File
@@ -52,12 +52,14 @@ 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.
-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))
+183 -135
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
@@ -255,14 +347,7 @@ 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()
s.setCacheAfterMove(l, oldSource, oldPath)
updated++
}
return updated, nil
@@ -283,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
@@ -298,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
}
@@ -309,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 -8
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"
@@ -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
+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
+5 -2
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
@@ -31,7 +32,7 @@ require (
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.46.1
modernc.org/sqlite v1.45.0
)
require (
@@ -49,6 +50,7 @@ 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
@@ -108,6 +110,7 @@ require (
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -270,7 +273,7 @@ require (
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-20260218203240-3dfff04db8fa // 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.34.0 // indirect
+24 -4
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=
@@ -94,6 +96,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
@@ -277,6 +281,8 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -293,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=
@@ -520,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=
@@ -920,8 +940,8 @@ 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-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
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-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
@@ -1293,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.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/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 -107
View File
@@ -11,7 +11,6 @@ import (
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
@@ -87,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 {
@@ -121,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.).
@@ -136,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
@@ -157,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
@@ -170,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
}
@@ -187,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")
@@ -257,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)
@@ -265,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
@@ -277,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
@@ -418,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)
}
@@ -1,11 +1,7 @@
package http
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -14,8 +10,6 @@ import (
"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"
@@ -86,8 +80,8 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q; filename*=utf-8''%s", dispositionType, asciiFileName, encodedFileName))
}
// 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
// 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:**
@@ -109,8 +103,8 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File or directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/raw [get]
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, 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"]
@@ -126,159 +120,6 @@ func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
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")
@@ -344,7 +185,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
return http.StatusInternalServerError, err
}
// Compute estimated download size
// 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
@@ -428,190 +269,8 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
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
// ** 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
+2 -2
View File
@@ -133,7 +133,7 @@ func (s *duplicateProcessingStats) shouldStop() (bool, string) {
//
// @Summary Find Duplicate Files
// @Description Finds duplicate files using multi-stage filtering: size → type → fuzzy filename → progressive checksums. Files must match on size, MIME type, and have 50%+ filename similarity before checksum verification. Large fuzzy groups (>10 files) are skipped to avoid false positives. Checksums use 2-pass progressive verification (header → middle) for accuracy while minimizing disk I/O (~16KB read per file).
// @Tags Duplicates
// @Tags Tools
// @Accept json
// @Produce json
// @Param source query string true "Source name for the desired source"
@@ -142,7 +142,7 @@ func (s *duplicateProcessingStats) shouldStop() (bool, string) {
// @Success 200 {object} duplicateResponse "List of duplicate file groups with metadata. Response includes 'incomplete' flag if processing stopped early due to resource limits."
// @Failure 400 {object} map[string]string "Bad Request"
// @Failure 503 {object} map[string]string "Service Unavailable (indexing in progress or another search running)"
// @Router /api/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 {
+68 -78
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") != "" {
@@ -190,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
}
}
@@ -265,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) {
@@ -346,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
@@ -386,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() {
@@ -397,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)
}
}
}()
@@ -443,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 -73
View File
@@ -74,51 +74,67 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
devMode: settings.Env.IsDevMode,
}
// Initialize cached PWA manifest
InitializePWAManifest()
// 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))
@@ -127,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)
}
})
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// subtitlesHandler handles subtitle requests for both external files and embedded streams
// @Summary Get subtitle content
// @Description Returns raw subtitle content from external files or embedded streams
// @Tags Subtitles
// @Tags Resources
// @Accept json
// @Produce text/plain
// @Param path query string true "Index path to the video file"
+164 -47
View File
@@ -113,8 +113,8 @@ func withHashFileHelper(fn handleFunc) handleFunc {
pathWithoutUserScope = pathWithoutUserScope + "/"
}
data.IndexPath = pathWithoutUserScope
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") {
// no need to fetch file info for uploads
// 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)
}
file, err := FileInfoFasterFunc(utils.FileOptions{
@@ -127,7 +127,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ShowHidden: link.ShowHidden,
FollowSymlinks: true,
}, store.Access, data.shareUser)
}, store.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")
@@ -312,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 {
@@ -330,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)
}
}
@@ -357,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 == "" {
@@ -422,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)
@@ -436,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 {
@@ -463,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)
@@ -484,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)
@@ -514,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 {
@@ -523,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")
}
}
+36 -5
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")
@@ -116,7 +131,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
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
@@ -265,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
@@ -318,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
@@ -327,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())
}
@@ -528,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)
@@ -575,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
+78 -69
View File
@@ -53,7 +53,7 @@ func isClientCancellation(ctx context.Context, err error) bool {
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Failure 500 {object} map[string]string "Internal server error"
// @Failure 501 {object} map[string]string "Preview generation not implemented"
// @Router /api/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")
@@ -71,7 +71,7 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
Source: source,
AlbumArt: true, // Extract album art for audio previews
Expand: true,
}, store.Access, d.user)
}, store.Access, d.user, store.Share)
if err != nil {
logger.Errorf("error getting file info: %v", err)
return errToStatus(err), err
@@ -99,60 +99,58 @@ 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) {
@@ -164,14 +162,39 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
return http.StatusBadRequest, fmt.Errorf("this item does not have a preview")
}
seekPercentage := 0
percentage := r.URL.Query().Get("atPercentage")
if percentage != "" {
var err error
seekPercentage, err = strconv.Atoi(percentage)
if err != nil {
seekPercentage = 0
}
if seekPercentage < 0 || seekPercentage > 100 {
seekPercentage = 0
}
}
// For directories: map atPercentage to frame index 0–3 for motion preview (cycle over previewable items)
var dirFrameIndex int
if d.fileInfo.Type == "directory" {
// 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)
@@ -179,29 +202,15 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
resizable := iteminfo.ResizableImageTypes[ext]
// If image is under 500KB, serve the original instead of generating a preview
const maxSizeForOriginal = 500 * 1024 // 500KB
if (!resizable || config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
// 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
+50 -17
View File
@@ -18,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"
@@ -38,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")
}
@@ -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,7 +139,7 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *request
// publicUploadHandler processes file uploads to a public upload share.
// @Summary Upload files to a public upload share
// @Description Handles file and directory uploads to an upload-only public share. Supports chunked uploads, conflict resolution (override), and directory creation.
// @Tags Public Shares
// @Tags Shares
// @Accept multipart/form-data
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -181,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"
@@ -198,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"
@@ -209,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")
@@ -229,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"
@@ -282,7 +281,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
FollowSymlinks: true,
Path: d.IndexPath,
Source: d.share.Source,
}, store.Access, d.shareUser)
}, store.Access, d.shareUser, store.Share)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
@@ -299,7 +298,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// publicBulkDeleteHandler deletes multiple resources from a public share in a single request.
// @Summary Bulk delete resources from public share
// @Description Deletes multiple resources specified in the request body. Returns a list of succeeded and failed deletions.
// @Tags Public Shares
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -309,7 +308,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or empty items array"
// @Failure 403 {object} map[string]string "Forbidden - delete not allowed for this share"
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
// @Router /public/api/resources/bulk/delete [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")
@@ -326,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"
@@ -395,7 +394,7 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// getShareImage serves banner or favicon files for shares as resizable previews
// @Summary Get share image (banner or favicon) as preview
// @Description Returns a resizable preview (large size) for the banner or favicon file of a share
// @Tags Public Shares
// @Tags Shares
// @Produce image/jpeg
// @Param hash query string true "Share hash"
// @Param banner query bool false "Request banner file"
@@ -433,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)
@@ -466,7 +465,6 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
}
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
// Get the asset query string from share
var assetQueryString string
if isBanner {
@@ -490,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)
}
+41 -8
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,7 +204,7 @@ type MoveCopyResponse struct {
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or empty items array"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
// @Router /api/resources/bulk/delete [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 {
@@ -274,7 +277,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
Path: indexPath,
Source: source,
ShowHidden: true,
}, store.Access, filePermUser)
}, store.Access, filePermUser, store.Share)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
@@ -330,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, filePermUser)
}, store.Access, filePermUser, store.Share)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -487,7 +490,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser)
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)
@@ -546,7 +549,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusOK, nil
}
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser)
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)
@@ -927,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
@@ -965,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)
}
+4 -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 {
+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)
}
+5 -8
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
@@ -154,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")
@@ -561,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)
}
@@ -584,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)
}
@@ -601,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)
+9 -90
View File
@@ -13,10 +13,10 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/icons"
)
var templateRenderer *TemplateRenderer
var cachedManifest PWAManifest // generated at startup
type TemplateRenderer struct {
templates *template.Template
@@ -219,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,
@@ -230,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
@@ -282,7 +285,7 @@ func manifestHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
if err := json.NewEncoder(w).Encode(cachedManifest); err != nil {
if err := json.NewEncoder(w).Encode(icons.CachedManifest); err != nil {
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
}
}
@@ -323,9 +326,9 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
}
// Fall back to embedded default favicon.png
assetPath = "img/icons/favicon.png"
case "favicon-32x32.png",
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
"apple-touch-icon.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 {
@@ -334,7 +337,7 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "mstile-256x256.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 {
@@ -395,87 +398,3 @@ func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
}
return handleWithStaticData(w, r, d, "index.html", "text/html")
}
// PWAManifest represents the web app manifest structure
type PWAManifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Icons []PWAIcon `json:"icons"`
StartURL string `json:"start_url"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Description string `json:"description"`
}
// PWAIcon represents an icon in the web app manifest
type PWAIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
}
// generatePWAManifest creates the PWA manifest structure
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
shortName := name
if len(name) > 12 {
shortName = name[:12]
}
return PWAManifest{
Name: name,
ShortName: shortName,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
ThemeColor: themeColor,
Description: description,
Icons: []PWAIcon{
{
Src: pwaIcon192,
Sizes: "192x192",
Type: "image/png",
Purpose: "any",
},
{
Src: pwaIcon256,
Sizes: "256x256",
Type: "image/png",
Purpose: "any maskable",
},
{
Src: pwaIcon512,
Sizes: "512x512",
Type: "image/png",
Purpose: "any",
},
},
}
}
// InitializePWAManifest generates and caches the PWA manifest at startup
func InitializePWAManifest() {
staticURL := config.Server.BaseURL + "public/static"
title := config.Frontend.Name
description := config.Frontend.Description
defaultThemeColor := "#455a64"
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
favicon := staticURL + "/favicon"
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
pwaIcon192 = staticURL + "/pwa-icon-192.png"
pwaIcon256 = staticURL + "/pwa-icon-256.png"
pwaIcon512 = staticURL + "/pwa-icon-512.png"
}
cachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
}
+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
}
+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]
+7 -6
View File
@@ -7,12 +7,13 @@ import (
)
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
+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
}
+40
View File
@@ -1,6 +1,7 @@
package preview
import (
"bytes"
"fmt"
"image"
"image/jpeg"
@@ -234,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
+44 -28
View File
@@ -22,9 +22,12 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
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
)
@@ -198,6 +201,9 @@ 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)
@@ -358,11 +364,6 @@ func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.Extend
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))
}
return imageBytes, nil
}
@@ -434,26 +435,50 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
_, _ = hasher.Write([]byte(CacheKey(fileMD5, previewSize, seekPercentage)))
hash := hex.EncodeToString(hasher.Sum(nil))
// Generate raw preview based on file type
imageBytes, err := service.generateRawPreview(ctx, file, previewSize, officeUrl, seekPercentage, hash)
if err != nil {
return nil, err
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
// 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
}
}
}
}
// For HEIC files, we've already done the resize/conversion, cache and return directly
if !fromExiftool {
var err error
imageBytes, err = service.generateRawPreview(ctx, file, previewSize, officeUrl, seekPercentage, hash)
if err != nil {
return nil, err
}
}
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 previewType == previewTypeHEIC {
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
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
}
// For regular images that were already resized, cache and return
if previewType == previewTypeImage {
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
@@ -464,14 +489,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, ctx.Err()
}
// Validate generated image size
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 (for videos, audio, documents, office files)
// Resize if needed (exiftool bytes, or videos, audio, documents). Original is never converted.
if previewSize != "original" {
options, err := getPreviewOptions(previewSize)
if err != nil {
@@ -492,7 +510,6 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
}
// Cache and return resized image
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
logger.Errorf("failed to cache resized image: %v", err)
}
@@ -500,7 +517,6 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
}
// Cache and return original size
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache original image: %v", err)
}
+1278 -644
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
+10 -3
View File
@@ -24,6 +24,7 @@
"plyr": "^3.8.4",
"qrcode.vue": "^3.4.1",
"srt-support-for-html5-videos": "^2.6.11",
"three": "^0.182.0",
"vue": "^3.4.21",
"vue-i18n": "^9.10.2",
"vue-lazyload": "^3.0.0",
@@ -2441,9 +2442,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -6268,6 +6269,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/three": {
"version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT"
},
"node_modules/tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
+1
View File
@@ -36,6 +36,7 @@
"plyr": "^3.8.4",
"qrcode.vue": "^3.4.1",
"srt-support-for-html5-videos": "^2.6.11",
"three": "^0.182.0",
"vue": "^3.4.21",
"vue-i18n": "^9.10.2",
"vue-lazyload": "^3.0.0",
+4 -4
View File
@@ -10,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/local-setup",
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/local",
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. */
@@ -41,7 +41,7 @@ export default defineConfig({
{
name: "dark-screenshots",
use: {
...devices["Desktop Firefox"],
...devices["Desktop Chrome"],
theme: 'dark',
},
testMatch: /.*screenshots.spec.ts/,
@@ -50,7 +50,7 @@ export default defineConfig({
{
name: "light-screenshots",
use: {
...devices["Desktop Firefox"],
...devices["Desktop Chrome"],
theme: 'light',
},
testMatch: /.*screenshots.spec.ts/,
+42 -12
View File
@@ -20,8 +20,8 @@ server:
utc: false # use UTC time in the output instead of local time
database: "database.db" # path to the database file
sources: # validate:required,dive
- path: "." # file system path. (Can be relative) validate:required
name: "backend" # display name
- path: "/relative/or/absolute/path" # file system path. (Can be relative) validate:required
name: "Source Name" # display name
config:
denyByDefault: false # deny access unless an "allow" access rule was specifically created.
private: false # designate as source as private -- currently just means no sharing permitted.
@@ -58,14 +58,19 @@ server:
cacheSizeMB: 32 # size of the SQLite cache in MB
walMode: false # enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
disableReuse: false # enable to always create a new indexing database on startup.
disableWebDAV: false # disable webdav support (default: false)
auth:
tokenExpirationHours: 2 # time in hours each web UI session token is valid for. Default is 2 hours.
methods:
proxy: # validate:omitempty
enabled: false
createUser: false # create user if not exists
header: "" # required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
enabled: false # whether to enable this authentication method.
adminGroup: "" # if set, users in this group will be granted admin privileges
userGroups: # if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
groupsClaim: "" # the JSON field name to read groups from. Default is "groups"
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: false # disable TLS verification (insecure, for testing only)
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
header: "" # required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
noauth: false # if set to true, overrides all other auth methods and disables authentication
password: # validate:omitempty
enabled: true
@@ -77,17 +82,41 @@ auth:
secret: "" # validate:required
enforcedOtp: false # if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
oidc: # validate:omitempty
enabled: false # whether to enable OIDC authentication
enabled: false # whether to enable this authentication method.
adminGroup: "" # if set, users in this group will be granted admin privileges
userGroups: # if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
groupsClaim: "groups" # the JSON field name to read groups from. Default is "groups"
userIdentifier: "preferred_username" # 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: false # disable TLS verification (insecure, for testing only)
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
clientId: "" # secret: client id of the OIDC application
clientSecret: "" # secret: client secret of the OIDC application
issuerUrl: "" # authorization URL of the OIDC provider
scopes: "openid email profile" # scopes to request from the OIDC provider
userIdentifier: "preferred_username" # the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
disableVerifyTLS: false # disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
ldap: # validate:omitempty
enabled: false # whether to enable this authentication method.
adminGroup: "" # if set, users in this group will be granted admin privileges
userGroups: # if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
groupsClaim: "" # the JSON field name to read groups from. Default is "groups"
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: false # disable TLS verification (insecure, for testing only)
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
createUser: false # create user if not exists
adminGroup: "" # if set, users in this group will be granted admin privileges.
groupsClaim: "groups" # the JSON field name to read groups from. Default is "groups"
server: "" # scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
baseDN: "" # LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
userDN: "" # Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
userPassword: "" # Bind password for service account
userFilter: "" # Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
jwt: # validate:omitempty
enabled: false # whether to enable this authentication method.
adminGroup: "" # if set, users in this group will be granted admin privileges
userGroups: # if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
groupsClaim: "" # the JSON field name to read groups from. Default is "groups"
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: false # disable TLS verification (insecure, for testing only)
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
header: "" # HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
secret: "" # secret: shared secret key for verifying JWT token signatures (required)
algorithm: "" # JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
key: "" # secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
adminUsername: "admin" # secret: the username of the admin user. If not set, the default is "admin".
adminPassword: "admin" # secret: the password of the admin user. If not set, the default is "admin".
@@ -119,6 +148,7 @@ frontend:
favicon: "" # path to a favicon to use for the frontend
description: "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server" # description that shows up in html head meta description
loginIcon: "" # path to an image file for the login page icon
loginButtonText: "" # text to display on the login button
oidcLoginButtonText: "" # text to display on the OIDC login button
userDefaults:
editorQuickSave: false # show quick save button in editor
@@ -141,7 +171,6 @@ userDefaults:
disableSettings: false # disable the user from viewing the settings page
preview:
disableHideSidebar: false # keep sidebar open when previewing files
highQuality: true # use high quality thumbnails
image: true # show thumbnails for image files
video: true # show thumbnails for video files
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
@@ -204,3 +233,4 @@ integrations:
wmv: true
debug: false # output ffmpeg stdout for media integration -- careful can produces lots of output!
extractEmbeddedSubtitles: false # extract embedded subtitles from media files
exiftoolPath: "" # path to exiftool executable
+6 -6
View File
@@ -7,7 +7,7 @@ import { getApiPath } from '@/utils/url.js'
* @returns {Promise<any>}
*/
export async function get(source, path) {
const apiPath = getApiPath('api/access', { source, path }, true)
const apiPath = getApiPath('access', { source, path })
return fetchJSON(apiPath)
}
/**
@@ -15,14 +15,14 @@ export async function get(source, path) {
* @returns {Promise<any>}
*/
export async function getAll(source) {
const apiPath = getApiPath('api/access', { source }, true)
const apiPath = getApiPath('access', { source })
return fetchJSON(apiPath)
}
/**
* @returns {Promise<{groups: string[]}>}
*/
export async function getGroups() {
const apiPath = getApiPath('api/access/groups', {}, true)
const apiPath = getApiPath('access/groups', {})
return fetchJSON(apiPath)
}
/**
@@ -32,7 +32,7 @@ export async function getGroups() {
* @returns {Promise<any>}
*/
export async function add(source, path, body) {
const apiPath = getApiPath('api/access', { source, path }, true);
const apiPath = getApiPath('access', { source, path });
return fetchJSON(apiPath, {
method: 'POST',
body: JSON.stringify(body),
@@ -52,7 +52,7 @@ export async function del(source, path, body) {
if (cascade) {
params.cascade = 'true';
}
const apiPath = getApiPath('api/access', params, true);
const apiPath = getApiPath('access', params);
return fetchJSON(apiPath, {
method: 'DELETE'
});
@@ -64,7 +64,7 @@ export async function del(source, path, body) {
* @returns {Promise<any>}
*/
export async function updatePath(source, oldPath, newPath) {
const apiPath = getApiPath('api/access', {}, true);
const apiPath = getApiPath('access', {});
return fetchJSON(apiPath, {
method: 'PATCH',
body: JSON.stringify({ source, oldPath, newPath }),
+77
View File
@@ -0,0 +1,77 @@
import { fetchURL } from "./utils";
import { getApiPath } from "@/utils/url.js";
import { notify } from "@/notify";
/**
* Create an archive on the server (server-side only).
* @param {Object} opts
* @param {string} opts.fromSource - Source name where paths to archive live
* @param {string} [opts.toSource] - Source where to write the archive (default: fromSource)
* @param {string[]} opts.paths - Paths to add (files/dirs; folders are walked; access-denied skipped)
* @param {string} opts.destination - Path where to write the archive (e.g. /folder/out.zip)
* @param {string} [opts.format] - "zip" or "tar.gz"; default from destination extension
* @param {number} [opts.compression] - 0-9 for tar.gz; 0 = default
* @param {boolean} [opts.deleteAfter] - Delete source files/folders after successful creation
*/
export async function createArchive(opts) {
const { fromSource, toSource, paths, destination, format, compression, deleteAfter } = opts;
if (!fromSource || !paths?.length || !destination) {
throw new Error("fromSource, paths, and destination are required");
}
const body = {
fromSource,
paths,
destination,
...(toSource && toSource !== fromSource && { toSource }),
...(format && { format }),
...(compression !== undefined && compression !== null && { compression }),
...(deleteAfter && { deleteAfter: true }),
};
try {
const apiPath = getApiPath("resources/archive");
const response = await fetchURL(apiPath, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json();
} catch (err) {
notify.showError(err.message || "Error creating archive");
throw err;
}
}
/**
* Unarchive (extract) an archive on the server (server-side only).
* @param {Object} opts
* @param {string} opts.fromSource - Source where the archive file lives
* @param {string} [opts.toSource] - Source to extract to (default: fromSource)
* @param {string} opts.path - Archive file path
* @param {string} opts.destination - Directory path to extract into
* @param {boolean} [opts.deleteAfter] - Delete archive after successful extract
*/
export async function unarchive(opts) {
const { fromSource, toSource, path, destination, deleteAfter } = opts;
if (!fromSource || !path || !destination) {
throw new Error("fromSource, path, and destination are required");
}
const body = {
fromSource,
...(toSource && toSource !== fromSource && { toSource }),
path,
destination,
...(deleteAfter && { deleteAfter: true }),
};
try {
const apiPath = getApiPath("resources/unarchive");
const response = await fetchURL(apiPath, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json();
} catch (err) {
notify.showError(err.message || "Error extracting archive");
throw err;
}
}
+164
View File
@@ -0,0 +1,164 @@
import { fetchURL, fetchJSON } from '@/api/utils'
import { getApiPath } from '@/utils/url.js'
import { notify } from '@/notify'
// POST /api/auth/login
export async function login(username, password, recaptcha, otp) {
if (!otp) {
otp = ''
}
if (!recaptcha) {
recaptcha = ''
}
if (!password) {
password = ''
}
const params = { username, recaptcha };
let apiPath = getApiPath('auth/login', params);
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-Password': encodeURIComponent(password),
'X-Secret': otp,
}
});
const bodyText = await res.text();
let body;
try {
body = JSON.parse(bodyText);
} catch {
body = { message: bodyText };
}
if (res.status != 200) {
const msg = body.message || 'Forbidden';
throw new Error(msg);
}
}
// POST /api/auth/logout
export async function logout() {
try {
const apiPath = getApiPath('auth/logout')
await fetchURL(apiPath, { method: 'POST' })
} catch (err) {
notify.showError(err.message || 'Failed to logout')
throw err
}
}
// POST /api/auth/signup
export async function signup(username, password, otp) {
const params = { username, password, otp }
let apiPath = getApiPath('auth/signup', params)
const res = await fetch(apiPath, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
if (res.status !== 201) {
let errorMessage = res.status
try {
const errorData = await res.json()
if (errorData.message) {
errorMessage = errorData.message
}
} catch (parseError) {
// If parsing fails, keep the status code as error message
}
throw new Error(errorMessage)
}
}
// POST /api/auth/otp/generate
export async function generateOTP(username, password) {
const params = { username }
try {
let apiPath = getApiPath('auth/otp/generate', params)
const res = await fetch(apiPath, {
method: 'POST',
headers: {
'X-Password': encodeURIComponent(password),
}
})
return await res.json()
} catch (error) {
notify.showError(error || 'Failed to generate OTP')
throw error
}
}
// POST /api/auth/otp/verify
export async function verifyOTP(username, password, otp) {
const params = { username }
try {
let apiPath = getApiPath('auth/otp/verify', params)
const res = await fetch(apiPath, {
method: 'POST',
headers: {
'X-Password': encodeURIComponent(password),
'X-Secret': otp,
}
})
if (res.status != 200) {
throw new Error('Failed to verify OTP')
}
} catch (error) {
notify.showError(error || 'Failed to verify OTP')
throw error
}
}
// POST /api/auth/renew
export async function renew() {
try {
const apiPath = getApiPath('auth/renew')
await fetchURL(apiPath, { method: 'POST' })
} catch (err) {
notify.showError(err.message || 'Failed to renew token')
throw err
}
}
// GET /api/auth/token/list
export async function getApiKeys() {
try {
const apiPath = getApiPath('auth/token/list')
return await fetchJSON(apiPath)
} catch (err) {
notify.showError(err.message || 'Failed to get API tokens')
throw err
}
}
// PUT /api/auth/token
export async function createApiKey(params) {
try {
const apiPath = getApiPath('auth/token', params)
await fetchURL(apiPath, {
method: 'POST'
})
} catch (err) {
notify.showError(err.message || 'Failed to create API token')
throw err
}
}
// DELETE /api/auth/token
export function deleteApiKey(params) {
try {
const apiPath = getApiPath('auth/token', params)
fetchURL(apiPath, {
method: 'DELETE'
})
} catch (err) {
notify.showError(err.message || 'Failed to delete API token')
throw err
}
}
+30 -6
View File
@@ -1,10 +1,34 @@
import * as filesApi from "./files";
import * as publicApi from "./public";
// Backend route organization:
// - /api/users/ -> users.js
// - /api/auth/ -> auth.js
// - /api/resources/ -> resources.js
// - /api/access/ -> access.js
// - /api/share/ (and /api/shares/) -> share.js
// - /api/settings/ -> settings.js
// - /api/tools/ -> tools.js
// - /api/office/ -> office.js
// - /api/media/ -> media.js
// - /public/api/* -> public functions in respective files (e.g., resourcesApi.fetchFilesPublic)
import * as authApi from "./auth";
import * as usersApi from "./users";
import * as settingsApi from "./settings";
import * as resourcesApi from "./resources";
import * as accessApi from "./access";
import * as shareApi from "./share";
import search from "./search";
import * as settingsApi from "./settings";
import * as toolsApi from "./tools";
import * as officeApi from "./office";
import * as mediaApi from "./media";
export {
authApi,
usersApi,
resourcesApi,
accessApi,
shareApi,
settingsApi,
toolsApi,
officeApi,
mediaApi
};
// Note: shareApi has been consolidated into publicApi
export { filesApi, publicApi, usersApi, settingsApi, shareApi, search, accessApi };
+21
View File
@@ -0,0 +1,21 @@
import { fetchURL } from "./utils";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
// GET /api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const apiPath = getApiPath('media/subtitles', {
source: source,
path: path,
name: subtitleName,
embedded: embedded.toString()
})
const res = await fetchURL(apiPath)
const content = await res.text()
return content
} catch (err) {
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
throw err
}
}
+49
View File
@@ -0,0 +1,49 @@
import { fetchURL } from './utils'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { notify } from '@/notify'
// GET /api/office/config or /public/api/office/config
export async function getConfig(req) {
try {
const params = {
path: req.path,
...(req.hash && { hash: req.hash }),
...(req.source && { source: req.source })
}
let apiPath
if (req.hash) {
apiPath = getPublicApiPath('office/config', params)
} else {
apiPath = getApiPath('office/config', params)
}
const res = await fetchURL(apiPath)
return await res.json()
} catch (err) {
notify.showError(err.message || 'Error fetching OnlyOffice configuration')
throw err
}
}
// POST /api/office/callback or /public/api/office/callback
export async function callback(params, hash = null) {
try {
let apiPath
if (hash) {
apiPath = getPublicApiPath('office/callback', { hash, ...params })
} else {
apiPath = getApiPath('office/callback', params)
}
const res = await fetchURL(apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
})
return await res.json()
} catch (err) {
notify.showError(err.message || 'Error sending OnlyOffice callback')
throw err
}
}
-368
View File
@@ -1,368 +0,0 @@
import { adjustedData } from "./utils";
import { notify } from "@/notify";
import { getPublicApiPath } from "@/utils/url.js";
import { state } from "@/store";
// ============================================================================
// PUBLIC API ENDPOINTS (hash-based authentication)
// ============================================================================
// Fetch public share data
/**
* @param {string} path
* @param {string} hash
* @param {string} password
* @param {boolean} content
* @param {boolean} metadata
* @returns {Promise<any>}
*/
export async function fetchPub(path, hash, password = "", content = false, metadata = false) {
const params = {
path: path,
hash,
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' }),
...(state.shareInfo.token && { token: state.shareInfo.token })
}
const apiPath = getPublicApiPath("resources", params);
const response = await fetch(apiPath, {
headers: {
"X-SHARE-PASSWORD": password || "",
},
});
if (!response.ok) {
const error = new Error(response.statusText);
// attempt to marshal json response
let data = null;
try {
data = await response.json()
} catch (e) {
// ignore
}
if (data) {
error.message = data.message;
}
(/** @type {any} */ (error)).status = response.status;
throw error;
}
let data = await response.json()
const adjusted = adjustedData(data);
return adjusted
}
// Generate a download URL
/**
* @param {{ path: string; hash: string; token: string; inline?: boolean }} share
* @param {string[]} files
* @returns {string}
*/
export function getDownloadURL(share, files, inline=false) {
// Handle array of files for repeated 'file' parameters
const fileArray = Array.isArray(files) ? files : [files]
const params = {
file: fileArray, // Array of file paths - getPublicApiPath will create repeated parameters
hash: share.hash,
token: share.token,
...(inline && { inline: 'true' })
}
const apiPath = getPublicApiPath("raw", params)
return window.origin + apiPath
}
// Generate a preview URL for public shares
/**
* @param {string} path
* @param {string} size - The size parameter (small, large, original). Omit for default (small).
* @returns {string}
*/
export function getPreviewURL(path, size) {
try {
const params = {
path: path,
hash: state.shareInfo.hash,
inline: 'true',
// Only add size parameter if specified and not the default
...(size && size !== 'small' && { size: size }),
...(state.shareInfo.token && { token: state.shareInfo.token })
}
const apiPath = getPublicApiPath('preview', params)
return window.origin + apiPath
} catch (/** @type {any} */ err) {
notify.showError(err.message || 'Error getting preview URL')
throw err
}
}
export function post(
hash,
path,
content = "",
overwrite = false,
onupload,
headers = {},
isDir = false
) {
if (!hash || hash === undefined || hash === null) {
throw new Error('no hash provided')
}
let sharePassword = localStorage.getItem("sharepass:" + hash);
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
try {
const apiPath = getPublicApiPath("resources", {
path: path,
hash: hash,
override: overwrite,
...(isDir && { isDir: 'true' })
});
const request = new XMLHttpRequest();
request.open("POST", apiPath, true);
for (const header in headers) {
request.setRequestHeader(header, headers[header]);
}
if (typeof onupload === "function") {
request.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = Math.round(
(event.loaded / event.total) * 100
);
onupload(percentComplete); // Pass the percentage to the callback
}
};
}
const promise = new Promise((resolve, reject) => {
request.onload = () => {
if (request.status >= 200 && request.status < 300) {
resolve(request.responseText);
} else if (request.status === 409) {
const error = new Error("conflict");
error.response = { status: request.status, responseText: request.responseText };
reject(error);
} else {
// Parse error message from response
let errorMessage = "Upload failed";
try {
const errorData = JSON.parse(request.responseText);
errorMessage = errorData.message || errorMessage;
} catch (e) {
// If parsing fails, use responseText or default message
errorMessage = request.responseText || errorMessage;
}
const error = new Error(errorMessage);
error.status = request.status;
// Show notification for upload errors
notify.showError(errorMessage);
reject(error);
}
};
request.onerror = () => {
const error = new Error("Network error");
notify.showError("Network error during upload");
reject(error);
};
request.onabort = () => {
const error = new Error("Upload aborted");
notify.showError("Upload was aborted");
reject(error);
};
if (
content instanceof Blob &&
!["http:", "https:"].includes(window.location.protocol)
) {
new Response(content).arrayBuffer()
.then(buffer => request.send(buffer))
.catch(err => reject(err));
} else {
request.send(content);
}
});
promise.xhr = request;
return promise;
} catch (err) {
notify.showError(err.message || "Error posting resource");
// We are returning a promise, so we should return a rejected promise on error.
return Promise.reject(err);
}
}
async function resourceAction(hash, path, method, content, token = "") {
try {
let headers = {};
let sharePassword = localStorage.getItem("sharepass:" + hash);
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
const apiPath = getPublicApiPath('resources', { path, hash: hash, token: token })
const response = await fetch(apiPath, {
method,
body: content,
headers,
});
if (!response.ok) {
const error = new Error(response.statusText);
// attempt to marshal json response
let data = null;
try {
data = await response.json()
} catch (e) {
// ignore
}
if (data) {
error.message = data.message;
}
(/** @type {any} */ (error)).status = response.status;
throw error;
}
return response;
} catch (err) {
notify.showError(err.message || 'Error performing resource action')
throw err
}
}
export async function bulkDelete(items) {
if (!items || !Array.isArray(items) || items.length === 0) {
throw new Error('items array is required and must not be empty')
}
const hash = state.shareInfo?.hash;
if (!hash) {
throw new Error('share hash is required')
}
const params = {
hash: hash,
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
const apiPath = getPublicApiPath("resources/bulk/delete", params)
const baseUrl = window.origin + apiPath
try {
const response = await fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(items),
})
const data = await response.json()
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
// Both are valid responses that should be returned, not thrown as errors
if (response.status === 200 || response.status === 207) {
return data
}
// For other error status codes, throw an error
const error = new Error(data.message || response.statusText)
error.status = response.status
throw error
} catch (err) {
// If the request fails completely, return all items as failed
if (err.status && err.status !== 200 && err.status !== 207) {
// Real error - return all as failed
return {
succeeded: [],
failed: items.map(item => ({
source: item.source || '',
path: item.path,
message: err.message || 'Delete failed',
})),
}
}
// Re-throw if it's not a handled error
throw err
}
}
export async function put(hash, path, content = '') {
// resourceAction already handles error notification, just propagate
return await resourceAction(hash, path, 'PUT', content)
}
export async function moveCopy(
hash,
items,
action = 'copy',
overwrite = false,
rename = false
) {
if (!items || !Array.isArray(items) || items.length === 0) {
throw new Error('items array is required and must not be empty')
}
try {
// Build request body with proper format
// For public shares, fromSource and toSource are not needed (always the share's source)
const requestBody = {
items: items.map(item => ({
fromPath: item.from,
toPath: item.to
})),
action: action,
overwrite: overwrite,
rename: rename
}
const apiPath = getPublicApiPath('resources', { hash: hash })
const response = await fetch(apiPath, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(state.shareInfo.token && { 'X-Auth-Token': state.shareInfo.token })
},
body: JSON.stringify(requestBody),
})
const data = await response.json()
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
if (response.status === 200 || response.status === 207) {
return data
}
// For other error status codes (like 500), preserve the response data
const error = new Error(data.message || response.statusText)
error.status = response.status
// Attach the response data so the caller can access failed items
if (data.failed) {
error.failed = data.failed
}
if (data.succeeded) {
error.succeeded = data.succeeded
}
throw error
} catch (err) {
// Only show notification and re-throw if it's a real error (not 200/207)
if (err.status && err.status !== 200 && err.status !== 207) {
console.error(err.message || 'Error moving/copying resources')
}
throw err
}
}
export async function getShareInfo(hash) {
try {
const apiPath = getPublicApiPath('shareinfo', { hash: hash })
const response = await fetch(apiPath)
return response.json()
} catch (err) {
notify.showError(err.message || 'Error getting share info')
throw err
}
}
@@ -6,16 +6,17 @@ import { globalVars } from '@/utils/constants'
import { downloadManager } from '@/utils/downloadManager'
// Notify if errors occur
export async function fetchFiles(source, path, content = false, metadata = false) {
export async function fetchFiles(source, path, content = false, metadata = false, skipExtendedAttrs = false) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
try {
const apiPath = getApiPath('api/resources', {
const apiPath = getApiPath('resources', {
path: path,
source: source,
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' })
...(metadata && { metadata: 'true' }),
...(skipExtendedAttrs && { skipExtendedAttrs: 'true' })
})
const res = await fetchURL(apiPath)
const data = await res.json()
@@ -27,12 +28,31 @@ export async function fetchFiles(source, path, content = false, metadata = false
}
}
export async function getItems(source, path, only = "") {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
try {
const apiPath = getApiPath('resources/items', {
path: path,
source: source,
...(only && { only: only }),
})
const res = await fetchURL(apiPath)
const data = await res.json()
return data
} catch (err) {
notify.showError(err.message || 'Error fetching items')
throw err
}
}
async function resourceAction(source, path, method, content) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
try {
const apiPath = getApiPath('api/resources', { path, source })
const apiPath = getApiPath('resources', { path, source })
let opts = { method }
if (content) {
opts.body = content
@@ -66,9 +86,9 @@ export async function bulkDelete(items) {
throw new Error('items array is required and must not be empty')
}
try {
const apiPath = getApiPath('api/resources/bulk/delete')
const apiPath = getApiPath('resources/bulk')
const response = await fetchURL(apiPath, {
method: 'POST',
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
@@ -154,7 +174,7 @@ export async function download(format, files, shareHash = "") {
sessionId: state.sessionId
}
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
const apiPath = getApiPath("resources/download", params, false, shareHash !== "")
const url = window.origin + apiPath
// Create a direct link and trigger the download
@@ -206,7 +226,7 @@ async function downloadChunked(file, shareHash = "") {
sessionId: state.sessionId
}
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
const apiPath = getApiPath("resources/download", params, false, shareHash !== "")
const baseUrl = window.origin + apiPath
const download = downloadManager.findById(downloadId)
@@ -354,7 +374,7 @@ export function post(
throw new Error('no source provided')
}
try {
const apiPath = getApiPath("api/resources", {
const apiPath = getApiPath("resources", {
path: path,
source: source,
override: overwrite,
@@ -440,7 +460,7 @@ export async function moveCopy(
rename: rename
}
const apiPath = getApiPath('api/resources')
const apiPath = getApiPath('resources')
// We use fetch directly here instead of fetchURL because fetchURL throws on non-2xx status,
// consuming the response body as text. We need to parse the JSON response for 500/207 errors.
// We need to manually add headers that fetchURL adds.
@@ -495,7 +515,7 @@ export async function checksum(source, path, algo) {
source: source,
checksum: algo
}
const apiPath = getApiPath('api/resources', params)
const apiPath = getApiPath('resources', params)
const res = await fetchURL(apiPath)
const data = await res.json()
return data.checksums[algo]
@@ -515,7 +535,7 @@ export function getDownloadURL(source, path, inline, useExternal) {
file: path,
...(inline && { inline: 'true' })
}
const apiPath = getApiPath('api/raw', params)
const apiPath = getApiPath('resources/download', params)
if (globalVars.externalUrl && useExternal) {
return globalVars.externalUrl + apiPath
}
@@ -537,7 +557,7 @@ export function getPreviewURL(source, path, modified) {
source: source,
inline: 'true'
}
const apiPath = getApiPath('api/preview', params)
const apiPath = getApiPath('resources/preview', params)
return window.origin + apiPath
} catch (err) {
notify.showError(err.message || 'Error getting preview URL')
@@ -545,49 +565,409 @@ export function getPreviewURL(source, path, modified) {
}
}
export async function sources() {
// POST /api/resources/archive - Create an archive
export async function createArchive(opts) {
const { fromSource, toSource, paths, destination, format, compression, deleteAfter } = opts;
if (!fromSource || !paths?.length || !destination) {
throw new Error("fromSource, paths, and destination are required");
}
const body = {
fromSource,
paths,
destination,
...(toSource && toSource !== fromSource && { toSource }),
...(format && { format }),
...(compression !== undefined && compression !== null && { compression }),
...(deleteAfter && { deleteAfter: true }),
};
try {
const apiPath = getApiPath('api/jobs/status/sources')
const res = await fetchURL(apiPath)
const data = await res.json()
// Return empty object if no sources are available - this is not an error
return data || {}
const apiPath = getApiPath("resources/archive");
const response = await fetchURL(apiPath, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json();
} catch (err) {
// Only show error for actual network/server errors, not for empty sources
if (err.status && err.status !== 200) {
notify.showError(err.message || 'Error fetching usage sources')
notify.showError(err.message || "Error creating archive");
throw err;
}
}
// POST /api/resources/unarchive - Extract an archive
export async function unarchive(opts) {
const { fromSource, toSource, path, destination, deleteAfter } = opts;
if (!fromSource || !path || !destination) {
throw new Error("fromSource, path, and destination are required");
}
const body = {
fromSource,
...(toSource && toSource !== fromSource && { toSource }),
path,
destination,
...(deleteAfter && { deleteAfter: true }),
};
try {
const apiPath = getApiPath("resources/unarchive");
const response = await fetchURL(apiPath, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json();
} catch (err) {
notify.showError(err.message || "Error extracting archive");
throw err;
}
}
// ============================================================================
// PUBLIC API ENDPOINTS (hash-based authentication)
// ============================================================================
// Fetch public share data
/**
* @param {string} path
* @param {string} hash
* @param {string} password
* @param {boolean} content
* @param {boolean} metadata
* @returns {Promise<any>}
*/
export async function fetchFilesPublic(path, hash, password = "", content = false, metadata = false, skipExtendedAttrs = false) {
const params = {
path: path,
hash,
...(skipExtendedAttrs && { skipExtendedAttrs: 'true' }),
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' }),
...(state.shareInfo.token && { token: state.shareInfo.token })
}
const apiPath = getPublicApiPath("resources", params);
const response = await fetch(apiPath, {
headers: {
"X-SHARE-PASSWORD": password || "",
},
});
if (!response.ok) {
const error = new Error(response.statusText);
let data = null;
try {
data = await response.json()
} catch (e) {
// ignore
}
if (data) {
error.message = data.message;
}
(/** @type {any} */ (error)).status = response.status;
throw error;
}
let data = await response.json()
const adjusted = adjustedData(data);
return adjusted
}
export async function getItemsPublic(hash, path, only = "") {
if (!hash || hash === undefined || hash === null) {
throw new Error('no hash provided')
}
try {
const apiPath = getPublicApiPath('resources/items', {
path: path,
hash: hash,
...(only && { only: only }),
...(state.shareInfo.token && { token: state.shareInfo.token })
})
const response = await fetch(apiPath)
const data = await response.json()
return data
} catch (err) {
notify.showError(err.message || 'Error fetching items')
throw err
}
}
// Generate a download URL
/**
* @param {{ path: string; hash: string; token: string; inline?: boolean }} share
* @param {string[]} files - Array of file paths (will be converted to repeated 'file' parameters)
* @returns {string}
*/
export function getDownloadURLPublic(share, files, inline=false) {
const fileArray = Array.isArray(files) ? files : [files]
const params = {
file: fileArray, // Array will be converted to repeated 'file' params by getPublicApiPath
hash: share.hash,
token: share.token,
...(inline && { inline: 'true' })
}
const apiPath = getPublicApiPath("resources/download", params)
return window.origin + apiPath
}
// Generate a preview URL for public shares
/**
* @param {string} path
* @param {string} size - The size parameter (small, large, original). Omit for default (small).
* @returns {string}
*/
export function getPreviewURLPublic(path, size) {
try {
const params = {
path: path,
hash: state.shareInfo.hash,
inline: 'true',
...(size && size !== 'small' && { size: size }),
...(state.shareInfo.token && { token: state.shareInfo.token })
}
const apiPath = getPublicApiPath('resources/preview', params)
return window.origin + apiPath
} catch (/** @type {any} */ err) {
notify.showError(err.message || 'Error getting preview URL')
throw err
}
}
export function postPublic(
hash,
path,
content = "",
overwrite = false,
onupload,
headers = {},
isDir = false
) {
if (!hash || hash === undefined || hash === null) {
throw new Error('no hash provided')
}
let sharePassword = localStorage.getItem("sharepass:" + hash);
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
try {
const apiPath = getPublicApiPath("resources", {
path: path,
hash: hash,
override: overwrite,
...(isDir && { isDir: 'true' })
});
const request = new XMLHttpRequest();
request.open("POST", apiPath, true);
for (const header in headers) {
request.setRequestHeader(header, headers[header]);
}
if (typeof onupload === "function") {
request.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = Math.round(
(event.loaded / event.total) * 100
);
onupload(percentComplete);
}
};
}
const promise = new Promise((resolve, reject) => {
request.onload = () => {
if (request.status >= 200 && request.status < 300) {
resolve(request.responseText);
} else if (request.status === 409) {
const error = new Error("conflict");
error.response = { status: request.status, responseText: request.responseText };
reject(error);
} else {
let errorMessage = "Upload failed";
try {
const errorData = JSON.parse(request.responseText);
errorMessage = errorData.message || errorMessage;
} catch (e) {
errorMessage = request.responseText || errorMessage;
}
const error = new Error(errorMessage);
error.status = request.status;
notify.showError(errorMessage);
reject(error);
}
};
request.onerror = () => {
const error = new Error("Network error");
notify.showError("Network error during upload");
reject(error);
};
request.onabort = () => {
const error = new Error("Upload aborted");
notify.showError("Upload was aborted");
reject(error);
};
if (
content instanceof Blob &&
!["http:", "https:"].includes(window.location.protocol)
) {
new Response(content).arrayBuffer()
.then(buffer => request.send(buffer))
.catch(err => reject(err));
} else {
request.send(content);
}
});
promise.xhr = request;
return promise;
} catch (err) {
notify.showError(err.message || "Error posting resource");
return Promise.reject(err);
}
}
async function resourceActionPublic(hash, path, method, content, token = "") {
try {
let headers = {};
let sharePassword = localStorage.getItem("sharepass:" + hash);
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
const apiPath = getPublicApiPath('resources', { path, hash: hash, token: token })
const response = await fetch(apiPath, {
method,
body: content,
headers,
});
if (!response.ok) {
const error = new Error(response.statusText);
let data = null;
try {
data = await response.json()
} catch (e) {
// ignore
}
if (data) {
error.message = data.message;
}
(/** @type {any} */ (error)).status = response.status;
throw error;
}
return response;
} catch (err) {
notify.showError(err.message || 'Error performing resource action')
throw err
}
}
export async function putPublic(hash, path, content = '') {
return await resourceActionPublic(hash, path, 'PUT', content)
}
export async function bulkDeletePublic(items) {
if (!items || !Array.isArray(items) || items.length === 0) {
throw new Error('items array is required and must not be empty')
}
const hash = state.shareInfo?.hash;
if (!hash) {
throw new Error('share hash is required')
}
const params = {
hash: hash,
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
const apiPath = getPublicApiPath("resources/bulk", params)
const baseUrl = window.origin + apiPath
try {
const response = await fetch(baseUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(items),
})
const data = await response.json()
if (response.status === 200 || response.status === 207) {
return data
}
const error = new Error(data.message || response.statusText)
error.status = response.status
throw error
} catch (err) {
if (err.status && err.status !== 200 && err.status !== 207) {
return {
succeeded: [],
failed: items.map(item => ({
source: item.source || '',
path: item.path,
message: err.message || 'Delete failed',
})),
}
}
throw err
}
}
export async function GetOfficeConfig(req) {
const params = {
path: req.path,
...(req.hash && { hash: req.hash }),
...(req.source && { source: req.source })
export async function moveCopyPublic(
hash,
items,
action = 'copy',
overwrite = false,
rename = false
) {
if (!items || !Array.isArray(items) || items.length === 0) {
throw new Error('items array is required and must not be empty')
}
let apiPath = getApiPath('api/onlyoffice/config', params)
if (req.hash) {
apiPath = getPublicApiPath('onlyoffice/config', params)
}
const res = await fetchURL(apiPath)
return await res.json()
}
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const apiPath = getApiPath('api/media/subtitles', {
source: source,
path: path,
name: subtitleName,
embedded: embedded.toString()
const requestBody = {
items: items.map(item => ({
fromPath: item.from,
toPath: item.to
})),
action: action,
overwrite: overwrite,
rename: rename
}
const apiPath = getPublicApiPath('resources', { hash: hash })
const response = await fetch(apiPath, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(state.shareInfo.token && { 'X-Auth-Token': state.shareInfo.token })
},
body: JSON.stringify(requestBody),
})
const res = await fetchURL(apiPath)
const content = await res.text()
return content
const data = await response.json()
if (response.status === 200 || response.status === 207) {
return data
}
const error = new Error(data.message || response.statusText)
error.status = response.status
if (data.failed) {
error.failed = data.failed
}
if (data.succeeded) {
error.succeeded = data.succeeded
}
throw error
} catch (err) {
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
if (err.status && err.status !== 200 && err.status !== 207) {
console.error(err.message || 'Error moving/copying resources')
}
throw err
}
}
}
Loaded 100 of 271 files, more files were not shown because too many files have changed in this diff. Show more