Compare commits

..
Author SHA1 Message Date
Graham Steffaniak 79bbda72dd Sidebar link name fixes (#2945) 2026-09-10 19:15:54 -05:00
Graham Steffaniak 8493fe582b updated changelog 2026-09-10 18:42:54 -05:00
Graham Steffaniak a615ba7814 updated with token change for backwards compatiblity (#2944) 2026-09-10 18:30:21 -05:00
Graham Steffaniak a2fb87a79c updated changelog 2026-09-10 18:08:51 -05:00
Aditya Raj Singh b9679d03a3 Fall back to buffered copies when FUSE rejects fast paths (#2938) 2026-09-10 18:08:03 -05:00
Aditya Raj Singh 4b9f03ad35 Preserve deleted sidebar links across restarts (#2935) 2026-09-10 18:05:44 -05:00
Aditya Raj Singh 4b465f5855 Support non-ASCII share passwords (#2933) 2026-09-10 18:00:37 -05:00
Graham Steffaniak 0d68ad5d7c merge changelog 2026-09-10 17:56:59 -05:00
Graham Steffaniak d417da2b83 Merge branch 'main' into dev/v2.0.6 2026-09-10 17:55:47 -05:00
Graham Steffaniak ba04611565 remove direct download tokens (#2943) 2026-09-10 17:54:53 -05:00
Kevin B. 0a2265980e scope padding to listing view (#2934) 2026-09-10 14:11:14 -05:00
Vladimir Babin 1d08a4549b Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2928) 2026-09-09 12:41:25 -05:00
Graham SteffaniakandKevin B. b12add4317 Beta/v2.0.5 (#2916)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-09-07 12:55:40 -05:00
Graham Steffaniak 5610cdd2bf fix onlyoffice csp security regression (#2876) (#2900) 2026-09-03 20:31:53 -05:00
Ethan Lee Qi Yang ea85c50946 fix(contributing): typo from improtant to important (#2873) 2026-08-30 08:48:32 -05:00
be47c7a1e7 Beta/v2.0.3 (#2870)
Co-authored-by: Aditya Raj Singh <github@adityaraj.com>
Co-authored-by: tbsagdic <86909795+tbsagdic@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:53:51 -05:00
219 changed files with 6533 additions and 2647 deletions

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
"version": "22"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
+2 -41
View File
@@ -20,6 +20,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
@@ -33,44 +35,3 @@ jobs:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_beta_release:
needs: [test_playwright]
if: startsWith(github.event.head_commit.message, 'Beta/v')
name: Create Beta Release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
fetch-depth: 0
- name: Extract version from commit message
id: extract_version
shell: bash
run: |
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
exit 1
fi
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
- name: Tag release commit on main
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
TAG="${{ steps.extract_version.outputs.tag_name }}"
git tag -f "$TAG" "${{ github.sha }}"
git push origin "refs/tags/$TAG" --force
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_version.outputs.tag_name }}
prerelease: false
make_latest: false
draft: false
generate_release_notes: true
overwrite_existing: true
name: ${{ steps.extract_version.outputs.tag_name }}
+2
View File
@@ -33,6 +33,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- name: Build frontend
working-directory: frontend
run: npm i && npm run build
@@ -1,88 +0,0 @@
name: Promote Beta to Stable
on:
workflow_dispatch:
inputs:
version:
description: "Version to promote (format: 0.0.0)"
required: true
type: string
jobs:
promote-beta-to-stable:
name: Promote Beta to Stable
runs-on: ubuntu-24.04-arm
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }} # Uses Personal Access Token for branch operations
- name: Validate version format
id: validate_version
run: |
VERSION="${{ github.event.inputs.version }}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ ERROR: Version must be in '0.0.0' format."
exit 1
fi
echo "✅ Version format validated: $VERSION"
- name: Check if beta branch exists
id: check_beta_branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
if ! git ls-remote --exit-code origin "$BETA_BRANCH"; then
echo "❌ ERROR: Beta branch '$BETA_BRANCH' does not exist!"
exit 1
fi
echo "✅ Beta branch '$BETA_BRANCH' exists."
- name: Check if stable branch already exists
id: check_stable_branch
run: |
VERSION="${{ github.event.inputs.version }}"
STABLE_BRANCH="stable/v$VERSION"
if git ls-remote --exit-code origin "$STABLE_BRANCH"; then
echo "❌ ERROR: Stable branch '$STABLE_BRANCH' already exists!"
exit 1
fi
echo "✅ Stable branch '$STABLE_BRANCH' does not exist."
- name: Create and push new stable branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
STABLE_BRANCH="stable/v$VERSION"
# Fetch latest changes
git fetch origin "$BETA_BRANCH"
# Create new stable branch from beta branch
git checkout -b "$STABLE_BRANCH" origin/"$BETA_BRANCH"
# Push new stable branch to remote
git push origin "$STABLE_BRANCH"
echo "✅ Successfully created stable branch '$STABLE_BRANCH' from '$BETA_BRANCH'."
- name: Merge beta into stable
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
STABLE_BRANCH="stable/v$VERSION"
# Checkout stable branch
git checkout "$STABLE_BRANCH"
# Merge beta into stable
git merge --no-ff "origin/$BETA_BRANCH" -m "Merge $BETA_BRANCH into $STABLE_BRANCH"
# Push merge changes
git push origin "$STABLE_BRANCH"
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
+8
View File
@@ -38,6 +38,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run lint
test-frontend:
@@ -45,6 +47,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run test
check-translations:
@@ -52,6 +56,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run i18n:check
check-docs:
@@ -68,5 +74,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run icons:check
-66
View File
@@ -1,66 +0,0 @@
name: stable release
on:
push:
branches:
- "stable/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false
make_latest: true
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
+2
View File
@@ -17,6 +17,8 @@ jobs:
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- name: Install dependencies and build frontend
run: npm i && npm run build
working-directory: frontend
+61
View File
@@ -2,6 +2,67 @@
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).
## v2.0.6
**Security**:
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
**Notes**:
- renaming sources updates sidebar links (#2878)
- disabling/deleting a user source removes that source from sidebar links (#2942)
**Bugfixes**:
- Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2894)
- Support non-ASCII share passwords (#2933)
- Fall back to buffered copies when FUSE rejects fast paths (#2938) (#2924)
- Preserve deleted sidebar links across restarts (#2935)
## v2.0.5
**New Features**:
- Users can set default view mode and thumbnail size from Profile settings (Listing options). Admins can set the same defaults for existing users from the user edit prompt. Values persist to the account and apply on new browsers or devices; they do not override view preferences already saved locally ([#2884](https://github.com/gtsteffaniak/filebrowser/issues/2884)).
- Admins can edit all profile preference defaults for existing users from the user management panel (listing, preview, sidebar, theme, and related options), matching what was previously available only for new users and global user defaults.
**Security**:
- [Moderate] public metadata api returns file content to anonymous share visitors, ignoring the share's download limit and file-viewer setting (GHSA-55mw-cwg7-m8f5) -- thanks @kta1kri
**Bugfixes**:
- Fixed infinite loading spinner on `/` and `/login` after CSP security hardening ([#2886](https://github.com/gtsteffaniak/filebrowser/issues/2886), [#2890](https://github.com/gtsteffaniak/filebrowser/issues/2890))
- fixed darkmode styling bug where background wouldn't respect configured settings when not logged in
- fixed styling bug where padding was missing from right side of listing view.
- tilde paths for sources were not properly expanded. (eg "~" for home)
- fixed tooltip not showing up for some circumstances on mobile, improved consistency.
- a few styling fixes for inconsistencies (#2908)
- Disk-usage widget no longer reports PB/TB-scale garbage on Docker Desktop for Mac (virtiofs) bind mounts; space is computed from the filesystem fragment size (`f_frsize`) instead of the optimal-transfer block size (`f_bsize`) on Linux ([#2894](https://github.com/gtsteffaniak/filebrowser/issues/2894)).
## v2.0.4
**Bugfixes**:
- OnlyOffice editor failed to load after the CSP security fix; allow configured `integrations.office.url` and `integrations.office.internalUrl` origins in `script-src` ([#2874](https://github.com/gtsteffaniak/filebrowser/issues/2874)).
## v2.0.3
**Security**:
- [High] Stored XSS via HTML preview: strip `<script>` from sanitized srcdoc, remove `allow-same-origin` from the preview iframe sandbox, set HttpOnly on the session cookie, and add a nonce-based `script-src` CSP on the SPA shell (GHSA-vvm6-jwrf-hgmg) -- thanks @qrn12580
- [Moderate] Non-admin users could PATCH privileged fields on their own account (scopes, permissions, etc.), allowing privilege escalation; restores v1 non-admin field guard on PATCH /api/users (GHSA-p5cc-4p84-c4m2) -- thanks @Chri6s
**New Features**:
- Added option to globally disable the "Install App" message via `frontend.disablePWAInstall`
- PWA improvements for installed mobile apps: dedicated maskable icons (192/512), manifest and splash colors that follow the instance default theme, runtime `theme-color` sync on dark-mode toggle, and edge-to-edge safe-area layout for notched devices (#2625) (#2869) -- thanks @APatenaude
**Bugfixes**:
- Fixed Fuji `.raf` thumbnail preview by extracting the camera-embedded JPEG from the RAF header (regression for files where TIFF-based raw extraction does not apply).
- Image previews for unsupported decodable formats now return HTTP 415 immediately instead of attempting JPEG decode and returning HTTP 500.
- OIDC: preserve the verified ID-token user identifier when falling back to the UserInfo endpoint for missing groups, so login no longer fails with HTTP 500 when UserInfo returns groups but omits the configured identifier.
- LDAP: restore `memberOf` as the default `groupsClaim` when unset, fixing group-based admin and login authorization for existing LDAP configs after the v2.0.2 default changed to `groups`.
- "Install app" message reappears after being cleared on device.
- Restored upload chunk size `0` to disable chunking as documented ([#2202](https://github.com/gtsteffaniak/filebrowser/issues/2202)).
- Fixed iOS 26 / WebKit multi-chunk upload stall by isolating chunk connections and returning partial chunk JSON responses ([#2734](https://github.com/gtsteffaniak/filebrowser/issues/2734)).
- Sidebar folder links with custom names, icons, or styles no longer disappear after restart; multiple shortcuts to different folders on the same source are preserved ([#2809](https://github.com/gtsteffaniak/filebrowser/issues/2809)).
- Adding a source to a user via scopes now auto-adds a matching sidebar link; removing a source keeps the link (shown disabled) so users can delete it manually.
- A password reset via CLI returns user to a password method user.
- Authentication login methods are consistently enforced without modifying existing account settings.
## v2.0.2
**Bugfixes**:
+1 -1
View File
@@ -7,7 +7,7 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
- **Go 1.25+** (see `backend/go.mod`)
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Docker** (optional, for containerized development)
- **Git** especially improtant on windows -- needed for bash support.
- **Git** especially important on Windows -- needed for bash support.
### Optional Tools
+2 -2
View File
@@ -14,11 +14,11 @@ RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
FROM node:jod-slim AS nbuild
WORKDIR /build
COPY ./frontend/package.json ./frontend/
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN cd frontend && npm i --maxsockets 1
RUN cd frontend && npm ci --maxsockets 1
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
+1 -1
View File
@@ -13,4 +13,4 @@ RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
+2 -2
View File
@@ -11,11 +11,11 @@ RUN go build -tags analytics -ldflags="-w -s \
FROM node:jod-slim AS nbuild
WORKDIR /build
COPY ./frontend/package.json ./frontend/
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN cd frontend && npm i
RUN cd frontend && npm ci
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
/**
* Read environment variables from file.
@@ -9,7 +10,7 @@ import { defineConfig, devices } from "@playwright/test";
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
export default defineConfig<PlaywrightFixtureOptions>({
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/screenshots",
+4 -1
View File
@@ -115,7 +115,7 @@ func setUser(username, password string, asAdmin bool) error {
return nil
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
logger.Infof("Converting user %s from %s to password login method", username, user.LoginMethod)
}
user.TOTPSecret = ""
user.TOTPNonce = ""
@@ -139,6 +139,9 @@ func promoteUser(username string) error {
if err != nil {
return fmt.Errorf("user %s not found", username)
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s cannot be promoted: only password-login users can be granted admin via CLI", username)
}
if user.Permissions.Admin {
fmt.Printf("user %s is already an admin\n", username)
return nil
+101
View File
@@ -0,0 +1,101 @@
package cmd
import (
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupCLIUserTest(t *testing.T) {
t.Helper()
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.InitializeCLI("../../../_docker/src/noauth/backend/config.yaml")
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
store, _, err := state.Open(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(store)
t.Cleanup(func() { _ = state.Close() })
}
func TestSetUserConvertsOIDCToPassword(t *testing.T) {
setupCLIUserTest(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatal(err)
}
if err := setUser("graham", "newpassword", false); err != nil {
t.Fatalf("setUser: %v", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if loaded.LoginMethod != users.LoginMethodPassword {
t.Fatalf("loginMethod = %q, want password", loaded.LoginMethod)
}
}
func TestPromoteUserRejectsNonPasswordLoginMethod(t *testing.T) {
setupCLIUserTest(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatal(err)
}
err := promoteUser("graham")
if err == nil {
t.Fatal("promoteUser: expected error for oidc user")
}
if !strings.Contains(err.Error(), "password-login") {
t.Fatalf("promoteUser err = %q, want password-login restriction", err.Error())
}
}
func TestPromoteUserAllowsPasswordLoginMethod(t *testing.T) {
setupCLIUserTest(t)
passwordUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodPassword,
},
}
if err := state.CreateUser(passwordUser, "secret"); err != nil {
t.Fatal(err)
}
if err := promoteUser("graham"); err != nil {
t.Fatalf("promoteUser: %v", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if !loaded.Permissions.Admin {
t.Fatal("expected admin after promote")
}
}
+2 -2
View File
@@ -210,14 +210,14 @@ func runCLI() (keepGoing bool, dbExists bool) {
switch {
case cmd == "" || cmd == "run":
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath)
dbExists = initializeDatabase(configPath, false)
return true, dbExists
case cmd == "version" || cmd == "setup":
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, false
case cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath)
dbExists = initializeDatabase(configPath, true)
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, dbExists
default:
+6 -2
View File
@@ -29,8 +29,12 @@ import (
var runtimeApp *app.App
func initializeDatabase(configFile string) bool {
settings.Initialize(configFile)
func initializeDatabase(configFile string, cliMode bool) bool {
if cliMode {
settings.InitializeCLI(configFile)
} else {
settings.Initialize(configFile)
}
if err := validateDatabasePaths(); err != nil {
logger.Fatalf("%v", err)
+16 -33
View File
@@ -7,10 +7,10 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -27,7 +27,8 @@ func validateUserInfo(newDB bool) {
changedFields := make([]string, 0, 8)
changePass := false
if updateUserScopes(user) {
scopesChanged := updateUserScopes(user)
if scopesChanged {
changedFields = append(changedFields, "backendScopes")
}
if updatePermissions(user) {
@@ -45,7 +46,7 @@ func validateUserInfo(newDB bool) {
if updateShowFirstLogin(user) {
changedFields = append(changedFields, "showFirstLogin")
}
if updateSidebarLinks(user) {
if updateSidebarLinks(user, scopesChanged) {
changedFields = append(changedFields, "sidebarLinks")
}
if updateTokens(user) {
@@ -81,7 +82,8 @@ func validateUserInfo(newDB bool) {
logger.Info("Resetting admin user to default username and password.")
user.Permissions = settings.AdminPerms()
user.Password = settings.Config.Auth.AdminPassword
changedFields = append(changedFields, "permissions", "password")
user.LoginMethod = users.LoginMethodPassword
changedFields = append(changedFields, "permissions", "password", "loginMethod")
changePass = true
}
@@ -231,37 +233,18 @@ func updatePreviewSettings(user *users.User) bool {
return false
}
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
func updateSidebarLinks(user *users.User) bool {
updated := false
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
updated = true
// updateSidebarLinks normalizes saved links and adds defaults for new or changed scopes.
func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
links, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks)
if scopesChanged || user.SidebarLinks == nil {
links, changed = usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
}
if !changed {
return false
}
if !usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes) {
return updated
}
if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 && len(user.SidebarLinks) > 0 {
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
} else if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 {
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
} else {
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
}
merged, changed := usersidebar.EnsureSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
if changed {
user.SidebarLinks = merged
updated = true
}
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
updated = true
}
return updated
user.SidebarLinks = links
return true
}
func updateTokens(user *users.User) bool {
+1 -1
View File
@@ -79,7 +79,7 @@ func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) {
updateUserScopes(graham)
updateSourcePermissions(graham)
updateSidebarLinks(graham)
updateSidebarLinks(graham, true)
foundScope := false
for _, scope := range graham.BackendScopes {
+36 -2
View File
@@ -33,7 +33,7 @@ func TestUpdateSidebarLinks_dedupesDuplicateMigratedLinks(t *testing.T) {
},
}
if !updateSidebarLinks(user) {
if !updateSidebarLinks(user, false) {
t.Fatal("expected updateSidebarLinks to return true")
}
@@ -63,7 +63,7 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
},
}
if !updateSidebarLinks(user) {
if !updateSidebarLinks(user, false) {
t.Fatal("expected updateSidebarLinks to return true")
}
@@ -77,3 +77,37 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
}
}
func TestUpdateSidebarLinks_keepsDeletedSourceLinkAbsent(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
NonAdminEditable: users.NonAdminEditable{
SidebarLinks: []users.SidebarLink{
{Name: "playwright + files", Category: "source", SourceName: fixturePlaywrightSource, Target: "/"},
{Name: "docker", Category: "source", SourceName: fixtureDockerSource, Target: "/"},
},
},
},
BackendScopes: []users.BackendScope{
{Path: fixturePlaywrightSource, Scope: "/"},
{Path: fixtureDockerSource, Scope: "/"},
{Path: fixtureAccessSource, Scope: "/"},
},
}
if !updateSidebarLinks(user, false) {
t.Fatal("expected default source names to be blanked on normalize")
}
if len(user.SidebarLinks) != 2 {
t.Fatalf("got %d sidebar links, want 2: %v", len(user.SidebarLinks), user.SidebarLinks)
}
for _, link := range user.SidebarLinks {
if link.Name != "" {
t.Fatalf("expected blank default name, got %#v", link)
}
}
}
+43 -35
View File
@@ -1,19 +1,19 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.26.0
go 1.27.0
require (
github.com/alecthomas/kong v1.16.1
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.20.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/evanoberholster/imagemeta v1.0.0
github.com/gen2brain/go-fitz v1.28.2
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.4
github.com/go-playground/validator/v10 v10.30.4
github.com/go-webauthn/webauthn v0.18.0
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
@@ -28,7 +28,7 @@ require (
github.com/stretchr/testify v1.12.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.55.0
golang.org/x/crypto v0.56.0
golang.org/x/image v0.45.0
golang.org/x/mod v0.40.0
golang.org/x/net v0.58.0
@@ -38,7 +38,7 @@ require (
golang.org/x/time v0.15.0
golang.org/x/tools v0.49.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.57.0
modernc.org/sqlite v1.58.0
)
require (
@@ -51,7 +51,7 @@ require (
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/exhaustruct/v5 v5.0.3 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
github.com/4meepo/tagalign v1.4.3 // indirect
github.com/4meepo/tagalign v1.4.4 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
@@ -68,13 +68,13 @@ require (
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alecthomas/go-check-sumtype v0.4.0 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/andybalholm/brotli v1.2.3 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -95,7 +95,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260903151058-ae99b731b8c5 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -112,7 +112,7 @@ require (
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/ebitengine/purego v0.11.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -121,14 +121,14 @@ require (
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghostiam/protogetter v0.3.21 // indirect
github.com/ghostiam/protogetter v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-critic/go-critic v0.14.4 // indirect
github.com/go-critic/go-critic v0.15.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/spec v0.22.9 // indirect
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-openapi/jsonpointer v1.0.1 // indirect
github.com/go-openapi/jsonreference v1.0.2 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
github.com/go-openapi/swag/conv v0.29.1 // indirect
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
github.com/go-openapi/swag/loading v0.29.1 // indirect
@@ -149,23 +149,24 @@ require (
github.com/go-webauthn/x v0.3.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hashstructure v1.0.0 // indirect
github.com/godoc-lint/godoc-lint v0.11.4 // indirect
github.com/gofrs/flock v0.13.1 // indirect
github.com/gohugoio/hashstructure v1.1.0 // indirect
github.com/gohugoio/hugo v0.165.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
github.com/golangci/golangci-lint/v2 v2.13.1 // indirect
github.com/golangci/golangci-lint/v2 v2.13.2 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260801150524-d2031e3af752 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-tpm v0.9.8 // indirect
@@ -190,7 +191,6 @@ require (
github.com/kovidgoyal/go-shm v1.0.0 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.9.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
@@ -208,7 +208,7 @@ require (
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.28 // indirect
github.com/mattn/go-runewidth v0.0.29 // indirect
github.com/mgechev/revive v1.16.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
@@ -224,15 +224,15 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.3.0 // indirect
github.com/raeperd/recvcheck v0.3.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
@@ -246,8 +246,8 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.28.0 // indirect
github.com/sirupsen/logrus v1.10.1 // indirect
github.com/securego/gosec/v2 v2.29.0 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
@@ -263,7 +263,7 @@ require (
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/timonwong/loggercheck v0.12.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
@@ -271,7 +271,7 @@ require (
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.5.0 // indirect
github.com/uudashr/iface v1.5.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v1.0.0 // indirect
@@ -290,17 +290,17 @@ require (
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260820142414-ca536658362e // indirect
golang.org/x/exp/typeparams v0.0.0-20260824195058-e88cd73687aa // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.8.1 // indirect
modernc.org/libc v1.75.4 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
mvdan.cc/gofumpt v0.11.0 // indirect
mvdan.cc/unparam v0.0.0-20260820064413-a201bf8e0538 // indirect
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
@@ -309,3 +309,11 @@ tool (
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
github.com/swaggo/swag/cmd/swag
)
exclude (
github.com/alecthomas/go-check-sumtype v0.5.0
github.com/gobwas/glob v1.0.0
github.com/gtsteffaniak/go-ffmpeg v1.0.0
github.com/gtsteffaniak/go-ffmpeg v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.2.0
)
+76 -78
View File
@@ -16,8 +16,8 @@ dev.gaijin.team/go/exhaustruct/v5 v5.0.3 h1:yOeA7DNjlT8y4yfmN6nWWYYggA13N523YAj9
dev.gaijin.team/go/exhaustruct/v5 v5.0.3/go.mod h1:KwtBsX8nHHH1YxhxkpiBq6bfsmw5WnazWpNvJPHgY9Y=
dev.gaijin.team/go/golib v0.8.1 h1:JYju4x9BSo+QD/AYeHULVDcvEhiFg8wOi6pT0IaZF5E=
dev.gaijin.team/go/golib v0.8.1/go.mod h1:c5fu7t1RSGMxSQgcUYO1sODbzsYnOCXJLmHeNG1Eb+0=
github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8=
github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
github.com/4meepo/tagalign v1.4.4 h1:052JDGj2pBORc6NkD30dDXpsUpoYszDeUwr9/5YZuVA=
github.com/4meepo/tagalign v1.4.4/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
github.com/Abirdcfly/dupword v0.1.8 h1:SrhcUuGsROBuChFxHALRYzyyPODWn9zwghmzPvD9Cd8=
github.com/Abirdcfly/dupword v0.1.8/go.mod h1:XZrhVnI7YGpsTiWZANSQaBJ4QpL/Tq5vIEdKJJAs9WI=
github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo=
@@ -62,8 +62,8 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/go-check-sumtype v0.4.0 h1:gd4mE4QsA62sXlnFIs8GUv8/0xH3DOUqTkYQR1l2v28=
github.com/alecthomas/go-check-sumtype v0.4.0/go.mod h1:aYYllI8IXNjo7ZYUzI2+wyLRECMg3zo+8CZj9JTBAWU=
github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E=
github.com/alecthomas/kong v1.16.1/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
@@ -80,8 +80,8 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI=
@@ -153,8 +153,8 @@ github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1Di
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be h1:qEvkJy1sjJXP+yf8IH2o13NV+Rh4nIUHjaLNJ+pWxpI=
github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/ultraviolet v0.0.0-20260903151058-ae99b731b8c5 h1:omidyaLW3GwtN5N35bPZWCxygynfhMRO2dTJ+OCcjLg=
github.com/charmbracelet/ultraviolet v0.0.0-20260903151058-ae99b731b8c5/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -171,8 +171,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -215,8 +215,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
github.com/evanoberholster/imagemeta v1.0.0 h1:FlSihlsjkWIoS83wmgPOP3CbAxlRpxM8jQGZgLu+MWQ=
@@ -244,30 +244,30 @@ github.com/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJt
github.com/gen2brain/go-fitz v1.28.2/go.mod h1:pY2hqAjp9Zy7qfPI2gwbJMHBFAdZpVXOLrRxD82l3Bs=
github.com/getkin/kin-openapi v0.146.0 h1:RA/1RdxrSJW4oc1+6IfnYB6AO9CaGy8GTKPh0k4Ordo=
github.com/getkin/kin-openapi v0.146.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/ghostiam/protogetter v0.3.21 h1:EeWTGvL/Eyosp653hiWb6Byx4b69iJC4/E+za7vQHoI=
github.com/ghostiam/protogetter v0.3.21/go.mod h1:iAKSpyoHwYzay+OpjoWgwzRtPFthEfuUvmlomTThck0=
github.com/ghostiam/protogetter v1.0.0 h1:g1s6hbyrNJKYJ2X/RcLMmfDo9afmxQe1MRmBitfxSAs=
github.com/ghostiam/protogetter v1.0.0/go.mod h1:50Fh5yZfs6SFGrd1FlmT04NcguyEOxyjNK5zGD6/EvM=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-critic/go-critic v0.14.4 h1:dSX4C3pWSeuMVxvQh6yG8U0ReSf3YOmKi4nwX5q7n/8=
github.com/go-critic/go-critic v0.14.4/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-critic/go-critic v0.15.0 h1:MDUZIQKytIl+vPEc0Igw51m4uLTdi1ZGz2jatEqNs1g=
github.com/go-critic/go-critic v0.15.0/go.mod h1:3BPrw8nHATGm3o2n37cssOmJzpySGKCdY8JWkCzxgKQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=
github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg=
github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY=
github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc=
github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs=
github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk=
github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA=
github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM=
@@ -285,18 +285,18 @@ github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or
github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE=
github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk=
github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4=
github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q=
github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8=
github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -322,8 +322,8 @@ github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUN
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/webauthn v0.18.0 h1:PC8R3PNLEmjZf++WwcQlo1Z39S9rf8ma69rlwkypZhA=
github.com/go-webauthn/webauthn v0.18.0/go.mod h1:ymzZQhx3D/PrDjznemBdQJ23gHTaSDxUchM7sH1lUCg=
github.com/go-webauthn/x v0.3.0 h1:Q2X9vbrlP0Ed+QGEzixh1hthGZlDnzVT0XH/9IIQ0kE=
github.com/go-webauthn/x v0.3.0/go.mod h1:5OkdSQdOy7taRXWqvNHggtaPffmW94ybu3rZEER4I+I=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
@@ -334,18 +334,18 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM=
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/godoc-lint/godoc-lint v0.11.4 h1:N9zUYVJ/OkznBbrBsU/zODvua64EUplMBLQWStO3+1M=
github.com/godoc-lint/godoc-lint v0.11.4/go.mod h1:7usTXoxS0QYIi5usTSCPwR0w/6xLjcz450shXjLn3t4=
github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo=
github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE=
github.com/gohugoio/gift v0.2.0 h1:vA31pP0rTVmBxBrhpY3WEt+4zM4g+1sDqYeemwsYeqc=
github.com/gohugoio/gift v0.2.0/go.mod h1:1Mrm5CjF33KpD749Dwj+UAjWZ3LC6cBXGuTMa5XwoP4=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 h1:pxlAea9eRwuAnt/zKbGqlFO2ZszpIe24YpOVLf+N+4I=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6/go.mod h1:m5hu1im5Qc7LDycVLvee6MPobJiRLBYHklypFJR0/aE=
github.com/gohugoio/go-radix v1.2.0 h1:D5GTk8jIoeXirBSc2P4E4NdHKDrenk9k9N0ctU5Yrhg=
github.com/gohugoio/go-radix v1.2.0/go.mod h1:k6vDa0ebpbpgtzSj9lPGJcA4AZwJ9xUNObUy2vczPFM=
github.com/gohugoio/hashstructure v1.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg=
github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE=
github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4=
github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8=
github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/SficorCU=
github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
github.com/gohugoio/hugo v0.165.0 h1:E9uRFnDw3E+Nv69OU8vsD1YLI3+tRcSHhs82y4fBXdc=
@@ -371,14 +371,16 @@ github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 h1:fVLolA3dG6s0brGetsiDAtcnpMSwVa2LqXJEw/9RJG4=
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6/go.mod h1:1xo+NFW5S+bEf2DKXhaxuzvcDN5AR6o/KMwvqC5Mkpo=
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A=
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U=
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU7GpP5BTtQTMWtVtj+mfPijgZeIg=
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA=
github.com/golangci/golangci-lint/v2 v2.13.1 h1:RuM4OcluM4xFQcGuRE6R7jA33pqxK/W1EsBxpugdZjg=
github.com/golangci/golangci-lint/v2 v2.13.1/go.mod h1:HwX7mDzqHbcSxlhrTygjX1GJbAfQ3sJAqOx41qQlhDE=
github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80qkk8G2B4TXU=
github.com/golangci/golangci-lint/v2 v2.13.2/go.mod h1:5xaMd1kAxV7GSBPEyngw8gnnjoRocqW9UVKdBR6627w=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
@@ -387,8 +389,8 @@ github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3H
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
github.com/golangci/rowserrcheck v0.0.0-20260801150524-d2031e3af752 h1:ivRL3tv3jTvJ6L9W3mdX8i+IuT8CxvEtsIfeH93Iqk4=
github.com/golangci/rowserrcheck v0.0.0-20260801150524-d2031e3af752/go.mod h1:dGge5Cok/D3UixuSKYyrqg7QpdmTJrdHAqu9OiiYWiw=
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 h1:0kPqfqytQizeQ9WBk487NcsGcRdS8YhHeUhXxkfEAhA=
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241/go.mod h1:lczq5oJ8PxpMESQG6dO3iFYTQPhpqoMOYw//n2FEnHA=
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
@@ -425,8 +427,6 @@ github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-ffmpeg v0.5.1 h1:gpKXi/0mn0j1Ug3v9x21tqbkT/2EZ6cDB1Ngqe8YfjI=
github.com/gtsteffaniak/go-ffmpeg v0.5.1/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-ffmpeg v0.5.2 h1:KVnKM3zewY9yeZVd59Hb0/10gSbhYf01yRM5RftXGEU=
github.com/gtsteffaniak/go-ffmpeg v0.5.2/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
@@ -495,8 +495,6 @@ github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJ
github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4=
github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI=
github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
github.com/ldez/gomoddirectives v0.9.0 h1:2YV/EX7nVlWL4jySusYTzBKHuE3D2fgcRsQuMa3yIoo=
@@ -537,8 +535,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mgechev/revive v1.16.0 h1:9OZVO8xOPUwIYynplHXv7liMllPQ/BTNZLp07sM/Im0=
@@ -581,8 +579,8 @@ github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
@@ -607,12 +605,12 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA=
github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY=
@@ -623,8 +621,8 @@ github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs=
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ=
github.com/raeperd/recvcheck v0.3.0 h1:PM+XYvyxIj3bo+kobJfFTdTuU3Lmfu96mKDbyHDbRt8=
github.com/raeperd/recvcheck v0.3.0/go.mod h1:PZNwG+HztFYMH2ZPq0Hu3QgkV2yiA6VrtNz9c1fXWJo=
github.com/raeperd/recvcheck v0.3.1 h1:p7nZMxiCWkdUtY2i3o+pv1h4fJCR5gpvqAXr9326hEY=
github.com/raeperd/recvcheck v0.3.1/go.mod h1:CbE7iRocQ4uc71JwpW/CPSh4BShnkre4A9Gb508RypU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -651,12 +649,12 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.28.0 h1:ZsSdiDb0AtTpLFVol5z91gbMei9ZiLEPG/pZjZujp7c=
github.com/securego/gosec/v2 v2.28.0/go.mod h1:lb4/9AHe+lJy/kjWmWRWWsEipvbwGKuxf+tY1Pmjdnk=
github.com/securego/gosec/v2 v2.29.0 h1:pF2HSLcnY5voqpxQumEe0O5YAAbvF4Syu4qeIewpLyc=
github.com/securego/gosec/v2 v2.29.0/go.mod h1:47C7ej+L2bC1NOxteU2aCGnCi4cZaoKzRt5y9plIvuY=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
@@ -711,8 +709,8 @@ github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZ
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c h1:d/FUP8GD4QiY/rdFzZmBu3KqL9eBNVzMu7W/5pGkaMM=
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
github.com/timonwong/loggercheck v0.12.0 h1:7Cb9XGLJZSWYNjgL459DIhpSiEHupD+MLQ6vaN2Pl3g=
github.com/timonwong/loggercheck v0.12.0/go.mod h1:Wa6Y+SJHQR01mViZlLLY5GAZVOWOLNMbkXddMjFWQCs=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
@@ -727,8 +725,8 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
github.com/uudashr/iface v1.5.0 h1:PgdMt4uAettGG8K/Kbamc4B9FABgUgnS3TLbl6fnjEk=
github.com/uudashr/iface v1.5.0/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
github.com/uudashr/iface v1.5.1 h1:BS3yrgaT55s3dAtJoxuTnsZNbsejq/mVuzp49YDyfBA=
github.com/uudashr/iface v1.5.1/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -788,14 +786,14 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
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/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-20260820142414-ca536658362e h1:4If57HMRswXp1wjYKdxwh795kBkFIzRawdB6rJHlLQQ=
golang.org/x/exp/typeparams v0.0.0-20260820142414-ca536658362e/go.mod h1:qkMnvgP8C7+xkci04txL2SJFc/0PGqnkf6gs5JUC43E=
golang.org/x/exp/typeparams v0.0.0-20260824195058-e88cd73687aa h1:0crASk1XE9p9RP0n9EbRutyWZoOkzTSuwTp3mqwppb0=
golang.org/x/exp/typeparams v0.0.0-20260824195058-e88cd73687aa/go.mod h1:qkMnvgP8C7+xkci04txL2SJFc/0PGqnkf6gs5JUC43E=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -918,8 +916,8 @@ modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.4 h1:EHQJNYDC6LiDIOqM76862xe4frbDc7IzEOZTtGxZV8Q=
modernc.org/libc v1.75.4/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
@@ -928,16 +926,16 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/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.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
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=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc=
mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo=
mvdan.cc/unparam v0.0.0-20260820064413-a201bf8e0538 h1:gAyMBEFTs+oVyKRmgsn+P9WlYFFZ6EI8dpgu7bCulCo=
mvdan.cc/unparam v0.0.0-20260820064413-a201bf8e0538/go.mod h1:MrS/+zJ1M2xvGXhKktiHbNQeQPyg3Qel+KkzT+f7/i4=
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 h1:Re1NRyLpiAt9kB+ImaaoapwWiQXrKwER4tY8fLOkDew=
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8/go.mod h1:MrS/+zJ1M2xvGXhKktiHbNQeQPyg3Qel+KkzT+f7/i4=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+9 -5
View File
@@ -15,13 +15,13 @@ import (
"github.com/dhowden/tag"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -131,10 +131,10 @@ func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service)
return "", "", fmt.Errorf("user not provided")
}
if opts.Path == "" {
return "", "", fmt.Errorf("path not provided")
return "", "", fmt.Errorf("%w: path not provided", errors.ErrInvalidRequestParams)
}
if opts.Source == "" {
return "", "", fmt.Errorf("source not provided")
return "", "", fmt.Errorf("%w: source not provided", errors.ErrInvalidRequestParams)
}
// Get index
@@ -403,6 +403,10 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
return
}
if !opts.Content {
return
}
// Process text content for non-video, non-audio files
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(info.RealPath)
+15 -2
View File
@@ -1,10 +1,12 @@
package fileutils
import (
"errors"
"io"
"os"
"path"
"path/filepath"
"syscall"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -126,8 +128,7 @@ func copySingleFile(source, dest string) error {
defer dst.Close()
// Copy the contents of the file.
_, err = io.Copy(dst, src)
if err != nil {
if err = copyFileContents(dst, src); err != nil {
return err
}
@@ -146,6 +147,18 @@ func copySingleFile(source, dest string) error {
return nil
}
func copyFileContents(dst io.Writer, src io.Reader) error {
_, err := io.Copy(dst, src)
if !errors.Is(err, syscall.EBADF) {
return err
}
// Some FUSE filesystems reject copy_file_range. Hide the optional fast-path
// methods so io.Copy retries from the current offsets with buffered I/O.
_, err = io.Copy(struct{ io.Writer }{dst}, struct{ io.Reader }{src})
return err
}
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
srcInfo, err := os.Stat(source)
@@ -1,12 +1,33 @@
package fileutils
import (
"bytes"
"io"
"os"
"path/filepath"
"syscall"
"testing"
"time"
)
type copyFileRangeRejectingWriter struct {
bytes.Buffer
readFromCalls int
}
func (w *copyFileRangeRejectingWriter) ReadFrom(r io.Reader) (int64, error) {
w.readFromCalls++
buf := make([]byte, 4)
n, err := r.Read(buf)
if n > 0 {
_, _ = w.Write(buf[:n])
}
if err != nil {
return int64(n), err
}
return int64(n), syscall.EBADF
}
func TestUnixModeToFileMode_setgid(t *testing.T) {
m := unixModeToFileMode(0o2770)
if got := m.Perm(); got != 0o770 {
@@ -117,3 +138,19 @@ func TestCopyFilePreservesModTime(t *testing.T) {
})
}
}
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
const content = "complete file contents"
src := struct{ io.Reader }{bytes.NewBufferString(content)}
dst := &copyFileRangeRejectingWriter{}
if err := copyFileContents(dst, src); err != nil {
t.Fatal(err)
}
if got := dst.String(); got != content {
t.Fatalf("copied contents = %q, want %q", got, content)
}
if dst.readFromCalls != 1 {
t.Fatalf("ReadFrom calls = %d, want 1", dst.readFromCalls)
}
}
@@ -0,0 +1,47 @@
//go:build linux
package fileutils
import (
"syscall"
)
func blockSize(stat *syscall.Statfs_t) uint64 {
if stat.Frsize > 0 {
return uint64(stat.Frsize)
}
return uint64(stat.Bsize)
}
// GetPartitionSize returns the filesystem size for Linux systems
func GetPartitionSize(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
return uint64(stat.Blocks) * blockSize(&stat), nil
}
// GetFreeSpace returns the available free space for Linux systems
func GetFreeSpace(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
return uint64(stat.Bavail) * blockSize(&stat), nil
}
// GetPartitionUsed returns the used space for Linux 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
}
bsize := blockSize(&stat)
total := uint64(stat.Blocks) * bsize
free := uint64(stat.Bavail) * bsize
return total - free, nil
}
@@ -0,0 +1,48 @@
//go:build linux
package fileutils
import (
"syscall"
"testing"
)
func TestBlockSizePrefersFrsize(t *testing.T) {
// virtiofs (Docker Desktop for Mac) reports Bsize as the 1 MiB optimal
// transfer size while Blocks are still counted in the 4 KiB fragment unit.
stat := &syscall.Statfs_t{
Bsize: 1048576,
Frsize: 4096,
Blocks: 1465121792,
Bavail: 1054717543,
}
got := blockSize(stat)
if got != 4096 {
t.Fatalf("blockSize = %d, want 4096 (Frsize)", got)
}
total := uint64(stat.Blocks) * blockSize(stat)
const wantTotal = 1465121792 * 4096 // ~5.5 TiB, matches df
if total != wantTotal {
t.Fatalf("total = %d bytes, want %d (~5.5 TiB)", total, uint64(wantTotal))
}
// Sanity: the old Bsize-based math overstates by exactly 256x.
if inflated := uint64(stat.Blocks) * uint64(stat.Bsize); inflated != total*256 {
t.Fatalf("expected 256x inflation from Bsize, got %d vs %d", inflated, total)
}
}
func TestBlockSizeFallsBackToBsize(t *testing.T) {
// Normal filesystems report Frsize == Bsize (or leave Frsize unset on
// exotic drivers); either way the accounting unit is recovered.
stat := &syscall.Statfs_t{Bsize: 4096, Frsize: 4096}
if got := blockSize(stat); got != 4096 {
t.Fatalf("blockSize = %d, want 4096", got)
}
zero := &syscall.Statfs_t{Bsize: 4096, Frsize: 0}
if got := blockSize(zero); got != 4096 {
t.Fatalf("blockSize with Frsize=0 = %d, want 4096 (Bsize fallback)", got)
}
}
@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !linux
package fileutils
+2
View File
@@ -9,6 +9,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/ports"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Service performs authentication using injected user reads. It never persists users.
@@ -72,6 +73,7 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
}
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Debugf("login rejected: user %q attempted password login but account loginMethod is %q", username, user.LoginMethod)
return nil, errors.ErrWrongLoginMethod
}
return &user, nil
+13 -11
View File
@@ -3,7 +3,6 @@ package share
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
@@ -62,31 +61,34 @@ func prepForFrontendOne(link *Share, viewer *users.User, r *http.Request, public
}
out.SingleFileShare = snap.IsSingleFileShare()
if r != nil && publicHost != "" {
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, snap.Token)
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, snap.Token)
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, out.HasPassword)
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, out.HasPassword)
}
return &out
}
// PublicShareURL builds share/download URLs using pre-resolved host and scheme (see web/proxy.go).
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, token string) string {
tokenParam := ""
if token != "" && isDirectDownload {
tokenParam = fmt.Sprintf("&token=%s", url.QueryEscape(token))
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, hasPassword bool) string {
if isDirectDownload && hasPassword {
if settings.Config.Http.ExternalUrl != "" {
return fmt.Sprintf("%s%spublic/share/%s?download=true",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
}
return fmt.Sprintf("%s://%s%spublic/share/%s?download=true", scheme, host, settings.Config.Http.BaseURL, hash)
}
if settings.Config.Http.ExternalUrl != "" {
if isDirectDownload {
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash, tokenParam)
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
}
return fmt.Sprintf("%s%spublic/share/%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
}
if isDirectDownload {
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s",
scheme, host, settings.Config.Http.BaseURL, hash, tokenParam)
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s",
scheme, host, settings.Config.Http.BaseURL, hash)
}
return fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, settings.Config.Http.BaseURL, hash)
}
@@ -0,0 +1,31 @@
package share
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestPublicShareURLPasswordProtectedDirectLink(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.BaseURL = "/files/"
got := PublicShareURL("example.com", "https", "abc123", true, true)
want := "https://example.com/files/public/share/abc123?download=true"
if got != want {
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
}
}
func TestPublicShareURLDirectDownloadWithoutPassword(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.BaseURL = "/files/"
got := PublicShareURL("example.com", "https", "abc123", true, false)
want := "https://example.com/files/public/api/resources/download?hash=abc123"
if got != want {
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
}
}
-1
View File
@@ -112,7 +112,6 @@ type Share struct {
ShareColumns
PasswordHash string `json:"password_hash,omitempty"`
UserID uint64 `json:"userID,omitempty"`
Token string `json:"token,omitempty"`
UserDownloads map[string]int `json:"userDownloads,omitempty"`
Version int `json:"version,omitempty"`
SourcePath string `json:"sourcePath,omitempty"`
@@ -5,9 +5,8 @@ import (
"fmt"
)
// currentSchemaVersion is the SQLite schema marker for this codebase (version 1).
// BoltDB has no schema_version; importing via cmd/migrate builds this SQLite shape directly.
const currentSchemaVersion = 1
// currentSchemaVersion is the SQLite schema marker for this codebase.
const currentSchemaVersion = 2
// Schema creates all tables for the SQLite database
func createSchema(db *sql.DB) error {
@@ -39,7 +38,6 @@ func createSchema(db *sql.DB) error {
expire INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
password_hash TEXT,
token TEXT,
user_downloads TEXT,
share_settings TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 0
@@ -183,6 +181,10 @@ func runMigrations(db *sql.DB, fromVersion int) error {
switch v {
case 1:
// Canonical schema is createSchema + Bolt import; no step migrations.
case 2:
if err := normalizeLegacyShareTokens(db); err != nil {
return err
}
default:
return fmt.Errorf("unknown schema version: %d", v)
}
+55 -35
View File
@@ -40,9 +40,7 @@ func scanShareUserID(s string, dest *uint64) error {
// GetShareByHash retrieves a share by hash
func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE hash = ?`
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE hash = ?`
var link share.Share
var userIDStr string
@@ -56,7 +54,6 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
&link.Expire,
&link.Downloads,
&link.PasswordHash,
&link.Token,
&userDownloadsJSON,
&shareSettingsJSON,
&link.Version,
@@ -87,9 +84,7 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
// GetSharesByUserID retrieves all non-expired shares for an owner user id.
func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
now := time.Now().Unix()
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
ORDER BY path`
rows, err := s.db.Query(query, shareUserIDDB(userID), now)
@@ -103,9 +98,7 @@ func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
// GetSharesBySourcePath retrieves shares for a specific source and path
func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, error) {
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE source = ? AND path = ? ORDER BY hash`
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? ORDER BY hash`
rows, err := s.db.Query(query, source, path)
if err != nil {
@@ -119,9 +112,7 @@ func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, e
// GetSharesBySourcePathUser retrieves shares for a specific source, path, and owner user id.
func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64) ([]*share.Share, error) {
now := time.Now().Unix()
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE source = ? AND path = ? AND user_id = ?
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ?
AND (expire = 0 OR expire > ?) ORDER BY hash`
rows, err := s.db.Query(query, source, path, shareUserIDDB(userID), now)
@@ -135,9 +126,7 @@ func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64)
// GetPermanentShare retrieves a permanent share (expire = 0) for source, path, and owner.
func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share.Share, error) {
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
LIMIT 1`
var link share.Share
@@ -152,7 +141,6 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
&link.Expire,
&link.Downloads,
&link.PasswordHash,
&link.Token,
&userDownloadsJSON,
&shareSettingsJSON,
&link.Version,
@@ -183,9 +171,7 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
// ListAllShares retrieves all non-expired shares
func (s *SQLStore) ListAllShares() ([]*share.Share, error) {
now := time.Now().Unix()
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
rows, err := s.db.Query(query, now)
if err != nil {
@@ -207,24 +193,59 @@ func (s *SQLStore) SaveShare(link *share.Share) error {
return fmt.Errorf("failed to marshal share settings: %w", err)
}
query := `INSERT OR REPLACE INTO shares
hasLegacyToken, err := s.sharesHasLegacyTokenColumn()
if err != nil {
return fmt.Errorf("failed to inspect shares schema: %w", err)
}
legacyToken := ""
if hasLegacyToken {
legacyToken, err = s.legacyShareToken(link.Hash)
if err != nil {
return err
}
}
var query string
var args []any
if hasLegacyToken {
query = `INSERT OR REPLACE INTO shares
(hash, user_id, source, path, expire, downloads, password_hash,
token, user_downloads, share_settings, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
args = []any{
link.Hash,
shareUserIDDB(link.UserID),
link.SourcePath,
link.Path,
link.Expire,
link.Downloads,
link.PasswordHash,
legacyToken,
userDownloadsJSON,
shareSettingsJSON,
link.Version,
}
} else {
query = `INSERT OR REPLACE INTO shares
(hash, user_id, source, path, expire, downloads, password_hash,
user_downloads, share_settings, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
args = []any{
link.Hash,
shareUserIDDB(link.UserID),
link.SourcePath,
link.Path,
link.Expire,
link.Downloads,
link.PasswordHash,
userDownloadsJSON,
shareSettingsJSON,
link.Version,
}
}
_, err = s.db.Exec(query,
link.Hash,
shareUserIDDB(link.UserID),
link.SourcePath,
link.Path,
link.Expire,
link.Downloads,
link.PasswordHash,
link.Token,
userDownloadsJSON,
shareSettingsJSON,
link.Version,
)
_, err = s.db.Exec(query, args...)
if err != nil {
return fmt.Errorf("failed to save share: %w", err)
}
@@ -299,7 +320,6 @@ func (s *SQLStore) scanShares(rows *sql.Rows) ([]*share.Share, error) {
&link.Expire,
&link.Downloads,
&link.PasswordHash,
&link.Token,
&userDownloadsJSON,
&shareSettingsJSON,
&link.Version,
@@ -0,0 +1,81 @@
package sqldb
import (
"database/sql"
"fmt"
)
const shareSelectColumns = `hash, user_id, source, path, expire, downloads,
password_hash, user_downloads, share_settings, version`
func sharesTableHasColumn(db *sql.DB, column string) (bool, error) {
rows, err := db.Query(`PRAGMA table_info(shares)`)
if err != nil {
return false, fmt.Errorf("read shares schema: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
cid int
name string
colType string
notnull int
dfltValue sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &colType, &notnull, &dfltValue, &pk); err != nil {
return false, fmt.Errorf("scan shares schema: %w", err)
}
if name == column {
return true, nil
}
}
if err := rows.Err(); err != nil {
return false, fmt.Errorf("iterate shares schema: %w", err)
}
return false, nil
}
// normalizeLegacyShareTokens keeps rollback compatibility with releases that still
// scan shares.token into a Go string (NULL is unsupported there).
func normalizeLegacyShareTokens(db *sql.DB) error {
hasToken, err := sharesTableHasColumn(db, "token")
if err != nil {
return err
}
if !hasToken {
return nil
}
if _, err := db.Exec(`UPDATE shares SET token = '' WHERE token IS NULL`); err != nil {
return fmt.Errorf("normalize legacy share tokens: %w", err)
}
return nil
}
func (s *SQLStore) sharesHasLegacyTokenColumn() (bool, error) {
return sharesTableHasColumn(s.db, "token")
}
func (s *SQLStore) legacyShareToken(hash string) (string, error) {
hasToken, err := s.sharesHasLegacyTokenColumn()
if err != nil {
return "", err
}
if !hasToken {
return "", nil
}
var token sql.NullString
err = s.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, hash).Scan(&token)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read legacy share token: %w", err)
}
if !token.Valid {
return "", nil
}
return token.String, nil
}
@@ -0,0 +1,171 @@
package sqldb
import (
"database/sql"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
)
func createLegacySharesDB(t *testing.T) *SQLStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "legacy-shares.db")
store, _, err := NewSQLStore(dbPath)
if err != nil {
t.Fatalf("NewSQLStore: %v", err)
}
_, err = store.db.Exec(`DROP TABLE shares`)
if err != nil {
t.Fatalf("drop shares: %v", err)
}
_, err = store.db.Exec(`
CREATE TABLE shares (
hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
source TEXT NOT NULL,
path TEXT NOT NULL,
expire INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
password_hash TEXT,
token TEXT,
user_downloads TEXT,
share_settings TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 0
)`)
if err != nil {
t.Fatalf("create legacy shares table: %v", err)
}
_, err = store.db.Exec(`
INSERT INTO shares (
hash, user_id, source, path, expire, downloads, password_hash, token,
user_downloads, share_settings, version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"legacy-null-token",
"1",
"/data",
"/docs/file.txt",
0,
0,
"",
nil,
"{}",
`{"shareType":"normal"}`,
1,
)
if err != nil {
t.Fatalf("insert legacy share: %v", err)
}
return store
}
func scanLegacyShareToken(t *testing.T, store *SQLStore, hash string) string {
t.Helper()
var token string
err := store.db.QueryRow(`
SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE hash = ?`, hash).Scan(
new(string),
new(string),
new(string),
new(string),
new(int64),
new(int),
new(string),
&token,
new([]byte),
new([]byte),
new(int),
)
if err != nil {
t.Fatalf("scan legacy share row: %v", err)
}
return token
}
func TestMigrationNormalizesNullLegacyShareToken(t *testing.T) {
store := createLegacySharesDB(t)
if err := runMigrations(store.db, 1); err != nil {
t.Fatalf("runMigrations: %v", err)
}
token := scanLegacyShareToken(t, store, "legacy-null-token")
if token != "" {
t.Fatalf("token = %q, want empty string after migration", token)
}
links, err := store.ListAllShares()
if err != nil {
t.Fatalf("ListAllShares: %v", err)
}
if len(links) != 1 || links[0].Hash != "legacy-null-token" {
t.Fatalf("unexpected shares loaded: %#v", links)
}
}
func TestSaveSharePreservesLegacyTokenColumn(t *testing.T) {
store := createLegacySharesDB(t)
if err := normalizeLegacyShareTokens(store.db); err != nil {
t.Fatalf("normalizeLegacyShareTokens: %v", err)
}
_, err := store.db.Exec(`UPDATE shares SET token = ? WHERE hash = ?`, "keep-me", "legacy-null-token")
if err != nil {
t.Fatalf("seed legacy token: %v", err)
}
link, err := store.GetShareByHash("legacy-null-token")
if err != nil {
t.Fatalf("GetShareByHash: %v", err)
}
link.Downloads = 2
if err := store.SaveShare(link); err != nil {
t.Fatalf("SaveShare: %v", err)
}
token := scanLegacyShareToken(t, store, "legacy-null-token")
if token != "keep-me" {
t.Fatalf("token = %q, want keep-me", token)
}
var tokenSQL sql.NullString
if err := store.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, "legacy-null-token").Scan(&tokenSQL); err != nil {
t.Fatalf("select token: %v", err)
}
if !tokenSQL.Valid {
t.Fatal("expected legacy token column to remain non-null after save")
}
}
func TestSaveShareSetsEmptyLegacyTokenForNewShare(t *testing.T) {
store := createLegacySharesDB(t)
if err := normalizeLegacyShareTokens(store.db); err != nil {
t.Fatalf("normalizeLegacyShareTokens: %v", err)
}
newShare := &share.Share{
ShareSettings: share.ShareSettings{
FrontendShareInfo: share.FrontendShareInfo{ShareType: "normal"},
},
ShareColumns: share.ShareColumns{
Hash: "brand-new-share",
Path: "/new/file.txt",
},
SourcePath: "/data",
UserID: 1,
Version: 1,
}
if err := store.SaveShare(newShare); err != nil {
t.Fatalf("SaveShare: %v", err)
}
token := scanLegacyShareToken(t, store, "brand-new-share")
if token != "" {
t.Fatalf("token = %q, want empty string for new share", token)
}
}
+1
View File
@@ -25,6 +25,7 @@ var (
ErrNotViewable = errors.New("directory or item is not viewable")
ErrPathEscapesScope = errors.New("resolved path escapes scope")
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
ErrInvalidLoginMethod = errors.New("invalid login method")
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
ErrUseMediaStream = errors.New("use /media/stream for audio and video")
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
+60 -7
View File
@@ -5,6 +5,8 @@ import (
"context"
"errors"
"fmt"
"image"
"image/color"
"io/fs"
"os"
"path/filepath"
@@ -28,8 +30,9 @@ const (
// iconSize defines an icon size to generate
type iconSize struct {
size int
name string
size int
name string
maskable bool
}
// determineSourceType determines which type of favicon source we have
@@ -153,13 +156,16 @@ func handleRasterFavicon(faviconPath string) ([]byte, error) {
func getIconSizesToGenerate() []iconSize {
return []iconSize{
// Browser favicon (32x32 PNG for Safari/older browsers)
{32, "favicon-32x32.png"},
{32, "favicon-32x32.png", false},
// PWA icons (PNG required for Apple PWA compatibility)
{192, "pwa-icon-192.png"},
{256, "pwa-icon-256.png"}, // Also serves as Windows tile
{512, "pwa-icon-512.png"},
{192, "pwa-icon-192.png", false},
{256, "pwa-icon-256.png", false}, // Also serves as Windows tile
{512, "pwa-icon-512.png", false},
// Maskable PWA icons: art inset into the Android adaptive-icon safe zone
{192, "pwa-icon-maskable-192.png", true},
{512, "pwa-icon-maskable-512.png", true},
// Platform-specific icons
{180, "apple-touch-icon.png"}, // iOS home screen (required for Apple)
{180, "apple-touch-icon.png", false}, // iOS home screen (required for Apple)
}
}
@@ -193,8 +199,55 @@ func generateIconSizes(sourceData []byte) error {
return nil
}
// generateMaskableIcon insets the art into the safe zone on an opaque background, since launchers zoom maskable icons to fill the mask edge-to-edge
func generateMaskableIcon(sourceData []byte, icon iconSize) error {
img, err := imaging.Decode(bytes.NewReader(sourceData))
if err != nil {
return fmt.Errorf("failed to decode source: %w", err)
}
// Android adaptive-icon content ratio (72/108 ≈ 2/3) keeps square art inside every mask shape
inner := icon.size * 2 / 3
art := imaging.Fit(img, inner, inner, imaging.Lanczos)
canvas := imaging.PasteCenter(imaging.New(icon.size, icon.size, maskableBackground(img)), art)
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer outFile.Close()
if err := imaging.Encode(outFile, canvas, imaging.PNG); err != nil {
os.Remove(outputPath)
return fmt.Errorf("encode failed: %w", err)
}
return nil
}
// maskableBackground samples the art's corners then edge midpoints for an opaque pad color, else white
func maskableBackground(img image.Image) color.Color {
b := img.Bounds()
cx, cy := (b.Min.X+b.Max.X)/2, (b.Min.Y+b.Max.Y)/2
points := []image.Point{
{b.Min.X, b.Min.Y}, {b.Max.X - 1, b.Min.Y}, {b.Min.X, b.Max.Y - 1}, {b.Max.X - 1, b.Max.Y - 1},
{cx, b.Min.Y}, {cx, b.Max.Y - 1}, {b.Min.X, cy}, {b.Max.X - 1, cy},
}
for _, p := range points {
r, g, bl, a := img.At(p.X, p.Y).RGBA()
if a == 0xffff {
return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(bl >> 8), A: 255}
}
}
return color.White
}
// generateSingleIcon generates a single icon file at the specified size
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
if icon.maskable {
return generateMaskableIcon(sourceData, icon)
}
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
// Create output file
+64
View File
@@ -0,0 +1,64 @@
package icons
import (
"image"
"image/color"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestMaskableBackgroundOpaqueCorner(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
got := maskableBackground(img)
want := color.NRGBA{R: 10, G: 20, B: 30, A: 255}
if got != want {
t.Fatalf("opaque corner: got %#v, want %#v", got, want)
}
}
func TestMaskableBackgroundOpaqueEdge(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
img.Set(6, 0, color.RGBA{R: 200, G: 100, B: 50, A: 255})
got := maskableBackground(img)
want := color.NRGBA{R: 200, G: 100, B: 50, A: 255}
if got != want {
t.Fatalf("opaque edge: got %#v, want %#v", got, want)
}
}
func TestMaskableBackgroundAllTransparent(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
if got := maskableBackground(img); got != color.White {
t.Fatalf("all transparent: got %#v, want white", got)
}
}
func TestDefaultBackgroundColor(t *testing.T) {
saved := settings.Config
defer func() { settings.Config = saved }()
dark := true
light := false
settings.Config.Frontend.Styling.DarkBackground = "#111111"
settings.Config.Frontend.Styling.LightBackground = "#ffffff"
settings.Config.UserDefaults.UI.DarkMode = &dark
if got := settings.DefaultBackgroundColor(); got != "#111111" {
t.Fatalf("dark branch: got %q, want %q", got, "#111111")
}
settings.Config.UserDefaults.UI.DarkMode = &light
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
t.Fatalf("light branch (dark disabled): got %q, want %q", got, "#ffffff")
}
settings.Config.UserDefaults.UI.DarkMode = nil
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
t.Fatalf("light branch (unset): got %q, want %q", got, "#ffffff")
}
}
+20 -6
View File
@@ -44,14 +44,14 @@ func pwaManifestName(name string) string {
}
// generatePWAManifest creates the PWA manifest structure
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
func generatePWAManifest(name, description, baseURL, themeColor, backgroundColor, pwaIcon192, pwaIcon256, pwaIcon512, pwaIconMaskable192, pwaIconMaskable512 string) PWAManifest {
return PWAManifest{
Name: pwaManifestName(name),
ID: baseURL,
Scope: baseURL,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
BackgroundColor: backgroundColor,
ThemeColor: themeColor,
Description: description,
Icons: []PWAIcon{
@@ -65,7 +65,7 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
Src: pwaIcon256,
Sizes: "256x256",
Type: "image/png",
Purpose: "any maskable",
Purpose: "any",
},
{
Src: pwaIcon512,
@@ -73,6 +73,18 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
Type: "image/png",
Purpose: "any",
},
{
Src: pwaIconMaskable192,
Sizes: "192x192",
Type: "image/png",
Purpose: "maskable",
},
{
Src: pwaIconMaskable512,
Sizes: "512x512",
Type: "image/png",
Purpose: "maskable",
},
},
}
}
@@ -85,11 +97,13 @@ func InitializePWAManifest() {
staticURL := config.Http.BaseURL + "public/static"
title := config.Frontend.Name
description := config.Frontend.Description
defaultThemeColor := "#455a64"
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
pwaIconMaskable192 := staticURL + "/icons/pwa-icon-maskable-192.png"
pwaIconMaskable512 := staticURL + "/icons/pwa-icon-maskable-512.png"
CachedManifest = generatePWAManifest(title, description, config.Http.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
backgroundColor := settings.DefaultBackgroundColor()
CachedManifest = generatePWAManifest(title, description, config.Http.BaseURL, backgroundColor, backgroundColor, pwaIcon192, pwaIcon256, pwaIcon512, pwaIconMaskable192, pwaIconMaskable512)
}
+2
View File
@@ -56,6 +56,8 @@ func ExtractEmbeddedPreview(ctx context.Context, path string) ([]byte, error) {
switch {
case ext == ".cr3":
data, err = extimagemeta.PreviewCR3(f)
case ext == ".raf":
data, err = extractRAFEmbeddedPreview(f)
case isRawImageExtension(ext):
data, err = extractTIFFEmbeddedPreview(f)
case isHEICExtension(ext):
+68
View File
@@ -0,0 +1,68 @@
//go:build !386 && !arm
package imagemeta
import (
"encoding/binary"
"io"
"os"
)
const (
rafMagicLen = 16
rafMagic = "FUJIFILMCCD-RAW "
rafJPEGOffsetPos = 0x54
rafJPEGLengthPos = 0x58
rafHeaderMinBytes = 0x5C
)
// extractRAFEmbeddedPreview reads the camera-embedded JPEG from a Fuji RAF file.
// Offset and length are stored as big-endian uint32 at 0x54 and 0x58 in the RAF header directory.
func extractRAFEmbeddedPreview(f *os.File) ([]byte, error) {
info, err := f.Stat()
if err != nil {
return nil, err
}
if info.Size() < rafHeaderMinBytes {
return nil, nil
}
header := make([]byte, rafHeaderMinBytes)
n, err := f.ReadAt(header, 0)
if err != nil && err != io.EOF {
return nil, err
}
if n < rafHeaderMinBytes {
return nil, nil
}
if !isRAFMagic(header[:rafMagicLen]) {
return nil, nil
}
offset := binary.BigEndian.Uint32(header[rafJPEGOffsetPos:rafJPEGLengthPos])
length := binary.BigEndian.Uint32(header[rafJPEGLengthPos:rafHeaderMinBytes])
if offset == 0 || length == 0 {
return nil, nil
}
if int64(offset)+int64(length) > info.Size() {
return nil, nil
}
data, err := readFileRange(f, offset, length)
if err != nil || len(data) == 0 || !IsJPEG(data) {
return nil, nil
}
return data, nil
}
func isRAFMagic(b []byte) bool {
if len(b) < len(rafMagic) {
return false
}
for i := 0; i < len(rafMagic); i++ {
if b[i] != rafMagic[i] {
return false
}
}
return true
}
@@ -0,0 +1,94 @@
//go:build !386 && !arm
package imagemeta
import (
"context"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func TestExtractRAFEmbeddedPreviewSynthetic(t *testing.T) {
jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9}
const jpegOffset = uint32(0x94)
data := make([]byte, int(jpegOffset)+len(jpeg))
copy(data[:rafMagicLen], []byte(rafMagic))
binary.BigEndian.PutUint32(data[rafJPEGOffsetPos:rafJPEGLengthPos], jpegOffset)
binary.BigEndian.PutUint32(data[rafJPEGLengthPos:rafHeaderMinBytes], uint32(len(jpeg)))
copy(data[jpegOffset:], jpeg)
path := filepath.Join(t.TempDir(), "sample.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if !IsJPEG(got) {
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
}
if len(got) != len(jpeg) {
t.Fatalf("preview size = %d, want %d", len(got), len(jpeg))
}
}
func TestExtractRAFEmbeddedPreviewIgnoresInvalidMagic(t *testing.T) {
data := make([]byte, 256)
copy(data[:rafMagicLen], []byte("FUJIFILMCCD-RAW!"))
path := filepath.Join(t.TempDir(), "bad.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatalf("ExtractEmbeddedPreview() = %v, want nil", got)
}
}
func TestExtractRAFEmbeddedPreviewDSCF1276(t *testing.T) {
path := "/Users/steffag/Downloads/DSCF1276.RAF"
if _, err := os.Stat(path); err != nil {
t.Skip(path)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if !IsJPEG(got) {
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
}
if len(got) < 100 {
t.Fatalf("preview size = %d, want >= 100", len(got))
}
}
func TestExtractRAFEmbeddedPreviewRejectsOutOfBoundsOffset(t *testing.T) {
jpeg := []byte{0xff, 0xd8, 0xff, 0xd9}
data := make([]byte, 256)
copy(data[:rafMagicLen], []byte(rafMagic))
binary.BigEndian.PutUint32(data[rafJPEGOffsetPos:rafJPEGLengthPos], 200)
binary.BigEndian.PutUint32(data[rafJPEGLengthPos:rafHeaderMinBytes], uint32(len(jpeg)))
path := filepath.Join(t.TempDir(), "oob.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatalf("ExtractEmbeddedPreview() = %v, want nil", got)
}
}
+17 -3
View File
@@ -271,10 +271,17 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
case previewTypeImage:
ext := strings.ToLower(filepath.Ext(file.Name))
if iteminfo.IsRawImage(ext) && s.ffmpegService != nil {
if bytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize); err == nil && len(bytes) >= minPreviewSize {
return bytes, nil
if iteminfo.IsRawImage(ext) {
if s.ffmpegService != nil {
previewBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err == nil && len(previewBytes) >= minPreviewSize {
return previewBytes, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
return nil, ErrUnsupportedFormat
}
return s.generateImagePreview(ctx, file, previewSize)
@@ -370,6 +377,13 @@ func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.Extend
return nil, err
}
ext := strings.ToLower(filepath.Ext(file.Name))
format, err := s.FormatFromExtension(ext)
if err != nil {
return nil, err
}
options.Format = format
imageBytes, err := s.CreatePreview(ctx, f, file.Size, options)
if err != nil {
if ctx.Err() != nil {
@@ -0,0 +1,119 @@
package preview
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
func TestGenerateRawPreviewRawWithoutEmbeddedReturnsUnsupported(t *testing.T) {
data := make([]byte, 256)
copy(data[:16], []byte("FUJIFILMCCD-RAW "))
dir := t.TempDir()
path := filepath.Join(dir, "empty.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
svc := NewPreviewGenerator(1, dir)
file := iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "empty.raf",
Size: int64(len(data)),
ModTime: time.Now(),
Type: "image/x-fuji-raf",
},
},
RealPath: path,
}
_, err := svc.generateRawPreview(context.Background(), file, "small", "", 0, "hash")
if !errors.Is(err, ErrUnsupportedFormat) {
t.Fatalf("generateRawPreview() err = %v, want ErrUnsupportedFormat", err)
}
if strings.Contains(err.Error(), "SOI") {
t.Fatalf("generateRawPreview() should fail fast, got JPEG decode error: %v", err)
}
}
func TestGenerateImagePreviewUnsupportedExtensionReturnsUnsupported(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "clip.mov")
// Must exceed maxSizeForOriginal (256KB) so we don't short-circuit to raw bytes.
payload := make([]byte, 256*1024+1)
copy(payload, []byte("not a video"))
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatal(err)
}
svc := NewPreviewGenerator(1, dir)
file := iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "clip.mov",
Size: int64(len(payload)),
ModTime: time.Now(),
Type: "video/quicktime",
},
},
RealPath: path,
}
_, err := svc.generateImagePreview(context.Background(), file, "small")
if !errors.Is(err, ErrUnsupportedFormat) {
t.Fatalf("generateImagePreview() err = %v, want ErrUnsupportedFormat", err)
}
}
func TestGenerateImagePreviewUsesPNGFormat(t *testing.T) {
dir := t.TempDir()
svc := NewPreviewGenerator(1, dir)
// 1x1 red PNG; pad past maxSizeForOriginal (256KB) so we exercise format selection.
const maxSizeForOriginal = 256 * 1024
pngBytes := []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
0x44, 0xae, 0x42, 0x60, 0x82,
}
payload := make([]byte, maxSizeForOriginal+1)
copy(payload, pngBytes)
path := filepath.Join(dir, "pixel.png")
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatal(err)
}
file := iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "pixel.png",
Size: int64(len(payload)),
ModTime: time.Now(),
Type: "image/png",
},
},
RealPath: path,
}
got, err := svc.generateImagePreview(context.Background(), file, "small")
if err != nil {
t.Fatal(err)
}
if len(got) < 8 || string(got[:8]) != "\x89PNG\r\n\x1a\n" {
t.Fatalf("expected PNG signature, got %x", got[:min(8, len(got))])
}
}
+3 -1
View File
@@ -49,7 +49,9 @@ func initialize(dbPath string) (bool, error) {
defer sharesMux.Unlock()
defer indexMux.Unlock()
logger.Info("Initializing state management system...")
if !settings.Env.IsCLIMode {
logger.Info("Initializing state management system...")
}
var existingDb bool
var err error
sqlDb, existingDb, err = sqldb.NewSQLStore(dbPath)
+22
View File
@@ -10,6 +10,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
)
// User operations
@@ -221,6 +222,10 @@ func CreateUser(user *users.User, plaintextPassword string) error {
users.SyncBackendSourcePermissionsMap(user)
if links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes); changed {
user.SidebarLinks = links
}
usersMux.Lock()
defer usersMux.Unlock()
@@ -328,6 +333,19 @@ func UpdateUser(user *users.User, plaintextPassword string, fields ...string) er
}
}
if sidebarFieldsPatched(fields) {
var links []users.SidebarLink
var changed bool
if fieldListRequiresScopeConversion(fields) {
links, changed = usersidebar.PrepareSidebarLinksForPersist(existingUser.SidebarLinks, existingUser.BackendScopes)
} else {
links, changed = usersidebar.NormalizeSidebarLinks(existingUser.SidebarLinks)
}
if changed {
existingUser.SidebarLinks = links
}
}
return commitUserUpdate(existingUser, storedSnapshot, sourceDefaults, sourceEnforced)
}
@@ -390,6 +408,10 @@ func fieldListRequiresScopeConversion(fields []string) bool {
return fieldListPatchesBackendScopes(fields) || fieldListPatchesAPISourcePermissions(fields)
}
func sidebarFieldsPatched(fields []string) bool {
return FieldListIncludes(fields, "sidebarLinks") || fieldListRequiresScopeConversion(fields)
}
// applyScopesFromAPI converts API scopes (with nested permissions) into BackendScopes.
// Legacy sourcePermissions map is merged into scopes when permissions are omitted per scope.
func applyScopesFromAPI(user *users.User) error {
@@ -0,0 +1,181 @@
package state
import (
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func initSidebarLinkTestDB(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize("../../../_docker/src/noauth/backend/config.yaml")
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
if _, err := Initialize(dbPath); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = Close() })
}
func countSourceSidebarLinks(links []users.SidebarLink) int {
n := 0
for _, link := range links {
if strings.HasPrefix(link.Category, "source") {
n++
}
}
return n
}
func TestCreateUserAddsSidebarLinksForAllScopes(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "scope-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
{Name: "include", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("scope-user")
if err != nil {
t.Fatal(err)
}
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
t.Fatalf("source sidebar links = %d, want 2: %#v", count, loaded.SidebarLinks)
}
}
func TestUpdateUserScopesAddsMissingSidebarLink(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "patch-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
patch := &users.User{
ID: u.ID,
FrontendUser: users.FrontendUser{
Username: "patch-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
{Name: "include", Scope: "/"},
},
},
}
if err := UpdateUser(patch, "", "scopes"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("patch-user")
if err != nil {
t.Fatal(err)
}
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
t.Fatalf("source sidebar links = %d, want 2: %#v", count, loaded.SidebarLinks)
}
}
func TestUpdateUserScopesPrunesSidebarLinks(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "stale-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
{Name: "include", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
patch := &users.User{
ID: u.ID,
FrontendUser: users.FrontendUser{
Username: "stale-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
},
},
}
if err := UpdateUser(patch, "", "scopes"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("stale-user")
if err != nil {
t.Fatal(err)
}
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 1 {
t.Fatalf("expected revoked include link pruned, got %d links: %#v", count, loaded.SidebarLinks)
}
}
func TestUpdateUserSidebarLinksPreservesFolderShortcuts(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "folder-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
links := []users.SidebarLink{
{Name: "exclude", Category: "source", SourceName: "exclude", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: "exclude", Target: "/photos"},
{Name: "divider", Category: "divider", Target: "#"},
}
patch := &users.User{
ID: u.ID,
FrontendUser: users.FrontendUser{
Username: "folder-user",
NonAdminEditable: users.NonAdminEditable{
SidebarLinks: links,
},
},
}
if err := UpdateUser(patch, "", "sidebarLinks"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("folder-user")
if err != nil {
t.Fatal(err)
}
if len(loaded.SidebarLinks) != 3 {
t.Fatalf("len(sidebarLinks) = %d, want 3: %#v", len(loaded.SidebarLinks), loaded.SidebarLinks)
}
if loaded.SidebarLinks[1].Name != "Photos" || loaded.SidebarLinks[1].Icon != "photo" || loaded.SidebarLinks[1].Target != "/photos" {
t.Fatalf("folder shortcut not preserved: %#v", loaded.SidebarLinks[1])
}
if !strings.Contains(loaded.SidebarLinks[0].SourceName, "playwright-files") {
t.Fatalf("expected canonical path persisted, got SourceName=%q", loaded.SidebarLinks[0].SourceName)
}
}
+3
View File
@@ -28,6 +28,9 @@ func FrontendLinks(links []users.SidebarLink, showToolsInSidebar bool) []users.S
}
}
link.SourceName = source.Name
if link.Name == "" {
link.Name = source.Name
}
} else if link.Category == "tool" && link.Target == "/tools" {
hasTools = true
}
@@ -0,0 +1,27 @@
package usersidebar
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestFrontendLinks_populatesEmptySourceName(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Category: "source", SourceName: ".", Target: "/"},
{Name: "My Vault", Category: string(users.SidebarLinkSourceMinimal), Icon: "folder", SourceName: "../frontend/tests/playwright-files", Target: "/docs"},
}
out := FrontendLinks(in, false)
if len(out) != 2 {
t.Fatalf("len(out) = %d, want 2", len(out))
}
if out[0].Name != "docker" || out[0].SourceName != "docker" {
t.Fatalf("default source link = %#v", out[0])
}
if out[1].Name != "My Vault" || out[1].SourceName != "playwright + files" {
t.Fatalf("custom source link = %#v", out[1])
}
}
+36 -13
View File
@@ -8,9 +8,12 @@ import (
// NormalizeSidebarLinks canonicalizes persisted sidebar links for storage.
// Source links are resolved via ResolveSourceKey on SourceName, with Name as fallback.
// SourceName is set to the canonical backend path; Name to the configured display name.
// Unresolvable source links are dropped. Source links are deduped by canonical path
// (first occurrence wins). Non-source links pass through; duplicate Tools links are deduped.
// SourceName is set to the canonical backend path; custom Name, Icon, and Category are preserved.
// Empty Name means "use live source display name"; names matching the current source display name
// are blanked so renames propagate without overwriting custom labels.
// Unresolvable source links are dropped. Source links are deduped by canonical path plus target
// (first occurrence wins), so multiple folder shortcuts on one source are kept. Non-source links
// pass through; duplicate Tools links are deduped.
func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool) {
if !users.SourceConfigLoaded() {
return links, false
@@ -19,7 +22,7 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
return links, false
}
seenSourcePaths := make(map[string]struct{})
seenSourceKeys := make(map[sourceLinkDedupeKey]struct{})
hasTools := false
out := make([]users.SidebarLink, 0, len(links))
changed := false
@@ -31,21 +34,22 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
changed = true
continue
}
if _, dup := seenSourcePaths[source.Path]; dup {
changed = true
continue
}
seenSourcePaths[source.Path] = struct{}{}
normalized := link
normalized.Category = users.NormalizeSidebarLinkCategory(normalized.Category)
normalized.SourceName = source.Path
if strings.TrimSpace(normalized.Name) == "" {
normalized.Name = source.Name
if strings.TrimSpace(normalized.Name) == source.Name {
normalized.Name = ""
}
if normalized.Target == "" {
normalized.Target = "/"
normalized.Target = normalizeSidebarTarget(normalized.Target)
key := sourceLinkDedupeKey{sourcePath: source.Path, target: normalized.Target}
if _, dup := seenSourceKeys[key]; dup {
changed = true
continue
}
seenSourceKeys[key] = struct{}{}
if normalized != link {
changed = true
}
@@ -77,3 +81,22 @@ func resolveSourceLink(link users.SidebarLink) (users.SourceInfo, bool) {
}
return users.SourceInfo{}, false
}
func normalizeSidebarTarget(target string) string {
t := strings.TrimSpace(target)
if t == "" || t == "#" {
return "/"
}
if !strings.HasPrefix(t, "/") {
t = "/" + t
}
if t != "/" && strings.HasSuffix(t, "/") {
t = strings.TrimSuffix(t, "/")
}
return t
}
type sourceLinkDedupeKey struct {
sourcePath string
target string
}
+115 -12
View File
@@ -69,9 +69,9 @@ func TestNormalizeSidebarLinks_dedupesLinuxAndDockerPaths(t *testing.T) {
t.Fatalf("len(out) = %d, want 3", len(out))
}
want := []users.SidebarLink{
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
{Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
{Category: "source", SourceName: ".", Target: "/"},
{Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
}
if !reflect.DeepEqual(out, want) {
t.Fatalf("got %#v, want %#v", out, want)
@@ -95,8 +95,24 @@ func TestNormalizeSidebarLinks_nameFallbackRemapsStalePath(t *testing.T) {
if out[0].SourceName != "../frontend/tests/playwright-files" {
t.Fatalf("SourceName = %q", out[0].SourceName)
}
if out[0].Name != "playwright + files" {
t.Fatalf("Name = %q", out[0].Name)
if out[0].Name != "" {
t.Fatalf("Name = %q, want blanked default name", out[0].Name)
}
}
func TestNormalizeSidebarLinks_blanksDefaultSourceName(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
}
out, changed := NormalizeSidebarLinks(in)
if !changed {
t.Fatal("expected changed=true")
}
if out[0].Name != "" {
t.Fatalf("Name = %q, want blanked when matching source display name", out[0].Name)
}
}
@@ -110,18 +126,20 @@ func TestNormalizeSidebarLinks_idempotentOnCanonicalLinks(t *testing.T) {
}
out, changed := NormalizeSidebarLinks(in)
if changed {
t.Fatal("expected changed=false for canonical links")
if !changed {
t.Fatal("expected changed=true when blanking default names")
}
if !reflect.DeepEqual(out, in) {
t.Fatalf("got %#v, want %#v", out, in)
for _, link := range out {
if link.Name != "" {
t.Fatalf("expected blank default name, got %#v", link)
}
}
out2, changed2 := NormalizeSidebarLinks(out)
if changed2 {
t.Fatal("expected second pass unchanged")
}
if !reflect.DeepEqual(out2, in) {
if !reflect.DeepEqual(out2, out) {
t.Fatalf("second pass got %#v", out2)
}
}
@@ -141,8 +159,8 @@ func TestNormalizeSidebarLinks_dropsUnresolvableSourceLink(t *testing.T) {
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
}
if out[0].Name != "docker" {
t.Fatalf("remaining link = %#v", out[0])
if out[0].Name != "" {
t.Fatalf("remaining link should have blanked default name: %#v", out[0])
}
}
@@ -164,6 +182,91 @@ func TestNormalizeSidebarLinks_dedupesToolsLink(t *testing.T) {
}
}
func TestNormalizeSidebarLinks_preservesMultipleFolderShortcutsOnSameSource(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
{Name: "Docs", Category: string(users.SidebarLinkSourceAlt), Icon: "folder", SourceName: ".", Target: "/docs"},
{Name: "divider", Category: "divider", Target: "#"},
}
out, changed := NormalizeSidebarLinks(in)
if !changed {
t.Fatal("expected changed=true when blanking default root source name")
}
if len(out) != 4 {
t.Fatalf("len(out) = %d, want 4", len(out))
}
if out[0].Name != "" {
t.Fatalf("root source link should have blank default name: %#v", out[0])
}
if out[1].Name != "Photos" || out[1].Icon != "photo" || out[1].Target != "/photos" {
t.Fatalf("photos link = %#v", out[1])
}
if out[2].Category != string(users.SidebarLinkSourceAlt) {
t.Fatalf("docs category = %q", out[2].Category)
}
}
func TestNormalizeSidebarLinks_dedupesSameSourceAndTarget(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "docker root", Category: "source-minimal", Icon: "home", SourceName: "docker", Target: "/"},
}
out, changed := NormalizeSidebarLinks(in)
if !changed {
t.Fatal("expected changed=true")
}
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
}
if out[0].Name != "" {
t.Fatalf("first link wins with blanked default name: %#v", out[0])
}
}
func TestNormalizeSidebarLinks_noCollisionOnPipeInPathOrTarget(t *testing.T) {
had := users.SourceConfigLoaded()
t.Cleanup(func() {
if !had {
users.SetSourceConfig(nil)
}
})
users.SetSourceConfig(&users.SourceConfigProvider{
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
switch path {
case "a":
return users.SourceInfo{Path: "a", Name: "src-a"}, true
case "a|/b":
return users.SourceInfo{Path: "a|/b", Name: "src-pipe"}, true
default:
return users.SourceInfo{}, false
}
},
GetSourceByName: func(name string) (users.SourceInfo, bool) {
return users.SourceInfo{}, false
},
})
in := []users.SidebarLink{
{Name: "one", Category: "source", SourceName: "a", Target: "/b|/c"},
{Name: "two", Category: "source", SourceName: "a|/b", Target: "/c"},
}
out, changed := NormalizeSidebarLinks(in)
if changed {
t.Fatal("expected changed=false for distinct path/target pairs")
}
if len(out) != 2 {
t.Fatalf("len(out) = %d, want 2 (no false dedupe)", len(out))
}
}
func TestNormalizeSidebarLinks_noConfigLoaded(t *testing.T) {
had := users.SourceConfigLoaded()
t.Cleanup(func() {
+33
View File
@@ -0,0 +1,33 @@
package usersidebar
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
// PrepareSidebarLinksForPersist normalizes sidebar links, prunes links for revoked scopes,
// and adds missing scoped source entries. Sources absent from config are dropped by NormalizeSidebarLinks.
func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
updated := false
if normalized, changed := NormalizeSidebarLinks(links); changed {
links = normalized
updated = true
}
if pruned, changed := PruneSidebarLinksForScopes(links, scopes); changed {
links = pruned
updated = true
}
merged, changed := EnsureSidebarLinksFromScopes(links, scopes)
if changed {
links = merged
updated = true
if normalized, changed := NormalizeSidebarLinks(links); changed {
links = normalized
updated = true
}
}
return links, updated
}
@@ -0,0 +1,55 @@
package usersidebar
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestPrepareSidebarLinksForPersist_addsScopedSourcesAndPreservesFolders(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
}
scopes := []users.BackendScope{
{Path: "."},
{Path: "../frontend/tests/playwright-files"},
}
out, changed := PrepareSidebarLinksForPersist(links, scopes)
if !changed {
t.Fatal("expected changed=true")
}
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3", len(out))
}
if out[1].Name != "Photos" || out[1].Icon != "photo" {
t.Fatalf("folder shortcut lost: %#v", out[1])
}
if out[2].Name != "" {
t.Fatalf("added scope link should have empty default name, got %#v", out[2])
}
}
func TestPrepareSidebarLinksForPersist_prunesLinksWhenScopeRemoved(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
}
scopes := []users.BackendScope{
{Path: "."},
}
out, changed := PrepareSidebarLinksForPersist(links, scopes)
if !changed {
t.Fatal("expected changed=true when revoked scope link is pruned")
}
if len(out) != 2 {
t.Fatalf("len(out) = %d, want 2 (revoked scope link removed)", len(out))
}
}
+37
View File
@@ -0,0 +1,37 @@
package usersidebar
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
// PruneSidebarLinksForScopes removes source-type links whose source is not in the user's scopes.
// Non-source links (tool, custom, divider) are preserved.
func PruneSidebarLinksForScopes(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
if !users.SourceConfigLoaded() || len(scopes) == 0 {
return links, false
}
allowed := make(map[string]struct{}, len(scopes))
for _, path := range uniqueScopedSourcePaths(scopes) {
allowed[path] = struct{}{}
}
out := make([]users.SidebarLink, 0, len(links))
changed := false
for _, link := range links {
if users.IsSourceSidebarCategory(link.Category) {
source, ok := resolveSourceLink(link)
if !ok {
changed = true
continue
}
if _, ok := allowed[source.Path]; !ok {
changed = true
continue
}
}
out = append(out, link)
}
return out, changed
}
@@ -0,0 +1,51 @@
package usersidebar
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestPruneSidebarLinksForScopes_removesRevokedSourceAndFolderShortcut(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
{Name: "Docs", Category: string(users.SidebarLinkCustom), Target: "https://example.com", Icon: "link"},
}
scopes := []users.BackendScope{
{Path: "."},
}
out, changed := PruneSidebarLinksForScopes(links, scopes)
if !changed {
t.Fatal("expected changed=true")
}
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3 (playwright link removed, docker + folder + custom kept)", len(out))
}
if out[2].Category != string(users.SidebarLinkCustom) {
t.Fatalf("custom link lost: %#v", out[2])
}
}
func TestPruneSidebarLinksForScopes_noChangeWhenAllScoped(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
}
scopes := []users.BackendScope{
{Path: "."},
}
out, changed := PruneSidebarLinksForScopes(links, scopes)
if changed {
t.Fatal("expected changed=false")
}
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
}
}
-1
View File
@@ -26,7 +26,6 @@ func EnsureSidebarLinksFromScopes(links []users.SidebarLink, scopes []users.Back
continue
}
out = append(out, users.SidebarLink{
Name: source.Name,
Category: string(users.SidebarLinkSource),
Target: "/",
SourceName: source.Path,
+2 -2
View File
@@ -67,8 +67,8 @@ func TestEnsureSidebarLinksFromScopes_addsMissingScopedSources(t *testing.T) {
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3", len(out))
}
if out[2].Name != "playwright + files" {
t.Fatalf("added link = %#v", out[2])
if out[2].Name != "" {
t.Fatalf("added link should have empty default name, got %#v", out[2])
}
if out[1].Category != string(users.SidebarLinkCustom) {
t.Fatalf("custom link lost: %#v", out[1])
+1
View File
@@ -14,4 +14,5 @@ var (
JwtCache = cache.NewCache[string](1*time.Hour, 72*time.Hour)
ViewGrantsCache = cache.NewCache[ViewGrant](15 * time.Minute)
ViewGrantIndex = cache.NewCache[string](15 * time.Minute)
ShareAccessGrantsCache = cache.NewCache[ShareAccessGrant](24 * time.Hour)
)
+52
View File
@@ -1,12 +1,14 @@
package utils
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
)
@@ -56,6 +58,37 @@ func HashSHA256(data string) string {
return hex.EncodeToString(bytes[:])
}
// SignHMACSHA256Base64URL returns base64url(payload).base64url(hmac-sha256 signature).
func SignHMACSHA256Base64URL(key, payload []byte) string {
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payloadB64))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return payloadB64 + "." + signature
}
// VerifyHMACSHA256Base64URL validates token form and returns the decoded payload.
func VerifyHMACSHA256Base64URL(key []byte, token string) ([]byte, bool) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return nil, false
}
payloadB64, signature := parts[0], parts[1]
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payloadB64))
expectedSignature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
return nil, false
}
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return nil, false
}
return payload, true
}
// RandomUint64ID returns a non-zero cryptographically random uint64 (e.g. new user rows after migration).
// Small ids 1, 2, 3, … remain valid for migrated Bolt-era users.
func RandomUint64ID() (uint64, error) {
@@ -89,3 +122,22 @@ func CSPNonce() (string, error) {
}
return base64.StdEncoding.EncodeToString(b), nil
}
// CSPNonceFromData returns an existing cspNonce from map template data, or generates
// and stores one when data is a map[string]interface{}. For other data types it
// generates a fresh nonce without modifying data.
func CSPNonceFromData(data interface{}) (string, error) {
m, ok := data.(map[string]interface{})
if !ok {
return CSPNonce()
}
if nonce, ok := m["cspNonce"].(string); ok && nonce != "" {
return nonce, nil
}
nonce, err := CSPNonce()
if err != nil {
return "", fmt.Errorf("csp nonce: %w", err)
}
m["cspNonce"] = nonce
return nonce, nil
}
+5
View File
@@ -6,3 +6,8 @@ type ViewGrant struct {
Source string
ExpiresAt int64
}
// ShareAccessGrant tracks remaining uses for count-limited share download tokens.
type ShareAccessGrant struct {
RemainingUses int
}
+9 -2
View File
@@ -1,6 +1,7 @@
package utils
import (
"net/http"
"os"
"unicode/utf8"
)
@@ -27,7 +28,13 @@ func IsTextFile(realPath string) (bool, error) {
if len(content) > sampleSize {
sample = content[:sampleSize]
}
// Check 1: Count null bytes - binary files typically have many nulls
// Check 1: Reject PDFs. A PDF with a long ASCII text layer looks like text
// for far more than sampleSize bytes.
if http.DetectContentType(sample) == "application/pdf" {
return false, nil
}
// Check 2: Count null bytes - binary files typically have many nulls
nullCount := 0
for _, b := range sample {
if b == 0x00 {
@@ -42,7 +49,7 @@ func IsTextFile(realPath string) (bool, error) {
}
}
// Check 2: Validate UTF-8 encoding
// Check 3: Validate UTF-8 encoding
// Trim sample to last complete UTF-8 rune to avoid false negatives
trimmedSample := sample
for len(trimmedSample) > 0 {
+48
View File
@@ -0,0 +1,48 @@
package utils
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestIsTextFile(t *testing.T) {
t.Parallel()
asciiPageStream := strings.Repeat("BT /Ft0 1 Tf <00430044004500460047> Tj ET\n", 300)
tests := []struct {
name string
content []byte
want bool
}{
{name: "empty", content: nil, want: true},
{name: "plain text", content: []byte("hello\nworld\n"), want: true},
{name: "utf-8 text", content: []byte("café ☕\n"), want: true},
{name: "svg", content: []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>`), want: true},
{name: "binary with null bytes", content: append([]byte("GIF89a"), make([]byte, 64)...), want: false},
{
name: "pdf with an ascii text layer larger than the sample",
content: append([]byte("%PDF-1.3\n"+asciiPageStream), 0xFF, 0xD8, 0xFF, 0xE0),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "sample")
if err := os.WriteFile(path, tt.content, 0o600); err != nil {
t.Fatalf("writing sample: %v", err)
}
got, err := IsTextFile(path)
if err != nil {
t.Fatalf("IsTextFile: %v", err)
}
if got != tt.want {
t.Fatalf("IsTextFile(%s) = %v, want %v", tt.name, got, tt.want)
}
})
}
}
+98 -53
View File
@@ -1,13 +1,11 @@
package web
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
libError "errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
@@ -146,6 +144,7 @@ func getOrCreateAuthenticatedUser(username string, loginMethod users.LoginMethod
}
// Verify login method matches
if userValue.LoginMethod != loginMethod {
logger.Debugf("login rejected: user %q attempted %s login but account loginMethod is %q", username, loginMethod, userValue.LoginMethod)
return nil, errors.ErrWrongLoginMethod
}
// Sync IdP groups into access-control GroupMap (write-through). Skip when the
@@ -233,17 +232,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
}
// Clear the authentication cookie by setting it to expire in the past
host := sessionCookieDomain(r)
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: "",
Domain: host,
Path: "/",
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0), // Expire immediately
MaxAge: -1, // Delete cookie
}
http.SetCookie(w, cookie)
http.SetCookie(w, sessionCookie(r, "", time.Unix(0, 0), -1, http.SameSiteStrictMode))
logoutUrl := fmt.Sprintf("%vlogin", settings.Config.Http.BaseURL) // Default fallback
if d.User != nil && d.User.LoginMethod == users.LoginMethodProxy {
@@ -376,40 +365,23 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int,
return 0, nil
}
func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
// AuthenticateShareRequest validates access to a password-protected share.
// UI sessions are minted only after successful X-SHARE-PASSWORD auth, never from download tokens.
func AuthenticateShareRequest(w http.ResponseWriter, r *http.Request, l share.Share) (int, error) {
if l.PasswordHash == "" {
return 200, nil
return http.StatusOK, nil
}
if validateShareUISessionCookie(r, l.Hash) {
return http.StatusOK, nil
}
tokenParam := r.URL.Query().Get("token")
if tokenParam != "" {
// Verify the token signature if it's in the new signed format
if strings.Contains(tokenParam, ".") {
parts := strings.Split(tokenParam, ".")
if len(parts) == 2 {
payload := parts[0]
signature := parts[1]
// Verify HMAC signature
mac := hmac.New(sha256.New, []byte(settings.Config.Auth.Key))
mac.Write([]byte(payload))
expectedSignature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
// Use constant-time comparison to prevent timing attacks
if hmac.Equal([]byte(signature), []byte(expectedSignature)) {
// Token signature is valid, now check if it matches stored token
if tokenParam == l.Token {
return 200, nil
}
}
}
} else {
// Legacy token format (plain base64) - direct comparison
if tokenParam == l.Token {
return 200, nil
}
if shareRequestAllowsDownloadToken(r) && authorizeShareDownloadAccessToken(tokenParam, l.Hash) {
return http.StatusOK, nil
}
logger.Debugf("share auth failed: hash=%s reason=invalid_token", l.Hash)
logger.Debugf("share auth failed: hash=%s reason=invalid_or_disallowed_token", l.Hash)
}
password := r.Header.Get("X-SHARE-PASSWORD")
@@ -417,27 +389,100 @@ func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
logger.Debugf("share auth failed: hash=%s reason=missing_password", l.Hash)
return http.StatusUnauthorized, nil
}
password, err := url.QueryUnescape(password)
if err != nil {
return http.StatusUnauthorized, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
logger.Debugf("share auth failed: hash=%s reason=wrong_password", l.Hash)
return http.StatusUnauthorized, nil
}
return 401, err
return http.StatusUnauthorized, err
}
if w != nil {
if cookieErr := SetShareUISessionCookie(w, r, l.Hash); cookieErr != nil {
logger.Debugf("share session cookie: hash=%s err=%v", l.Hash, cookieErr)
}
}
return http.StatusOK, nil
}
const sessionCookieName = "filebrowser_quantum_jwt"
const shareUISessionCookieName = "filebrowser_share_session"
// validateShareUISessionCookie checks the HttpOnly share UI session cookie for a hash.
func validateShareUISessionCookie(r *http.Request, shareHash string) bool {
if r == nil || shareHash == "" {
return false
}
cookie, err := r.Cookie(shareUISessionCookieName)
if err != nil || cookie.Value == "" {
return false
}
return validateShareUISessionToken(cookie.Value, shareHash)
}
// SetShareUISessionCookie stores a short-lived session after successful X-SHARE-PASSWORD auth.
// Enables native browser streaming downloads without putting tokens in download URLs.
func SetShareUISessionCookie(w http.ResponseWriter, r *http.Request, shareHash string) error {
token, expiresAt, err := mintShareUISessionToken(shareHash, maxShareUISessionTTL)
if err != nil {
return err
}
expiresTime := time.Unix(expiresAt, 0)
http.SetCookie(w, shareUISessionCookie(r, token, expiresTime))
return nil
}
const maxShareUISessionTTL = 24 * time.Hour
// shareUISessionCookiePath scopes the share session cookie to the configured HTTP base URL path.
func shareUISessionCookiePath() string {
base := settings.Config.Http.BaseURL
if base == "" {
return "/"
}
return base
}
// shareUISessionCookie builds the HttpOnly share UI session cookie for a validated password entry.
func shareUISessionCookie(r *http.Request, token string, expiresTime time.Time) *http.Cookie {
maxAge := int(time.Until(expiresTime).Seconds())
if maxAge < 0 {
maxAge = 0
}
return &http.Cookie{
Name: shareUISessionCookieName,
Value: token,
Path: shareUISessionCookiePath(),
SameSite: http.SameSiteLaxMode,
HttpOnly: true,
Secure: requestScheme(r) == "https",
Expires: expiresTime,
MaxAge: maxAge,
}
}
// sessionCookie builds the JWT cookie. HttpOnly prevents srcdoc XSS from reading
// document.cookie; Secure is set when the request is HTTPS (including via proxy).
func sessionCookie(r *http.Request, token string, expiresTime time.Time, maxAge int, sameSite http.SameSite) *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
Value: token,
Domain: sessionCookieDomain(r),
Path: "/",
SameSite: sameSite,
HttpOnly: true,
Secure: requestScheme(r) == "https",
Expires: expiresTime,
MaxAge: maxAge,
}
return 200, nil
}
// SetSessionCookie sets the authentication token as an HTTP cookie.
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: token,
Domain: sessionCookieDomain(r),
Path: "/",
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
Expires: expiresTime,
}
http.SetCookie(w, cookie)
http.SetCookie(w, sessionCookie(r, token, expiresTime, 0, http.SameSiteStrictMode))
}
// applyNamedApiTokenGlobalCaps intersects owner globals with JWT global caps for named custom API tokens.
+16
View File
@@ -0,0 +1,16 @@
package web
import (
libError "errors"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
)
// loginMethodHTTPStatus maps a login error to an HTTP status and client-safe message.
func loginMethodHTTPStatus(err error) (int, error) {
if libError.Is(err, errors.ErrWrongLoginMethod) {
return http.StatusUnauthorized, errors.ErrInvalidLoginMethod
}
return 0, err
}
@@ -0,0 +1,121 @@
package web
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
fberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestGetOrCreateAuthenticatedUserRejectsLoginMethodMismatch(t *testing.T) {
setupTestEnv(t)
passwordUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodPassword,
},
}
if err := state.CreateUser(passwordUser, "secret"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
_, err := getOrCreateAuthenticatedUser("graham", users.LoginMethodOidc, false, nil)
if !errors.Is(err, fberrors.ErrWrongLoginMethod) {
t.Fatalf("getOrCreateAuthenticatedUser() err = %v, want ErrWrongLoginMethod", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
if loaded.LoginMethod != users.LoginMethodPassword {
t.Fatalf("loginMethod changed to %q, want password", loaded.LoginMethod)
}
}
func TestGetOrCreateAuthenticatedUserRejectsPasswordUserForOIDCAccount(t *testing.T) {
setupTestEnv(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
_, err := getOrCreateAuthenticatedUser("graham", users.LoginMethodOidc, false, nil)
if err != nil {
t.Fatalf("getOrCreateAuthenticatedUser() for matching oidc user: %v", err)
}
}
func TestAuthenticatePasswordRejectsWrongLoginMethod(t *testing.T) {
setupTestEnv(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, "secret"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=graham", nil)
req.Header.Set("X-Password", url.QueryEscape("secret"))
_, err := auth.AuthenticatePassword(req, true)
if !errors.Is(err, fberrors.ErrWrongLoginMethod) {
t.Fatalf("AuthenticatePassword() err = %v, want ErrWrongLoginMethod", err)
}
}
func TestLoginHelperReturnsInvalidLoginMethodForPasswordMismatch(t *testing.T) {
setupTestEnv(t)
app.MustWireServices(state.Default())
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, "secret"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
origPassword := settings.Config.Auth.Methods.PasswordAuth.Enabled
settings.Config.Auth.Methods.PasswordAuth.Enabled = true
t.Cleanup(func() {
settings.Config.Auth.Methods.PasswordAuth.Enabled = origPassword
})
handler := LoginHelper(true, func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
return http.StatusOK, nil
})
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=graham", nil)
req.Header.Set("X-Password", url.QueryEscape("secret"))
rec := httptest.NewRecorder()
status, err := handler(rec, req, &requestContext{})
if status != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", status)
}
if !errors.Is(err, fberrors.ErrInvalidLoginMethod) {
t.Fatalf("err = %v, want ErrInvalidLoginMethod", err)
}
}
+3
View File
@@ -18,6 +18,7 @@ import (
libErrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-cache/cache"
@@ -139,6 +140,8 @@ func ErrToStatus(err error) int {
return http.StatusBadRequest
case errors.Is(err, libErrors.ErrIsDirectory):
return http.StatusMethodNotAllowed
case errors.Is(err, preview.ErrUnsupportedFormat):
return http.StatusUnsupportedMediaType
default:
return http.StatusInternalServerError
}
+24
View File
@@ -0,0 +1,24 @@
package web
import (
"errors"
"net/http"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
)
func TestErrToStatusUnsupportedPreviewFormat(t *testing.T) {
status := ErrToStatus(preview.ErrUnsupportedFormat)
if status != http.StatusUnsupportedMediaType {
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
}
}
func TestErrToStatusWrapsUnsupportedPreviewFormat(t *testing.T) {
err := errors.Join(errors.New("preview failed"), preview.ErrUnsupportedFormat)
status := ErrToStatus(err)
if status != http.StatusUnsupportedMediaType {
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ func authenticateLDAP(username, password string) ([]string, map[string]string, e
logger.Debugf("ldap: no service account bind (userPassword empty)")
}
groupAttr := settings.ResolveGroupsClaim(c.GroupsClaim)
groupAttr := settings.ResolveLdapGroupsClaim(c.GroupsClaim)
attributes := []string{"dn", groupAttr}
if c.UserIdentifier != "" {
attributes = append(attributes, c.UserIdentifier)
+10 -2
View File
@@ -83,7 +83,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
// Authenticate the share request if needed
var status int
if link.Hash != "" {
status, err = AuthenticateShareRequest(r, link)
status, err = AuthenticateShareRequest(w, r, link)
if err != nil || status != http.StatusOK {
return status, fmt.Errorf("could not authenticate share request")
}
@@ -149,7 +149,6 @@ func withHashFileHelper(fn handleFunc) handleFunc {
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")
}
file.Token = link.Token
file.Source = link.Hash
file.Hash = link.Hash
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
@@ -387,6 +386,9 @@ func LoginHelper(disableOtp bool, fn handleFunc) handleFunc {
if err == errors.ErrNoTotpProvided {
return 403, err
}
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return 401, errors.ErrUnauthorized
}
d.User = user
@@ -504,6 +506,9 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
// Setup user based on JWT claims
user, err := SetupJwtUser(r, data, username, claims)
if err != nil {
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return http.StatusForbidden, err
}
data.User = user
@@ -535,6 +540,9 @@ func getProxyUser(w http.ResponseWriter, r *http.Request, data *requestContext,
// proxy user logic
user, err := SetupProxyUser(r, data, proxyUser)
if err != nil {
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return http.StatusForbidden, err
}
data.User = user
+31 -14
View File
@@ -322,6 +322,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
setupTestEnv(t)
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" // bcrypt hashed password
const accentedPasswordBcrypt = "$2b$04$JaVUkztbmWKgV8GsSvRorettpNHrc0BvYV3/oARpepfoanKbqt5zi"
// Create and save a dummy user (shares reference owner by UserID)
dummyUser := &users.User{
@@ -363,7 +364,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
},
{
name: "Private share, valid password when token exists",
name: "Private share, valid password",
share: &share.Share{
ShareSettings: share.ShareSettings{
ShareLimits: share.ShareLimits{SourceName: "srv"},
@@ -372,7 +373,6 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
SourcePath: "/srv",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "some_random_token",
},
extraHeaders: map[string]string{
"X-SHARE-PASSWORD": "password",
@@ -389,12 +389,11 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
SourcePath: "/srv",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
},
expectedStatusCode: http.StatusUnauthorized,
},
{
name: "Private share, valid token",
name: "Private share, download token rejected on listing route",
share: &share.Share{
ShareSettings: share.ShareSettings{
ShareLimits: share.ShareLimits{SourceName: "srv"},
@@ -403,10 +402,9 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
SourcePath: "/srv",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
},
token: "123",
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
token: "download-token-placeholder",
expectedStatusCode: http.StatusUnauthorized,
},
{
name: "Private share, invalid password",
@@ -418,30 +416,49 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
SourcePath: "/srv",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
},
extraHeaders: map[string]string{
"X-SHARE-PASSWORD": "wrong-password",
},
expectedStatusCode: http.StatusUnauthorized,
},
{
name: "Private share, valid encoded non-ASCII password",
share: &share.Share{
ShareSettings: share.ShareSettings{
ShareLimits: share.ShareLimits{SourceName: "srv"},
},
ShareColumns: share.ShareColumns{Hash: "accented_password_hash", Path: "/"},
SourcePath: "/srv",
UserID: 1,
PasswordHash: accentedPasswordBcrypt,
},
extraHeaders: map[string]string{
"X-SHARE-PASSWORD": "pass%C3%A9",
},
expectedStatusCode: http.StatusOK,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Save the share in the state
if err := state.CreateShare(tc.share); err != nil {
t.Fatal("failed to save share:", err)
}
// Create a response recorder to capture handler output
recorder := httptest.NewRecorder()
// Wrap the handler with authentication middleware
handler := withHashFile(publicGetResourceHandler)
// Prepare the request with query parameters and optional headers
req := newTestRequest(t, tc.share.Hash, tc.token, tc.password, tc.extraHeaders)
token := tc.token
if tc.name == "Private share, download token rejected on listing route" {
minted, _, err := mintShareDownloadAccessToken(tc.share.Hash, time.Hour, 0)
if err != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", err)
}
token = minted
}
req := newTestRequest(t, tc.share.Hash, token, tc.password, tc.extraHeaders)
// Serve the request
handler(recorder, req)
+27 -16
View File
@@ -239,9 +239,10 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
rawIDToken, ok := token.Extra("id_token").(string)
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
var userdata userInfo // Declare userdata here to be populated by either source
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
loginUsername := "" // Variable to hold the login username
var userdata userInfo // Declare userdata here to be populated by either source
var idTokenClaims map[string]any // Verified ID-token claims preserved across UserInfo fallback
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
loginUsername := "" // Variable to hold the login username
// Create custom unmarshaller for userInfo
userInfoUnmarshaller := &userInfoUnmarshaller{
@@ -267,6 +268,9 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
} else {
// Successfully verified and decoded ID token claims
logger.Debugf("ID Token verified and claims decoded: %+v", userdata)
if len(userdata.Claims) > 0 {
idTokenClaims = maps.Clone(userdata.Claims)
}
// Decide if we rely on ID token claims or still need UserInfo
// Even if parsing succeeded, if essential claims are missing, use UserInfo
@@ -301,6 +305,9 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
logger.Errorf("failed to decode user info from endpoint: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to decode user info from endpoint: %v", err)
}
if len(idTokenClaims) > 0 {
mergeMissingOidcClaims(&userdata, idTokenClaims)
}
}
// --- Determine login username dynamically ---
@@ -319,6 +326,18 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
return loginWithOidcUser(w, r, loginUsername, userdata.Groups)
}
// mergeMissingOidcClaims copies claims from from into userdata when absent after UserInfo fallback.
func mergeMissingOidcClaims(userdata *userInfo, from map[string]any) {
if userdata.Claims == nil {
userdata.Claims = make(map[string]any)
}
for k, v := range from {
if _, exists := userdata.Claims[k]; !exists {
userdata.Claims[k] = v
}
}
}
// loginWithOidcUser extracts the username from the user claims (userInfo)
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
@@ -357,6 +376,9 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
user, err := getOrCreateAuthenticatedUser(username, users.LoginMethodOidc, isAdmin, groups)
if err != nil {
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return http.StatusUnauthorized, err
}
@@ -375,19 +397,8 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
// This allows backend to identify expired sessions and provide better user feedback
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
// Set the authentication token as an HTTP cookie
host := requestHost(r)
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
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
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)
// Set the authentication token as an HTTP cookie (Lax for OIDC redirect return).
http.SetCookie(w, sessionCookie(r, tokenString, expiresTime, 0, http.SameSiteLaxMode))
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
+36
View File
@@ -206,6 +206,42 @@ func TestUserInfoUnmarshaller_InvalidJSON(t *testing.T) {
}
}
func TestMergeMissingOidcClaims_PreservesIDTokenIdentifier(t *testing.T) {
userdata := userInfo{
Claims: map[string]any{"groups": []any{"admins"}},
Groups: []string{"admins"},
}
idTokenClaims := map[string]any{
"preferred_username": "alice",
"sub": "user-123",
}
mergeMissingOidcClaims(&userdata, idTokenClaims)
if userdata.Claims["preferred_username"] != "alice" {
t.Fatalf("preferred_username = %v, want alice", userdata.Claims["preferred_username"])
}
if userdata.Claims["sub"] != "user-123" {
t.Fatalf("sub = %v, want user-123", userdata.Claims["sub"])
}
if _, ok := userdata.Claims["groups"]; !ok {
t.Fatal("UserInfo groups claim should be preserved")
}
}
func TestMergeMissingOidcClaims_UserInfoWinsOnConflict(t *testing.T) {
userdata := userInfo{
Claims: map[string]any{"email": "from-userinfo@example.com"},
}
idTokenClaims := map[string]any{"email": "from-idtoken@example.com"}
mergeMissingOidcClaims(&userdata, idTokenClaims)
if userdata.Claims["email"] != "from-userinfo@example.com" {
t.Fatalf("email = %v, want UserInfo value preserved", userdata.Claims["email"])
}
}
func TestOidcRedirectURLTrustProxyHeaders(t *testing.T) {
settings.Config.Http.BaseURL = "/"
settings.Config.Http.TrustProxyHeaders = true
+8 -4
View File
@@ -298,13 +298,17 @@ func joinOnlyOfficeAPIURL(baseURL, apiPath string) string {
return base + "/" + path
}
// shareTokenForOnlyOffice returns the share download token for OnlyOffice server URLs.
// Only password-protected shares have a token; callers must already have passed share auth.
// shareTokenForOnlyOffice mints a short-lived download token for OnlyOffice server URLs.
func shareTokenForOnlyOffice(d *Context) string {
if d.Share.Hash == "" || d.Share.PasswordHash == "" || d.Share.Token == "" {
if d.Share.Hash == "" || d.Share.PasswordHash == "" {
return ""
}
return d.Share.Token
token, _, err := mintShareDownloadAccessToken(d.Share.Hash, 2*time.Hour, 0)
if err != nil {
logger.Errorf("failed to mint OnlyOffice share download token: hash=%s error=%v", d.Share.Hash, err)
return ""
}
return token
}
// buildOnlyOfficeViewURL constructs the view URL that OnlyOffice server uses to fetch the document.
+2 -2
View File
@@ -83,7 +83,7 @@ func requestPageFullURL(r *http.Request) string {
}
// ShareURLFromRequest builds a public share or direct-download URL from the request and server config.
func ShareURLFromRequest(r *http.Request, hash string, isDirectDownload bool, token string) string {
func ShareURLFromRequest(r *http.Request, hash string, isDirectDownload bool, hasPassword bool) string {
host, scheme := shareURLParams(r)
return share.PublicShareURL(host, scheme, hash, isDirectDownload, token)
return share.PublicShareURL(host, scheme, hash, isDirectDownload, hasPassword)
}
+6
View File
@@ -803,6 +803,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
if acquireErr := activeUploadSessions.acquire(realPath, sessionID); acquireErr != nil {
if isUploadSessionConflict(acquireErr) {
logger.Debugf("%v", acquireErr)
drainRequestBody(r)
return http.StatusConflict, acquireErr
}
logger.Debugf("%v", acquireErr)
@@ -819,6 +820,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
// If type mismatch (existing dir vs requesting file) and not overriding
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
logger.Debugf("Type conflict detected in chunked: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
drainRequestBody(r)
return http.StatusConflict, nil
}
}
@@ -828,6 +830,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
drainRequestBody(r)
return http.StatusConflict, nil
}
// If overriding, delete existing thumbnails
@@ -935,6 +938,9 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
activity.RecordUpload(r, toActor(d), source, path, false)
activeUploadSessions.release(realPath, sessionID)
}
if err = writeChunkUploadResponse(w, assembled, totalSize, assembled == totalSize); err != nil {
logger.Debugf("could not write chunk upload response: %v", err)
}
return http.StatusOK, nil
}
@@ -0,0 +1,34 @@
package web
import (
"net/http"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
)
func TestCheckPermissionsMissingParamsStatus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
opts utils.FileOptions
}{
{name: "missing path", opts: utils.FileOptions{Source: "default"}},
{name: "missing source", opts: utils.FileOptions{Path: "/"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := files.CheckPermissions(tt.opts, &users.User{})
if err == nil {
t.Fatal("expected an error")
}
if status := ErrToStatus(err); status != http.StatusBadRequest {
t.Fatalf("status=%d err=%v, want %d", status, err, http.StatusBadRequest)
}
})
}
}
@@ -2,6 +2,7 @@ package web
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
@@ -560,3 +561,51 @@ func TestResourcePostHandler_ExpiredSessionAllowsNewUpload(t *testing.T) {
t.Fatalf("got %q want %q", got, body)
}
}
func TestResourcePostHandler_PartialChunkResponse(t *testing.T) {
_, user := setupUploadHTTPTest(t)
part1 := []byte("aaaa")
total := 8
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/resources?source=uploads&path=/partial.bin", bytes.NewReader(part1))
req.ContentLength = int64(len(part1))
req.Header.Set("X-File-Upload-Session", "session-partial")
req.Header.Set("X-File-Chunk-Offset", "0")
req.Header.Set("X-File-Total-Size", strconv.Itoa(total))
status, err := ResourcePostHandler(rec, req, &requestContext{User: user})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk1 status=%d err=%v", status, err)
}
if rec.Header().Get("Connection") != "close" {
t.Fatalf("expected Connection: close, got %q", rec.Header().Get("Connection"))
}
var payload chunkUploadResponse
if decodeErr := json.NewDecoder(rec.Body).Decode(&payload); decodeErr != nil {
t.Fatalf("decode response: %v", decodeErr)
}
if payload.Complete || payload.Offset != int64(len(part1)) || payload.Total != int64(total) {
t.Fatalf("unexpected payload: %+v", payload)
}
part2 := []byte("bbbb")
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/api/resources?source=uploads&path=/partial.bin", bytes.NewReader(part2))
req2.ContentLength = int64(len(part2))
req2.Header.Set("X-File-Upload-Session", "session-partial")
req2.Header.Set("X-File-Chunk-Offset", strconv.Itoa(len(part1)))
req2.Header.Set("X-File-Total-Size", strconv.Itoa(total))
status, err = ResourcePostHandler(rec2, req2, &requestContext{User: user})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk2 status=%d err=%v", status, err)
}
var finalPayload chunkUploadResponse
if decodeErr := json.NewDecoder(rec2.Body).Decode(&finalPayload); decodeErr != nil {
t.Fatalf("decode final response: %v", decodeErr)
}
if !finalPayload.Complete || finalPayload.Offset != int64(total) {
t.Fatalf("unexpected final payload: %+v", finalPayload)
}
}
+122
View File
@@ -0,0 +1,122 @@
package web
import (
"net/http"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSessionCookie_HttpOnlyAndSecureOnHTTPS(t *testing.T) {
prev := settings.Config.Http.TrustProxyHeaders
settings.Config.Http.TrustProxyHeaders = true
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = prev })
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Forwarded-Proto", "https")
req.Host = "example.com"
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
if !c.HttpOnly {
t.Fatal("expected HttpOnly session cookie")
}
if !c.Secure {
t.Fatal("expected Secure session cookie over HTTPS")
}
if c.Name != sessionCookieName {
t.Fatalf("cookie name: got %q", c.Name)
}
}
func TestSessionCookie_NotSecureOnHTTP(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "http://localhost/", nil)
if err != nil {
t.Fatal(err)
}
req.Host = "localhost"
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
if !c.HttpOnly {
t.Fatal("expected HttpOnly session cookie")
}
if c.Secure {
t.Fatal("did not expect Secure cookie on plain HTTP")
}
}
func TestSpaContentSecurityPolicy_BlocksUntrustedScripts(t *testing.T) {
prev := settings.Config.Integrations.OnlyOffice
settings.Config.Integrations.OnlyOffice = settings.OnlyOffice{}
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
csp := spaContentSecurityPolicy("testnonce")
if csp != "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com" {
t.Fatalf("unexpected CSP: %s", csp)
}
if strings.Contains(csp, "'unsafe-inline'") {
t.Fatal("script-src must not allow unsafe-inline (srcdoc XSS)")
}
if strings.Contains(csp, "default-src") {
t.Fatal("CSP should only restrict script-src to avoid breaking frames, blobs, and external assets")
}
}
func TestSpaContentSecurityPolicy_IncludesOnlyOfficeOrigins(t *testing.T) {
prev := settings.Config.Integrations.OnlyOffice
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
tests := []struct {
name string
oo settings.OnlyOffice
want string
}{
{
name: "url only",
oo: settings.OnlyOffice{Url: "http://localhost:9052"},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
},
{
name: "internal url only",
oo: settings.OnlyOffice{InternalUrl: "http://onlyoffice-internal:80"},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://onlyoffice-internal:80",
},
{
name: "both urls",
oo: settings.OnlyOffice{
Url: "https://office.example.com",
InternalUrl: "http://onlyoffice-internal",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com https://office.example.com http://onlyoffice-internal",
},
{
name: "duplicate origins",
oo: settings.OnlyOffice{
Url: "http://localhost:9052",
InternalUrl: "http://localhost:9052",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
},
{
name: "invalid urls ignored",
oo: settings.OnlyOffice{
Url: "not-a-url",
InternalUrl: "://bad",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings.Config.Integrations.OnlyOffice = tt.oo
if got := spaContentSecurityPolicy("testnonce"); got != tt.want {
t.Fatalf("unexpected CSP:\ngot: %s\nwant: %s", got, tt.want)
}
})
}
}
+84 -168
View File
@@ -1,9 +1,7 @@
package web
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
@@ -260,19 +258,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return status, err2
}
stringHash := ""
var token string
if len(hash) > 0 {
payloadBuffer := make([]byte, 24)
if _, err = rand.Read(payloadBuffer); err != nil {
return http.StatusInternalServerError, err
}
payload := base64.URLEncoding.EncodeToString(payloadBuffer)
mac := hmac.New(sha256.New, []byte(settings.Config.Auth.Key))
mac.Write([]byte(payload))
signature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
token = payload + "." + signature
stringHash = string(hash)
}
if req.Hash != "" {
@@ -295,7 +281,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
shouldResetCounts := link.DownloadsLimit != req.DownloadsLimit ||
link.PerUserDownloadLimit != req.PerUserDownloadLimit
if err = applySharePasswordUpdate(link, req.Password, stringHash, token); err != nil {
if err = applySharePasswordUpdate(link, req.Password, stringHash); err != nil {
return err
}
@@ -392,7 +378,6 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
SourcePath: source.Path,
UserID: d.User.ID,
PasswordHash: stringHash,
Token: token,
Version: 1,
}
s.DownloadURL = ""
@@ -421,174 +406,107 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return RenderJSON(w, r, prepared)
}
// DirectDownloadResponse represents the response for direct download endpoint
// DirectDownloadResponse represents the response for direct download endpoint.
type DirectDownloadResponse struct {
Status string `json:"status"`
Hash string `json:"hash"`
DownloadURL string `json:"url"`
ShareURL string `json:"shareUrl"`
Hash string `json:"hash"`
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
URL string `json:"url"`
}
// shareDirectDownloadHandler creates a direct download link for files only.
// @Summary Create direct download link
// @Description Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.
// parseShareDownloadTokenParams reads duration, unit, and optional use-count from the direct-download API query.
func parseShareDownloadTokenParams(r *http.Request) (time.Duration, int, error) {
durationStr := r.URL.Query().Get("duration")
if durationStr == "" {
durationStr = "60"
}
durationNum, err := strconv.Atoi(durationStr)
if err != nil {
return 0, 0, fmt.Errorf("invalid duration: %v", err)
}
unit := r.URL.Query().Get("unit")
if unit == "" {
unit = "minutes"
}
ttl, err := parseShareDownloadAccessDuration(durationNum, unit)
if err != nil {
return 0, 0, err
}
maxUses := 0
if countStr := r.URL.Query().Get("count"); countStr != "" {
maxUses, err = strconv.Atoi(countStr)
if err != nil || maxUses <= 0 {
return 0, 0, fmt.Errorf("invalid count: %v", err)
}
}
return ttl, maxUses, nil
}
// mintShareDownloadTokenResponse builds the JSON payload for GET /api/share/direct.
func mintShareDownloadTokenResponse(r *http.Request, hash string, ttl time.Duration, maxUses int) (DirectDownloadResponse, error) {
token, expiresAt, err := mintShareDownloadAccessToken(hash, ttl, maxUses)
if err != nil {
return DirectDownloadResponse{}, err
}
host, scheme := shareURLParams(r)
return DirectDownloadResponse{
Hash: hash,
Token: token,
ExpiresAt: expiresAt,
URL: directDownloadURL(host, scheme, hash, token),
}, nil
}
// shareDirectDownloadHandler mints a download-only ephemeral token for a password-protected share.
// @Summary Get direct download link
// @Description Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.
// @Tags Shares
// @Accept json
// @Produce json
// @Param path query string true "File path to create download link for"
// @Param source query string true "Source name for the file"
// @Param duration query string false "Duration in minutes for link validity (default: 60)"
// @Param count query string false "Maximum number of downloads allowed (default: unlimited)"
// @Param speed query string false "Download speed limit in kbps (default: unlimited)"
// @Success 201 {object} DirectDownloadResponse "Direct download link created"
// @Failure 400 {object} map[string]string "Bad request - invalid parameters or path is not a file"
// @Failure 403 {object} map[string]string "Forbidden - access denied"
// @Failure 500 {object} map[string]string "Internal server error"
// @Param hash query string true "Share hash"
// @Param duration query string false "Duration value (default: 60)"
// @Param unit query string false "Duration unit: minutes or hours (default: minutes)"
// @Param count query string false "Maximum number of downloads allowed with this token (default: unlimited within TTL)"
// @Param X-SHARE-PASSWORD header string true "Share password"
// @Success 200 {object} DirectDownloadResponse "Ephemeral download link"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 401 {object} map[string]string "Invalid or missing share password"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Share not found"
// @Router /api/share/direct [get]
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
// Extract query parameters
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
duration := r.URL.Query().Get("duration")
downloadCountStr := r.URL.Query().Get("count")
downloadSpeedStr := r.URL.Query().Get("speed")
// Validate required parameters
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
hash := r.URL.Query().Get("hash")
if hash == "" {
return http.StatusBadRequest, fmt.Errorf("hash is required")
}
cleanPath, err := utils.SanitizePath(path)
link, err := state.GetShare(hash)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %w", err)
return http.StatusNotFound, fmt.Errorf("share not found")
}
path = cleanPath
// Validate source exists
sourceInfo, ok := settings.Config.Server.NameToSource[source]
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
if !link.HasPassword() {
return http.StatusBadRequest, fmt.Errorf("share is not password protected")
}
if !link.UserCanEdit(d.User) {
return http.StatusForbidden, fmt.Errorf("you are not allowed to mint tokens for this share")
}
// Get user scope for this source
userscope, err := d.User.GetScopeForSourceName(source)
status, err := AuthenticateShareRequest(w, r, link)
if err != nil || status != http.StatusOK {
return http.StatusUnauthorized, fmt.Errorf("invalid share password")
}
ttl, maxUses, err := parseShareDownloadTokenParams(r)
if err != nil {
return http.StatusForbidden, err
return http.StatusBadRequest, err
}
// Validate the path exists and is a file (not a folder)
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", source)
}
metadata, exists := idx.GetReducedMetadata(path, false)
if !exists {
return http.StatusBadRequest, fmt.Errorf("path is either not a file or not found: %s", path)
}
// Check if it's a file (not a directory)
if metadata.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("path must be a file, not a directory: %s", path)
}
// Set default duration to 60 minutes if not provided
if duration == "" {
duration = "60"
}
// Parse download count
var downloadCount int
if downloadCountStr != "" {
downloadCount, err = strconv.Atoi(downloadCountStr)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid downloadCount: %v", err)
}
}
// Parse download speed (in bytes per second)
var downloadSpeed int
if downloadSpeedStr != "" {
downloadSpeed, err = strconv.Atoi(downloadSpeedStr)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid downloadSpeed: %v", err)
}
}
// Calculate expiration time
durationNum, err := strconv.Atoi(duration)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid duration: %v", err)
}
expire := time.Now().Add(time.Minute * time.Duration(durationNum)).Unix()
// Generate secure hash for the share
secureHash, err := generateShortUUID()
response, err := mintShareDownloadTokenResponse(r, hash, ttl, maxUses)
if err != nil {
return http.StatusInternalServerError, err
}
// Create the scope path (file: no trailing slash; matches share cache normalization)
scopePath := utils.JoinPathAsUnix(userscope, path)
// Check if an existing share already matches these parameters
existingShares, err := state.GetSharesInScope(scopePath, sourceInfo.Path, d.User.ID)
if err == nil && len(existingShares) > 0 {
for _, existing := range existingShares {
if existing.DownloadsLimit == downloadCount &&
existing.MaxBandwidth == downloadSpeed &&
existing.QuickDownload &&
(existing.Expire == 0 || existing.Expire >= expire) { // Existing expires later or never
response := DirectDownloadResponse{
Status: "201",
Hash: existing.Hash,
DownloadURL: ShareURLFromRequest(r, existing.Hash, true, existing.Token),
ShareURL: ShareURLFromRequest(r, existing.Hash, false, existing.Token),
}
return RenderJSON(w, r, response)
}
}
}
// No matching existing share found, create a new one
shareLink := &share.Share{
ShareSettings: share.ShareSettings{
FrontendShareInfo: share.FrontendShareInfo{
QuickDownload: true,
},
ShareLimits: share.ShareLimits{
MaxBandwidth: downloadSpeed,
DownloadsLimit: downloadCount,
SourceName: sourceInfo.Name,
},
},
ShareColumns: share.ShareColumns{
Hash: secureHash,
Path: scopePath,
Expire: expire,
},
SourcePath: idx.Path,
UserID: d.User.ID,
Version: 1,
}
if err = state.CreateShare(shareLink); err != nil {
return http.StatusInternalServerError, err
}
snap, err := state.GetShare(secureHash)
if err != nil {
return http.StatusInternalServerError, err
}
response := DirectDownloadResponse{
Status: "200",
Hash: secureHash,
DownloadURL: ShareURLFromRequest(r, secureHash, true, snap.Token),
ShareURL: ShareURLFromRequest(r, secureHash, false, snap.Token),
}
activity.RecordShareMutation(r, toActor(d), activitydb.EventShareCreate, secureHash, snap.SourceName, snap.Path, nil)
return RenderJSON(w, r, response)
}
@@ -677,7 +595,7 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
frontendShareInfo := shareInfo.FrontendShareInfo
frontendShareInfo.ShareURL = ShareURLFromRequest(r, hash, false, "")
frontendShareInfo.ShareURL = ShareURLFromRequest(r, hash, false, shareInfo.HasPassword())
frontendShareInfo.BannerUrl = shareInfo.BannerURL()
frontendShareInfo.FaviconUrl = shareInfo.FaviconURL()
filtered := make([]users.SidebarLink, 0, len(frontendShareInfo.SidebarLinks))
@@ -822,17 +740,15 @@ func sharePasswordFromRequest(password *string) ([]byte, int, error) {
// applySharePasswordUpdate sets or preserves password credentials on share update.
// Nil password omits the field (keep existing); empty string clears; non-empty replaces.
func applySharePasswordUpdate(link *share.Share, password *string, hashedPassword, token string) error {
func applySharePasswordUpdate(link *share.Share, password *string, hashedPassword string) error {
if password == nil {
return nil
}
if *password == "" {
link.PasswordHash = ""
link.Token = ""
return nil
}
link.PasswordHash = hashedPassword
link.Token = token
return nil
}
+239
View File
@@ -0,0 +1,239 @@
package web
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
const (
shareAccessScopeDownload = "download"
shareAccessScopeUISession = "ui"
maxShareAccessTTL = 24 * time.Hour
)
// shareDownloadGrantMu serializes use-count decrements for limited download tokens.
var shareDownloadGrantMu sync.Mutex
type shareAccessClaims struct {
Hash string `json:"h"`
Exp int64 `json:"exp"`
Scope string `json:"s"`
ID string `json:"id,omitempty"`
}
// parseShareAccessDuration converts API duration parameters into a time.Duration.
func parseShareAccessDuration(duration int, unit string) (time.Duration, error) {
if duration <= 0 {
return 0, fmt.Errorf("duration must be positive")
}
switch unit {
case "minutes", "minute", "":
return time.Duration(duration) * time.Minute, nil
case "hours", "hour":
return time.Duration(duration) * time.Hour, nil
default:
return 0, fmt.Errorf("invalid unit: %s", unit)
}
}
// parseShareDownloadAccessDuration validates share direct-download TTL capped at 24 hours.
func parseShareDownloadAccessDuration(duration int, unit string) (time.Duration, error) {
ttl, err := parseShareAccessDuration(duration, unit)
if err != nil {
return 0, err
}
if ttl > maxShareAccessTTL {
return 0, fmt.Errorf("duration exceeds maximum of 24 hours")
}
return ttl, nil
}
// shareAccessAuthKey returns the HMAC signing key for share access tokens.
func shareAccessAuthKey() []byte {
return []byte(settings.Config.Auth.Key)
}
// mintSignedShareAccessToken signs share access claims as a base64url.payload.signature token.
func mintSignedShareAccessToken(claims shareAccessClaims) (token string, expiresAt int64, err error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", 0, err
}
return utils.SignHMACSHA256Base64URL(shareAccessAuthKey(), payload), claims.Exp, nil
}
// verifySignedShareAccessToken parses and verifies a signed share access token payload.
func verifySignedShareAccessToken(tokenParam string) (*shareAccessClaims, bool) {
payload, ok := utils.VerifyHMACSHA256Base64URL(shareAccessAuthKey(), tokenParam)
if !ok {
return nil, false
}
var claims shareAccessClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, false
}
return &claims, true
}
// mintShareDownloadAccessToken creates a signed, download-scoped ephemeral token for a share.
func mintShareDownloadAccessToken(shareHash string, ttl time.Duration, maxUses int) (token string, expiresAt int64, err error) {
if shareHash == "" {
return "", 0, fmt.Errorf("share hash is required")
}
if ttl <= 0 || ttl > maxShareAccessTTL {
return "", 0, fmt.Errorf("invalid token duration")
}
if maxUses < 0 {
return "", 0, fmt.Errorf("invalid max uses")
}
expiresAt = time.Now().Add(ttl).Unix()
claims := shareAccessClaims{
Hash: shareHash,
Exp: expiresAt,
Scope: shareAccessScopeDownload,
}
remainingUses := -1
if maxUses > 0 {
idBytes := make([]byte, 12)
if _, err = rand.Read(idBytes); err != nil {
return "", 0, err
}
claims.ID = base64.RawURLEncoding.EncodeToString(idBytes)
remainingUses = maxUses
}
token, expiresAt, err = mintSignedShareAccessToken(claims)
if err != nil {
return "", 0, err
}
if claims.ID != "" {
utils.ShareAccessGrantsCache.Set(claims.ID, utils.ShareAccessGrant{RemainingUses: remainingUses})
}
return token, expiresAt, nil
}
// mintShareUISessionToken creates a signed cookie-scoped token for UI share access after password entry.
func mintShareUISessionToken(shareHash string, ttl time.Duration) (token string, expiresAt int64, err error) {
if shareHash == "" {
return "", 0, fmt.Errorf("share hash is required")
}
if ttl <= 0 || ttl > maxShareAccessTTL {
return "", 0, fmt.Errorf("invalid token duration")
}
expiresAt = time.Now().Add(ttl).Unix()
return mintSignedShareAccessToken(shareAccessClaims{
Hash: shareHash,
Exp: expiresAt,
Scope: shareAccessScopeUISession,
})
}
// validateShareUISessionToken checks a UI session token for the given share hash.
func validateShareUISessionToken(tokenParam, shareHash string) bool {
claims, ok := verifySignedShareAccessToken(tokenParam)
if !ok || claims == nil {
return false
}
if claims.Scope != shareAccessScopeUISession || claims.Hash != shareHash {
return false
}
return time.Now().Unix() <= claims.Exp
}
// validateShareDownloadAccessToken checks a download token without consuming a limited use.
func validateShareDownloadAccessToken(tokenParam, shareHash string) bool {
claims, ok := verifySignedShareAccessToken(tokenParam)
if !ok || claims == nil {
return false
}
if claims.Scope != shareAccessScopeDownload || claims.Hash != shareHash {
return false
}
if time.Now().Unix() > claims.Exp {
return false
}
if claims.ID == "" {
return true
}
grant, ok := utils.ShareAccessGrantsCache.Get(claims.ID)
if !ok || grant.RemainingUses <= 0 {
return false
}
return true
}
// authorizeShareDownloadAccessToken validates a download token and atomically consumes one use when limited.
func authorizeShareDownloadAccessToken(tokenParam, shareHash string) bool {
claims, ok := verifySignedShareAccessToken(tokenParam)
if !ok || claims == nil {
return false
}
if claims.Scope != shareAccessScopeDownload || claims.Hash != shareHash {
return false
}
if time.Now().Unix() > claims.Exp {
return false
}
if claims.ID == "" {
return true
}
shareDownloadGrantMu.Lock()
defer shareDownloadGrantMu.Unlock()
grant, ok := utils.ShareAccessGrantsCache.Get(claims.ID)
if !ok || grant.RemainingUses <= 0 {
return false
}
grant.RemainingUses--
if grant.RemainingUses <= 0 {
utils.ShareAccessGrantsCache.Delete(claims.ID)
} else {
utils.ShareAccessGrantsCache.Set(claims.ID, grant)
}
return true
}
// shareRouteAllowsDownloadToken reports whether an ephemeral download token may authorize this path.
func shareRouteAllowsDownloadToken(path string) bool {
return strings.Contains(path, "/resources/download") ||
strings.Contains(path, "/resources/view") ||
strings.Contains(path, "/media/stream") ||
strings.Contains(path, "/raw")
}
// shareRequestAllowsDownloadToken reports whether an ephemeral download token may authorize this request.
func shareRequestAllowsDownloadToken(r *http.Request) bool {
if r == nil {
return false
}
return shareRouteAllowsDownloadToken(r.URL.Path)
}
// directDownloadURL builds a public download URL with an ephemeral token query parameter.
func directDownloadURL(host, scheme, hash, token string) string {
tokenParam := ""
if token != "" {
tokenParam = fmt.Sprintf("&token=%s", token)
}
if settings.Config.Http.ExternalUrl != "" {
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash, tokenParam)
}
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s",
scheme, host, settings.Config.Http.BaseURL, hash, tokenParam)
}
+129
View File
@@ -0,0 +1,129 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestMintAndValidateShareDownloadAccessToken(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
token, expiresAt, err := mintShareDownloadAccessToken("share123", time.Hour, 0)
if err != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", err)
}
if token == "" || expiresAt <= time.Now().Unix() {
t.Fatalf("unexpected token response: token=%q expiresAt=%d", token, expiresAt)
}
if !validateShareDownloadAccessToken(token, "share123") {
t.Fatal("expected valid token for matching hash")
}
if validateShareDownloadAccessToken(token, "other-hash") {
t.Fatal("expected invalid token for different hash")
}
}
func TestShareDownloadTokenRejectedOnListingRoute(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
token, _, err := mintShareDownloadAccessToken("share123", time.Hour, 0)
if err != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=share123&token="+token, nil)
if shareRequestAllowsDownloadToken(req) {
t.Fatal("listing route should not allow download token auth")
}
downloadReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=share123&token="+token, nil)
if !shareRequestAllowsDownloadToken(downloadReq) {
t.Fatal("download route should allow download token auth")
}
}
func TestAuthorizeShareDownloadAccessTokenUseCount(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
token, _, err := mintShareDownloadAccessToken("share123", time.Hour, 1)
if err != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", err)
}
if !authorizeShareDownloadAccessToken(token, "share123") {
t.Fatal("expected first authorization to succeed")
}
if authorizeShareDownloadAccessToken(token, "share123") {
t.Fatal("expected token to be invalid after single use")
}
}
func TestMintAndValidateShareUISessionToken(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
token, expiresAt, err := mintShareUISessionToken("share123", time.Hour)
if err != nil {
t.Fatalf("mintShareUISessionToken: %v", err)
}
if token == "" || expiresAt <= time.Now().Unix() {
t.Fatalf("unexpected token response: token=%q expiresAt=%d", token, expiresAt)
}
if !validateShareUISessionToken(token, "share123") {
t.Fatal("expected valid UI session token for matching hash")
}
if validateShareUISessionToken(token, "other-hash") {
t.Fatal("expected invalid UI session token for different hash")
}
}
func TestAuthorizeShareDownloadAccessTokenConcurrentSingleUse(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
token, _, mintErr := mintShareDownloadAccessToken("share123", time.Hour, 1)
if mintErr != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", mintErr)
}
allowed := make(chan bool, 2)
for i := 0; i < 2; i++ {
go func() {
allowed <- authorizeShareDownloadAccessToken(token, "share123")
}()
}
successes := 0
for i := 0; i < 2; i++ {
if <-allowed {
successes++
}
}
if successes != 1 {
t.Fatalf("expected exactly one concurrent authorization, got %d", successes)
}
}
func TestParseShareDownloadAccessDurationMax24Hours(t *testing.T) {
if _, err := parseShareDownloadAccessDuration(25, "hours"); err == nil {
t.Fatal("expected error for duration over 24 hours")
}
ttl, err := parseShareDownloadAccessDuration(2, "hours")
if err != nil {
t.Fatalf("parseShareDownloadAccessDuration: %v", err)
}
if ttl != 2*time.Hour {
t.Fatalf("ttl = %v, want 2h", ttl)
}
}
+9 -12
View File
@@ -9,43 +9,40 @@ import (
func TestApplySharePasswordUpdatePreservesWhenOmitted(t *testing.T) {
link := &share.Share{
PasswordHash: "hashed",
Token: "tok",
}
if err := applySharePasswordUpdate(link, nil, "", ""); err != nil {
if err := applySharePasswordUpdate(link, nil, ""); err != nil {
t.Fatal(err)
}
if link.PasswordHash != "hashed" || link.Token != "tok" {
t.Fatalf("expected preserved secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
if link.PasswordHash != "hashed" {
t.Fatalf("expected preserved password hash, got hash=%q", link.PasswordHash)
}
}
func TestApplySharePasswordUpdateClearsWhenEmpty(t *testing.T) {
link := &share.Share{
PasswordHash: "hashed",
Token: "tok",
}
empty := ""
if err := applySharePasswordUpdate(link, &empty, "", ""); err != nil {
if err := applySharePasswordUpdate(link, &empty, ""); err != nil {
t.Fatal(err)
}
if link.PasswordHash != "" || link.Token != "" {
t.Fatalf("expected cleared secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
if link.PasswordHash != "" {
t.Fatalf("expected cleared password hash, got hash=%q", link.PasswordHash)
}
}
func TestApplySharePasswordUpdateReplacesWhenProvided(t *testing.T) {
link := &share.Share{
PasswordHash: "old",
Token: "oldtok",
}
next := "new-password"
if err := applySharePasswordUpdate(link, &next, "newhash", "newtok"); err != nil {
if err := applySharePasswordUpdate(link, &next, "newhash"); err != nil {
t.Fatal(err)
}
if link.PasswordHash != "newhash" || link.Token != "newtok" {
t.Fatalf("expected replaced secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
if link.PasswordHash != "newhash" {
t.Fatalf("expected replaced password hash, got hash=%q", link.PasswordHash)
}
}
+120
View File
@@ -0,0 +1,120 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestAuthenticateShareRequestUISessionCookie(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
link := share.Share{
ShareColumns: share.ShareColumns{Hash: "ui_session_share"},
PasswordHash: string(passwordHash),
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=ui_session_share", nil)
req.Header.Set("X-SHARE-PASSWORD", "secret")
status, err := AuthenticateShareRequest(rec, req, link)
if err != nil || status != http.StatusOK {
t.Fatalf("expected password auth to succeed, status=%d err=%v", status, err)
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != shareUISessionCookieName {
t.Fatalf("expected share session cookie, got %#v", cookies)
}
downloadReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=ui_session_share", nil)
downloadReq.AddCookie(cookies[0])
status, err = AuthenticateShareRequest(nil, downloadReq, link)
if err != nil || status != http.StatusOK {
t.Fatalf("expected cookie auth to succeed, status=%d err=%v", status, err)
}
token, _, mintErr := mintShareUISessionToken("other_share", time.Hour)
if mintErr != nil {
t.Fatalf("mintShareUISessionToken: %v", mintErr)
}
wrongReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=ui_session_share", nil)
wrongReq.AddCookie(&http.Cookie{Name: shareUISessionCookieName, Value: token})
status, err = AuthenticateShareRequest(nil, wrongReq, link)
if err != nil || status == http.StatusOK {
t.Fatalf("expected mismatched hash cookie to fail, status=%d err=%v", status, err)
}
}
func TestAuthenticateShareRequestDownloadTokenDoesNotMintUISession(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
link := share.Share{
ShareColumns: share.ShareColumns{Hash: "dl_token_share"},
PasswordHash: string(passwordHash),
}
downloadToken, _, err := mintShareDownloadAccessToken(link.Hash, time.Hour, 0)
if err != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash="+link.Hash+"&token="+downloadToken, nil)
req.Header.Set("X-SHARE-PASSWORD", "secret")
status, err := AuthenticateShareRequest(rec, req, link)
if err != nil || status != http.StatusOK {
t.Fatalf("expected download token auth to succeed, status=%d err=%v", status, err)
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("download token auth must not mint a UI session cookie when password header is also present")
}
}
func TestAuthenticateShareRequestDownloadTokenWrongPasswordNoUISession(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
passwordHash, bcryptErr := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
if bcryptErr != nil {
t.Fatalf("bcrypt: %v", bcryptErr)
}
link := share.Share{
ShareColumns: share.ShareColumns{Hash: "dl_wrong_pw_share"},
PasswordHash: string(passwordHash),
}
downloadToken, _, mintErr := mintShareDownloadAccessToken(link.Hash, time.Hour, 0)
if mintErr != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", mintErr)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash="+link.Hash+"&token="+downloadToken, nil)
req.Header.Set("X-SHARE-PASSWORD", "wrong-password")
status, authErr := AuthenticateShareRequest(rec, req, link)
if authErr != nil || status != http.StatusOK {
t.Fatalf("expected download token auth to succeed, status=%d err=%v", status, authErr)
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("valid download token with wrong password header must not mint a UI session cookie")
}
}
+68 -2
View File
@@ -2,6 +2,7 @@ package web
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
@@ -42,7 +43,13 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return fmt.Errorf("error reloading template: %w", err)
}
}
// Set headers
nonce, err := utils.CSPNonceFromData(data)
if err != nil {
return err
}
// Inherited by srcdoc preview frames: nonce'd SPA scripts run; untrusted preview scripts do not.
w.Header().Set("Content-Security-Policy", spaContentSecurityPolicy(nonce))
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("X-Accel-Expires", "0")
@@ -51,6 +58,51 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return templates.ExecuteTemplate(w, name, data)
}
// spaContentSecurityPolicy restricts only scripts. srcdoc preview frames inherit
// this header, blocking inline scripts without limiting frames, images, or API calls.
func spaContentSecurityPolicy(nonce string) string {
policy := fmt.Sprintf(
"script-src 'self' 'nonce-%s' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
nonce,
)
for _, origin := range onlyOfficeScriptSrcOrigins() {
policy += " " + origin
}
return policy
}
func onlyOfficeScriptSrcOrigins() []string {
oo := settings.Config.Integrations.OnlyOffice
candidates := []string{
cspOriginFromURL(oo.Url),
cspOriginFromURL(oo.InternalUrl),
}
var origins []string
seen := make(map[string]struct{}, len(candidates))
for _, origin := range candidates {
if origin == "" {
continue
}
if _, ok := seen[origin]; ok {
continue
}
seen[origin] = struct{}{}
origins = append(origins, origin)
}
return origins
}
func cspOriginFromURL(raw string) string {
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
w.Header().Set("Content-Type", contentType)
userSelectedTheme := ""
@@ -80,7 +132,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
externalLinks = newExternalLinks
}
defaultThemeColor := "#455a64"
defaultThemeColor := settings.DefaultBackgroundColor()
staticURL := settings.Config.Http.BaseURL + "public/static"
description := settings.Config.Frontend.Description
title := settings.Config.Frontend.Name
@@ -170,6 +222,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"title": title,
"customCSS": template.CSS(settings.Config.Frontend.Styling.CustomCSSRaw),
"userSelectedTheme": template.CSS(userSelectedTheme),
"defaultDarkMode": settings.DefaultDarkMode(),
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
@@ -223,8 +276,20 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"loginButtonText": settings.Config.Frontend.LoginButtonText,
"passkeyAvailable": settings.Config.Auth.Methods.PasskeyAuth.Enabled,
"passkeyLoginButtonText": settings.Config.Auth.Methods.PasskeyAuth.LoginButtonText,
"disablePWAInstall": settings.Config.Frontend.DisablePWAInstall,
}
cspNonce, err := utils.CSPNonce()
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("csp nonce: %w", err)
}
globalVars, ok := data["globalVars"].(map[string]interface{})
if !ok {
return http.StatusInternalServerError, errors.New("unable to load global variables")
}
globalVars["cspNonce"] = cspNonce
data["cspNonce"] = cspNonce
// Marshal each variable to JSON strings for direct template usage
globalVarsJSON, err := json.Marshal(data["globalVars"])
if err != nil {
@@ -324,6 +389,7 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
assetPath = "img/icons/favicon.png"
case "icons/favicon-32x32.png",
"icons/pwa-icon-192.png", "icons/pwa-icon-256.png", "icons/pwa-icon-512.png",
"icons/pwa-icon-maskable-192.png", "icons/pwa-icon-maskable-512.png",
"icons/apple-touch-icon.png", "icons/mstile-256x256.png":
// Files are generated as PWAIconsCacheDir()/basename (URL uses icons/ prefix only for routing)
rel := strings.TrimPrefix(path, "icons/")
+26
View File
@@ -3,7 +3,9 @@ package web
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strconv"
@@ -96,3 +98,27 @@ func isContentUpload(r *http.Request) bool {
_, ok, err := parseUploadTotalSize(r)
return err == nil && ok
}
func drainRequestBody(r *http.Request) {
if r.Body == nil {
return
}
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}
type chunkUploadResponse struct {
Offset int64 `json:"offset"`
Total int64 `json:"total"`
Complete bool `json:"complete"`
}
func writeChunkUploadResponse(w http.ResponseWriter, offset, total int64, complete bool) error {
w.Header().Set("Connection", "close")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).Encode(chunkUploadResponse{
Offset: offset,
Total: total,
Complete: complete,
})
}
+28 -4
View File
@@ -4,8 +4,6 @@ import (
"encoding/json"
stderrors "errors"
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"io"
"net/http"
"net/url"
@@ -13,15 +11,17 @@ import (
"sort"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
type UserRequest struct {
@@ -273,6 +273,25 @@ func validatePatchWhich(which []string) error {
return nil
}
// validateNonAdminSelfPatchWhich rejects self-service PATCH attempts that touch privileged fields.
// Non-admins may only update NonAdminEditable profile fields, plus password (with X-Password) and otpEnabled.
func validateNonAdminSelfPatchWhich(which []string) error {
allowed := nonAdminEditableFieldNameSet()
for _, w := range which {
f := utils.CapitalizeFirst(strings.TrimSpace(w))
if f == "" {
return fmt.Errorf("which must not contain empty field names")
}
if strings.EqualFold(f, "Password") || strings.EqualFold(f, "OtpEnabled") {
continue
}
if _, ok := allowed[f]; !ok {
return fmt.Errorf("cannot update privileged user fields without admin permissions")
}
}
return nil
}
// userPutOnlyNonAdminEditableFields reports whether req.Which lists exclusively NonAdminEditable fields,
// excluding Password.
func userPutOnlyNonAdminEditableFields(which []string) bool {
@@ -373,6 +392,11 @@ func userPatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if !d.User.Permissions.Admin && targetUsername != d.User.Username {
return http.StatusForbidden, nil
}
if !d.User.Permissions.Admin && targetUsername == d.User.Username {
if err = validateNonAdminSelfPatchWhich(req.Which); err != nil {
return http.StatusForbidden, err
}
}
uValue, err2 := state.GetUserByUsername(targetUsername)
if err2 == errors.ErrNotExist {
+48
View File
@@ -53,3 +53,51 @@ func TestValidatePatchWhich(t *testing.T) {
}
})
}
func TestValidateNonAdminSelfPatchWhich(t *testing.T) {
t.Run("allows profile fields", func(t *testing.T) {
for _, which := range [][]string{
{"locale"},
{"darkMode", "viewMode"},
{"preview"},
{"sorting"},
} {
if err := validateNonAdminSelfPatchWhich(which); err != nil {
t.Fatalf("unexpected error for which=%v: %v", which, err)
}
}
})
t.Run("allows password and otpEnabled", func(t *testing.T) {
for _, which := range [][]string{
{"password"},
{"otpEnabled"},
{"locale", "password"},
} {
if err := validateNonAdminSelfPatchWhich(which); err != nil {
t.Fatalf("unexpected error for which=%v: %v", which, err)
}
}
})
t.Run("rejects privileged fields", func(t *testing.T) {
for _, which := range [][]string{
{"scopes"},
{"permissions"},
{"sourcePermissions"},
{"loginMethod"},
{"lockPassword"},
{"disableSettings"},
} {
if err := validateNonAdminSelfPatchWhich(which); err == nil {
t.Fatalf("expected error for which=%v", which)
}
}
})
t.Run("rejects mixed privileged and profile fields", func(t *testing.T) {
if err := validateNonAdminSelfPatchWhich([]string{"locale", "scopes"}); err == nil {
t.Fatal("expected error when which mixes profile and privileged fields")
}
})
}
+3
View File
@@ -149,6 +149,9 @@ func ValidateLdapAuth() error {
if ldapCfg.UserFilter == "" {
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
}
if ldapCfg.GroupsClaim == "" {
ldapCfg.GroupsClaim = defaultLdapGroupsClaim
}
applyAuthCommonDefaults(&ldapCfg.AuthCommon)
if err := verifyLdapConnection(); err != nil {
logger.Fatalf("LDAP connection check failed: %v", err)
+12 -1
View File
@@ -1,6 +1,9 @@
package settings
const defaultGroupsClaim = "groups"
const (
defaultGroupsClaim = "groups"
defaultLdapGroupsClaim = "memberOf"
)
// ResolveGroupsClaim returns the configured groups claim or the shared default when empty.
func ResolveGroupsClaim(claim string) string {
@@ -10,6 +13,14 @@ func ResolveGroupsClaim(claim string) string {
return claim
}
// ResolveLdapGroupsClaim returns the configured LDAP groups attribute or memberOf when empty.
func ResolveLdapGroupsClaim(claim string) string {
if claim == "" {
return defaultLdapGroupsClaim
}
return claim
}
// applyAuthCommonDefaults sets shared defaults for external auth methods that support group claims.
func applyAuthCommonDefaults(c *AuthCommon) {
c.GroupsClaim = ResolveGroupsClaim(c.GroupsClaim)
+20
View File
@@ -24,3 +24,23 @@ func TestResolveGroupsClaim(t *testing.T) {
t.Fatalf("ResolveGroupsClaim(memberOf) = %q, want memberOf", got)
}
}
func TestResolveLdapGroupsClaim(t *testing.T) {
if got := ResolveLdapGroupsClaim(""); got != "memberOf" {
t.Fatalf("ResolveLdapGroupsClaim(\"\") = %q, want memberOf", got)
}
if got := ResolveLdapGroupsClaim("groups"); got != "groups" {
t.Fatalf("ResolveLdapGroupsClaim(groups) = %q, want groups", got)
}
}
func TestApplyLdapAuthCommonDefaultsUsesMemberOf(t *testing.T) {
ldapCfg := LdapConfig{AuthCommon: AuthCommon{}}
if ldapCfg.GroupsClaim == "" {
ldapCfg.GroupsClaim = defaultLdapGroupsClaim
}
applyAuthCommonDefaults(&ldapCfg.AuthCommon)
if ldapCfg.GroupsClaim != "memberOf" {
t.Fatalf("ldap groupsClaim = %q, want memberOf", ldapCfg.GroupsClaim)
}
}
+15 -2
View File
@@ -31,6 +31,16 @@ const (
)
func Initialize(configFile string) {
initializeConfig(configFile, false)
}
// InitializeCLI loads config for CLI subcommands without server-only setup (ffmpeg, frontend, etc.).
func InitializeCLI(configFile string) {
initializeConfig(configFile, true)
}
func initializeConfig(configFile string, cliMode bool) {
Env.IsCLIMode = cliMode
err := loadConfigWithDefaults(configFile, false)
if err != nil {
logger.Error("unable to load config, waiting 5 seconds before exiting...")
@@ -57,6 +67,9 @@ func Initialize(configFile string) {
setupSources(false)
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
setupUrls()
if cliMode {
return
}
warnHttpProxyConfig()
setupFrontend(false)
setupMedia(false)
@@ -404,7 +417,7 @@ func setupSources(generate bool) {
if source.Config.Disabled {
continue
}
realPath, err := filepath.Abs(source.Path)
realPath, err := AbsPath(source.Path)
if err != nil {
logger.Fatalf("error getting real path for source %v: %v", source.Path, err)
}
@@ -962,7 +975,7 @@ func SetDefaults(generate bool) Settings {
// validateCustomImage validates a custom image file path and returns the absolute path or error
func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) {
// Get absolute path
absolutePath, err = filepath.Abs(configPath)
absolutePath, err = AbsPath(configPath)
if err != nil {
return "", fmt.Errorf("could not resolve path: %w", err)
}
+38
View File
@@ -0,0 +1,38 @@
package settings
import (
"fmt"
"os"
"path/filepath"
)
// ExpandTilde replaces a leading ~ or ~/ with the user home directory.
func ExpandTilde(path string) (string, error) {
if path == "" || path[0] != '~' {
return path, nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to expand home directory: %w", err)
}
if path == "~" {
return homeDir, nil
}
if path[1] == '/' || path[1] == '\\' {
return filepath.Join(homeDir, path[2:]), nil
}
return path, nil
}
// AbsPath expands a leading tilde, then resolves the path to an absolute path.
func AbsPath(path string) (string, error) {
expanded, err := ExpandTilde(path)
if err != nil {
return "", err
}
return filepath.Abs(expanded)
}
+54
View File
@@ -0,0 +1,54 @@
package settings
import (
"os"
"path/filepath"
"testing"
)
func TestExpandTilde(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("UserHomeDir: %v", err)
}
tests := []struct {
name string
in string
want string
}{
{name: "empty", in: "", want: ""},
{name: "absolute", in: "/abs/path", want: "/abs/path"},
{name: "relative", in: "relative/path", want: "relative/path"},
{name: "tilde only", in: "~", want: home},
{name: "tilde slash", in: "~/", want: filepath.Join(home, "")},
{name: "tilde subpath", in: "~/Documents", want: filepath.Join(home, "Documents")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ExpandTilde(tt.in)
if err != nil {
t.Fatalf("ExpandTilde(%q): %v", tt.in, err)
}
if got != tt.want {
t.Fatalf("ExpandTilde(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestAbsPath_expandsTilde(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("UserHomeDir: %v", err)
}
got, err := AbsPath("~/")
if err != nil {
t.Fatalf("AbsPath: %v", err)
}
if got != home {
t.Fatalf("AbsPath(\"~/\") = %q, want %q", got, home)
}
}
-5
View File
@@ -140,12 +140,7 @@ func ApplyUserDefaultsFrom(u *users.User, d UserDefaults) {
u.BackendScopes = MergeDefaultEnabledBackendScopes(u.BackendScopes)
if len(u.SidebarLinks) == 0 && len(u.BackendScopes) > 0 {
scope := u.BackendScopes[0]
name := scope.Path
if src := Config.Server.SourceMap[scope.Path]; src != nil && src.Name != "" {
name = src.Name
}
u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{
Name: name,
Category: "source",
Target: "/",
Icon: "",
+2
View File
@@ -38,6 +38,7 @@ type Http struct {
}
type Environment struct {
IsCLIMode bool `json:"-"` // true when running a CLI subcommand (user set, promote, etc.)
IsPlaywright bool `json:"-"`
IsDevMode bool `json:"-"`
IsFirstLoad bool `json:"-"` // used internally to track if this is the first load of the application
@@ -300,6 +301,7 @@ type Frontend struct {
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
DisablePWAInstall bool `json:"disablePWAInstall"` // disable the PWA install button in the sidebar
}
type StylingConfig struct {
+13
View File
@@ -118,3 +118,16 @@ func readCustomCSS(path string) (string, error) {
logger.Debugf("Loaded custom CSS from: %s (%d bytes)", path, len(content))
return string(content), nil
}
// DefaultDarkMode reports whether the instance default theme is dark.
func DefaultDarkMode() bool {
return Config.UserDefaults.UI.DarkMode != nil && *Config.UserDefaults.UI.DarkMode
}
// DefaultBackgroundColor returns the background color of the instance's default theme.
func DefaultBackgroundColor() string {
if DefaultDarkMode() {
return Config.Frontend.Styling.DarkBackground
}
return Config.Frontend.Styling.LightBackground
}
+1 -11
View File
@@ -17,17 +17,7 @@ import (
// - test.yaml + database-test.yaml + frontend-test.yaml
// - myapp.yml + server-myapp.yml
func combineYAMLFiles(configFilePath string) ([]byte, error) {
// Get absolute path and expand tilde
expandedPath := configFilePath
if strings.HasPrefix(configFilePath, "~/") {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to expand home directory: %v", err)
}
expandedPath = filepath.Join(homeDir, configFilePath[2:])
}
absPath, err := filepath.Abs(expandedPath)
absPath, err := AbsPath(configFilePath)
if err != nil {
return nil, fmt.Errorf("failed to resolve config file path: %v", err)
}
+39 -29
View File
@@ -3088,7 +3088,7 @@ const docTemplate = `{
},
"/api/share/direct": {
"get": {
"description": "Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.",
"description": "Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.",
"consumes": [
"application/json"
],
@@ -3098,50 +3098,59 @@ const docTemplate = `{
"tags": [
"Shares"
],
"summary": "Create direct download link",
"summary": "Get direct download link",
"parameters": [
{
"type": "string",
"description": "File path to create download link for",
"name": "path",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the file",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration in minutes for link validity (default: 60)",
"description": "Duration value (default: 60)",
"name": "duration",
"in": "query"
},
{
"type": "string",
"description": "Maximum number of downloads allowed (default: unlimited)",
"description": "Duration unit: minutes or hours (default: minutes)",
"name": "unit",
"in": "query"
},
{
"type": "string",
"description": "Maximum number of downloads allowed with this token (default: unlimited within TTL)",
"name": "count",
"in": "query"
},
{
"type": "string",
"description": "Download speed limit in kbps (default: unlimited)",
"name": "speed",
"in": "query"
"description": "Share password",
"name": "X-SHARE-PASSWORD",
"in": "header",
"required": true
}
],
"responses": {
"201": {
"description": "Direct download link created",
"200": {
"description": "Ephemeral download link",
"schema": {
"$ref": "#/definitions/web.DirectDownloadResponse"
}
},
"400": {
"description": "Bad request - invalid parameters or path is not a file",
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Invalid or missing share password",
"schema": {
"type": "object",
"additionalProperties": {
@@ -3150,7 +3159,7 @@ const docTemplate = `{
}
},
"403": {
"description": "Forbidden - access denied",
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
@@ -3158,8 +3167,8 @@ const docTemplate = `{
}
}
},
"500": {
"description": "Internal server error",
"404": {
"description": "Share not found",
"schema": {
"type": "object",
"additionalProperties": {
@@ -5675,6 +5684,10 @@ const docTemplate = `{
"description": "disable the nav buttons in the sidebar",
"type": "boolean"
},
"disablePWAInstall": {
"description": "disable the PWA install button in the sidebar",
"type": "boolean"
},
"disableUsedPercentage": {
"description": "disable used percentage for the sources in the sidebar",
"type": "boolean"
@@ -6826,9 +6839,6 @@ const docTemplate = `{
"title": {
"type": "string"
},
"token": {
"type": "string"
},
"userDownloads": {
"type": "object",
"additionalProperties": {
@@ -8010,13 +8020,13 @@ const docTemplate = `{
"web.DirectDownloadResponse": {
"type": "object",
"properties": {
"expiresAt": {
"type": "integer"
},
"hash": {
"type": "string"
},
"shareUrl": {
"type": "string"
},
"status": {
"token": {
"type": "string"
},
"url": {
+39 -29
View File
@@ -3077,7 +3077,7 @@
},
"/api/share/direct": {
"get": {
"description": "Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.",
"description": "Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.",
"consumes": [
"application/json"
],
@@ -3087,50 +3087,59 @@
"tags": [
"Shares"
],
"summary": "Create direct download link",
"summary": "Get direct download link",
"parameters": [
{
"type": "string",
"description": "File path to create download link for",
"name": "path",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the file",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration in minutes for link validity (default: 60)",
"description": "Duration value (default: 60)",
"name": "duration",
"in": "query"
},
{
"type": "string",
"description": "Maximum number of downloads allowed (default: unlimited)",
"description": "Duration unit: minutes or hours (default: minutes)",
"name": "unit",
"in": "query"
},
{
"type": "string",
"description": "Maximum number of downloads allowed with this token (default: unlimited within TTL)",
"name": "count",
"in": "query"
},
{
"type": "string",
"description": "Download speed limit in kbps (default: unlimited)",
"name": "speed",
"in": "query"
"description": "Share password",
"name": "X-SHARE-PASSWORD",
"in": "header",
"required": true
}
],
"responses": {
"201": {
"description": "Direct download link created",
"200": {
"description": "Ephemeral download link",
"schema": {
"$ref": "#/definitions/web.DirectDownloadResponse"
}
},
"400": {
"description": "Bad request - invalid parameters or path is not a file",
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Invalid or missing share password",
"schema": {
"type": "object",
"additionalProperties": {
@@ -3139,7 +3148,7 @@
}
},
"403": {
"description": "Forbidden - access denied",
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
@@ -3147,8 +3156,8 @@
}
}
},
"500": {
"description": "Internal server error",
"404": {
"description": "Share not found",
"schema": {
"type": "object",
"additionalProperties": {
@@ -5664,6 +5673,10 @@
"description": "disable the nav buttons in the sidebar",
"type": "boolean"
},
"disablePWAInstall": {
"description": "disable the PWA install button in the sidebar",
"type": "boolean"
},
"disableUsedPercentage": {
"description": "disable used percentage for the sources in the sidebar",
"type": "boolean"
@@ -6815,9 +6828,6 @@
"title": {
"type": "string"
},
"token": {
"type": "string"
},
"userDownloads": {
"type": "object",
"additionalProperties": {
@@ -7999,13 +8009,13 @@
"web.DirectDownloadResponse": {
"type": "object",
"properties": {
"expiresAt": {
"type": "integer"
},
"hash": {
"type": "string"
},
"shareUrl": {
"type": "string"
},
"status": {
"token": {
"type": "string"
},
"url": {
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more