mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-13 12:41:59 -05:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
462f19af2e | ||
|
|
45b0ae5955 | ||
|
|
3e17afa337 | ||
|
|
23113bf740 | ||
|
|
0a330e2c38 | ||
|
|
bf18a264be | ||
|
|
81a8848165 | ||
|
|
5610cdd2bf | ||
|
|
ea85c50946 | ||
|
|
be47c7a1e7 |
No files matched your search
@@ -5,7 +5,7 @@
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
"version": "22"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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'."
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,51 @@
|
||||
|
||||
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.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)
|
||||
|
||||
## 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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
+18
-25
@@ -81,7 +81,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
|
||||
}
|
||||
|
||||
@@ -233,35 +234,27 @@ func updatePreviewSettings(user *users.User) bool {
|
||||
|
||||
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
|
||||
func updateSidebarLinks(user *users.User) bool {
|
||||
updated := false
|
||||
needsEnsure := usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
|
||||
validBefore := usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks)
|
||||
|
||||
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
|
||||
user.SidebarLinks = normalized
|
||||
updated = true
|
||||
links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
if !usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes) {
|
||||
return updated
|
||||
user.SidebarLinks = links
|
||||
|
||||
if needsEnsure {
|
||||
if validBefore == 0 && len(user.SidebarLinks) > 0 {
|
||||
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
|
||||
} else if validBefore == 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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return true
|
||||
}
|
||||
|
||||
func updateTokens(user *users.User) bool {
|
||||
|
||||
+43
-35
@@ -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
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 TestUpdateUserScopesKeepsStaleSidebarLinks(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 != 2 {
|
||||
t.Fatalf("expected stale include link kept, 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)
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,10 @@ 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.
|
||||
// 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 +20,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,11 +32,6 @@ 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)
|
||||
@@ -43,9 +39,15 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
|
||||
if strings.TrimSpace(normalized.Name) == "" {
|
||||
normalized.Name = source.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 +79,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
|
||||
}
|
||||
@@ -164,6 +164,88 @@ 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 canonical folder shortcuts unchanged")
|
||||
}
|
||||
if len(out) != 4 {
|
||||
t.Fatalf("len(out) = %d, want 4", len(out))
|
||||
}
|
||||
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 != "docker" {
|
||||
t.Fatalf("first link wins: %#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() {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package usersidebar
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
// PrepareSidebarLinksForPersist normalizes sidebar links and adds missing scoped source entries.
|
||||
// Inaccessible sources are not pruned; callers rely on the UI to gray out stale links.
|
||||
func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
|
||||
updated := false
|
||||
|
||||
if normalized, changed := NormalizeSidebarLinks(links); changed {
|
||||
links = normalized
|
||||
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 != "playwright + files" {
|
||||
t.Fatalf("added scope link = %#v", out[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSidebarLinksForPersist_keepsLinksWhenScopeRemoved(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=false when only pruning is not performed")
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len(out) = %d, want 3 (stale scope link kept)", len(out))
|
||||
}
|
||||
}
|
||||
@@ -89,3 +89,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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,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 +234,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 {
|
||||
@@ -427,17 +418,27 @@ func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
|
||||
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",
|
||||
const sessionCookieName = "filebrowser_quantum_jwt"
|
||||
|
||||
// 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: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
|
||||
SameSite: sameSite,
|
||||
HttpOnly: true,
|
||||
Secure: requestScheme(r) == "https",
|
||||
Expires: expiresTime,
|
||||
MaxAge: maxAge,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
// SetSessionCookie sets the authentication token as an HTTP cookie.
|
||||
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
|
||||
http.SetCookie(w, sessionCookie(r, token, expiresTime, 0, http.SameSiteStrictMode))
|
||||
}
|
||||
|
||||
// applyNamedApiTokenGlobalCaps intersects owner globals with JWT global caps for named custom API tokens.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -387,6 +387,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 +507,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 +541,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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/")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -5675,6 +5675,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"
|
||||
|
||||
@@ -5664,6 +5664,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"
|
||||
|
||||
@@ -413,6 +413,9 @@ definitions:
|
||||
disableNavButtons:
|
||||
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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
22
|
||||
Generated
+367
-499
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": ">=9.0.0",
|
||||
"node": ">=20.0.0"
|
||||
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rm -rf ../backend/internal/web/embed/* && vite build && cp -r ../backend/internal/web/dist/* ../backend/internal/web/embed",
|
||||
@@ -31,7 +31,6 @@
|
||||
"css-vars-ponyfill": "^2.4.3",
|
||||
"dompurify": "^3.2.4",
|
||||
"epubjs": "^0.3.93",
|
||||
"glob": "^9.3.5",
|
||||
"highlight.js": "^11.11.1",
|
||||
"mammoth": "^1.9.1",
|
||||
"marked": "^15.0.6",
|
||||
@@ -69,7 +68,7 @@
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-checker": "^0.10.3",
|
||||
"vite-plugin-compression2": "^1.0.0",
|
||||
"vitest": "^4.1.8",
|
||||
"vitest": "^5.0.0",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^2.0.7",
|
||||
"wawoff2": "^2.0.1"
|
||||
|
||||
@@ -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/global-setup",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/settings",
|
||||
|
||||
@@ -164,6 +164,7 @@ frontend:
|
||||
loginIcon: "" # path to an image file for the login page icon
|
||||
loginButtonText: "" # text to display on the login button
|
||||
oidcLoginButtonText: "" # text to display on the OIDC login button
|
||||
disablePWAInstall: false # disable the PWA install button in the sidebar
|
||||
userDefaults: # optional signup/CLI defaults; per-user values are managed in the UI
|
||||
sidebar:
|
||||
disableQuickToggles: false # disable the quick toggles in the sidebar
|
||||
|
||||
Binary file not shown.
@@ -1,10 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="{{ if .htmlVars.defaultDarkMode }}dark-mode{{ end }}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>"{{ .htmlVars.title }}"</title>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="{{ .htmlVars.favicon }}">
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<!-- Add to home screen for Safari on iOS/iPadOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="assets">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ .htmlVars.appIcon }}">
|
||||
|
||||
@@ -49,9 +49,9 @@
|
||||
<link rel="canonical" href="https://github.com/quantumx-apps">
|
||||
<link rel="canonical" href="https://filebrowserquantum.com">
|
||||
|
||||
<!-- Injected variables come from backend server -->
|
||||
<script>
|
||||
window.globalVars = {{ .globalVars }};
|
||||
<!-- Injected variables come from backend server (JSON block is not subject to script-src CSP) -->
|
||||
<script id="app-config" type="application/json">{{ .globalVars }}</script>
|
||||
<script nonce="{{ .cspNonce }}">
|
||||
window.__pwaDeferredPrompt = null;
|
||||
window.addEventListener("beforeinstallprompt", function (event) {
|
||||
event.preventDefault();
|
||||
@@ -72,6 +72,11 @@
|
||||
--iconBackground: #dddddd;
|
||||
--activeWhiteIcon: gray;
|
||||
}
|
||||
/* paint safe areas + iOS overscroll with app background (notched PWAs) */
|
||||
html {
|
||||
background-color: var(--background);
|
||||
min-height: calc(100% + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
.dark-mode {
|
||||
--background: {{ .htmlVars.darkBackground }};
|
||||
--alt-background: #283136;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// scripts/build-icon-subset.js
|
||||
import fs from 'fs-extra';
|
||||
import { sync } from 'glob';
|
||||
import { globSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import wawoff2 from 'wawoff2';
|
||||
@@ -73,7 +73,7 @@ function addIcon(icons, value) {
|
||||
}
|
||||
|
||||
function scanVueAndTemplates(icons) {
|
||||
const files = sync(path.join(srcDir, '**/*.vue'));
|
||||
const files = globSync(path.join(srcDir, '**/*.vue'));
|
||||
const materialTag = new RegExp(
|
||||
`material-symbols(?:-outlined)?[^>]*>\\s*(${ICON_NAME})\\s*<`,
|
||||
'g',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable security/detect-object-injection */
|
||||
// scripts/sync-translations.js
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Translator } from 'deepl-node';
|
||||
import fs from 'fs-extra';
|
||||
import { sync } from 'glob';
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
@@ -18,7 +18,7 @@ const __dirname = path.dirname(__filename);
|
||||
const localesDir = path.resolve(__dirname, '../src/i18n');
|
||||
const masterLocaleFile = path.join(localesDir, 'en.json');
|
||||
const masterLanguageCode = 'en';
|
||||
const targetLocaleFiles = sync(path.join(localesDir, '*.json'))
|
||||
const targetLocaleFiles = globSync(path.join(localesDir, '*.json'))
|
||||
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
|
||||
|
||||
const requireApiKey = !checkOnly && !enforceOrder && !cleanupOnly;
|
||||
|
||||
@@ -19,6 +19,14 @@ export { fetchPreviewImage } from '@/utils/previewRequests'
|
||||
|
||||
const VIEW_TOKEN_TTL_SECONDS = 15 * 60;
|
||||
|
||||
function chunkUploadApiPath(apiPath, headers) {
|
||||
if (headers["X-File-Chunk-Offset"] === undefined) {
|
||||
return apiPath;
|
||||
}
|
||||
const sep = apiPath.includes("?") ? "&" : "?";
|
||||
return `${apiPath}${sep}_chunkSeq=${headers["X-File-Chunk-Offset"]}`;
|
||||
}
|
||||
|
||||
function cacheViewTokenFromListing(data) {
|
||||
const scope = getters.isShare()
|
||||
? state.shareInfo?.hash || data?.source
|
||||
@@ -718,11 +726,15 @@ export function post(
|
||||
}
|
||||
const request = new XMLHttpRequest();
|
||||
opState.xhr = request;
|
||||
request.open("POST", apiPath, true);
|
||||
const resolvedPath = chunkUploadApiPath(apiPath, headers);
|
||||
request.open("POST", resolvedPath, true);
|
||||
|
||||
Object.entries(headers).forEach(([header, value]) => {
|
||||
request.setRequestHeader(header, value);
|
||||
});
|
||||
if (headers["X-File-Chunk-Offset"] !== undefined) {
|
||||
request.setRequestHeader("Connection", "close");
|
||||
}
|
||||
|
||||
if (typeof onupload === "function") {
|
||||
request.upload.onprogress = (event) => {
|
||||
@@ -1258,11 +1270,14 @@ export function postPublic(
|
||||
});
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", apiPath, true);
|
||||
request.open("POST", chunkUploadApiPath(apiPath, headers), true);
|
||||
|
||||
Object.entries(headers).forEach(([header, value]) => {
|
||||
request.setRequestHeader(header, value);
|
||||
});
|
||||
if (headers["X-File-Chunk-Offset"] !== undefined) {
|
||||
request.setRequestHeader("Connection", "close");
|
||||
}
|
||||
|
||||
if (typeof onupload === "function") {
|
||||
request.upload.onprogress = (event) => {
|
||||
|
||||
@@ -17,10 +17,12 @@ export async function getAllUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/api/users?username= (single user by login name)
|
||||
export async function get(username) {
|
||||
// GET /api/users?username= (single user by login name; use public: true for public routes)
|
||||
export async function get(username, options = {}) {
|
||||
try {
|
||||
const apiPath = getPublicApiPath('users', { username })
|
||||
const apiPath = options.public === true
|
||||
? getPublicApiPath('users', { username })
|
||||
: getApiPath('users', { username })
|
||||
return await fetchJSON(apiPath)
|
||||
} catch (err) {
|
||||
notify.showError(err.message || `Failed to fetch user: ${username}`)
|
||||
|
||||
@@ -206,6 +206,10 @@ import { resourcesApi, shareApi, usersApi } from "@/api";
|
||||
import Action from "@/components/Action.vue";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import {
|
||||
hideInteractiveTooltip,
|
||||
showHoverTooltip,
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
import { url } from "@/utils";
|
||||
import buttons from "@/utils/buttons";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
@@ -587,14 +591,10 @@ export default {
|
||||
mutations.closeHovers();
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
hideInteractiveTooltip();
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
mutations.showTooltip({
|
||||
content: text,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
showHoverTooltip(text, event);
|
||||
},
|
||||
onCreateToggleClick() {
|
||||
if (this.createToggleDisabled) return;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<i
|
||||
:class="[iconClasses, { 'tooltip-info-icon--pressed': pressed }]"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="ariaLabel || undefined"
|
||||
@click.stop="onClick"
|
||||
@touchstart.stop="onTouchStart"
|
||||
@touchend.stop="onTouchEnd"
|
||||
@touchcancel.stop="onTouchCancel"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
{{ icon }}
|
||||
</i>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
onTooltipHelpClick,
|
||||
onTooltipHelpMouseEnter,
|
||||
onTooltipHelpMouseLeave,
|
||||
onTooltipHelpTouchEnd,
|
||||
useTapForTooltip,
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
|
||||
export default {
|
||||
name: "HelpTooltipIcon",
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "help",
|
||||
},
|
||||
iconStyle: {
|
||||
type: String,
|
||||
default: "outlined",
|
||||
validator: (value) => value === "outlined" || value === "symbols",
|
||||
},
|
||||
iconClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
ariaLabel: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pressed: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
iconClasses() {
|
||||
return [
|
||||
"no-select",
|
||||
"tooltip-info-icon",
|
||||
this.iconStyle === "symbols"
|
||||
? "material-symbols"
|
||||
: "material-symbols-outlined",
|
||||
this.iconClass,
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClick(event) {
|
||||
onTooltipHelpClick(event, this.text);
|
||||
},
|
||||
onTouchStart() {
|
||||
if (useTapForTooltip()) {
|
||||
this.pressed = true;
|
||||
}
|
||||
},
|
||||
onTouchEnd(event) {
|
||||
this.pressed = false;
|
||||
onTooltipHelpTouchEnd(event, this.text);
|
||||
},
|
||||
onTouchCancel() {
|
||||
this.pressed = false;
|
||||
},
|
||||
onMouseEnter(event) {
|
||||
onTooltipHelpMouseEnter(event, this.text);
|
||||
},
|
||||
onMouseLeave() {
|
||||
this.pressed = false;
|
||||
onTooltipHelpMouseLeave();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -19,20 +19,13 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
|
||||
v-if="textPosition === 'inside'"
|
||||
>
|
||||
{{ displayed_text }}
|
||||
<i
|
||||
v-if="helpText && status === 'error'"
|
||||
class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon v-if="helpText && status === 'error'" :text="helpText" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mutations } from "@/store";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
|
||||
// We're leaving this untouched as you can read in the beginning
|
||||
@@ -46,6 +39,7 @@ const isNumber = (n) => {
|
||||
|
||||
export default {
|
||||
name: "progress-bar",
|
||||
components: { HelpTooltipIcon },
|
||||
props: {
|
||||
val: {
|
||||
default: 0,
|
||||
@@ -317,20 +311,6 @@ export default {
|
||||
return style;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
showTooltip(event) {
|
||||
if (this.helpText) {
|
||||
mutations.showTooltip({
|
||||
content: this.helpText,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
}
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -65,20 +65,20 @@
|
||||
<div class="sizeInputWrapper">
|
||||
<p>{{ $t("search.smallerThan") }}</p>
|
||||
<input
|
||||
class="sizeInput"
|
||||
class="sizeInput input"
|
||||
v-model="smallerThan"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="MB"
|
||||
/>
|
||||
<p>{{ $t("search.largerThan") }}</p>
|
||||
<input class="sizeInput" v-model="largerThan" type="number" placeholder="MB" />
|
||||
<input class="sizeInput input" v-model="largerThan" type="number" placeholder="MB" />
|
||||
</div>
|
||||
<div class="sizeInputWrapper">
|
||||
<p>{{ $t("search.olderThanDate") }}</p>
|
||||
<input class="sizeInput" v-model="modifiedOlderThan" type="date" />
|
||||
<input class="sizeInput input" v-model="modifiedOlderThan" type="date" />
|
||||
<p>{{ $t("search.newerThanDate") }}</p>
|
||||
<input class="sizeInput" v-model="modifiedNewerThan" type="date" />
|
||||
<input class="sizeInput input" v-model="modifiedNewerThan" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-items">
|
||||
@@ -110,10 +110,7 @@
|
||||
<!-- Message when no results are found -->
|
||||
<div class="searchPrompt" v-show="isEmpty && !isRunning">
|
||||
<p>{{ noneMessage }}</p>
|
||||
<i class="material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
|
||||
@mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="searchHelpText" />
|
||||
</div>
|
||||
<!-- List of search results -->
|
||||
<ul v-show="results.length > 0">
|
||||
@@ -146,6 +143,7 @@
|
||||
|
||||
<script>
|
||||
import { resourcesApi, toolsApi } from "@/api";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import Icon from "@/components/files/Icon.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
@@ -158,6 +156,7 @@ import { globalVars } from "@/utils/constants";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { isTypeAheadSessionActive } from "@/utils/listingTypeAhead.js";
|
||||
import { utcStartOfDaySecondsFromDateInput } from "@/utils/moment";
|
||||
import { shouldIgnoreOutsideTap } from "@/utils/tooltipHelp.js";
|
||||
|
||||
const boxes = {
|
||||
folder: { label: "folders", icon: "folder" },
|
||||
@@ -171,6 +170,7 @@ const boxes = {
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HelpTooltipIcon,
|
||||
Icon,
|
||||
ToggleSwitch,
|
||||
SettingsItem,
|
||||
@@ -352,6 +352,12 @@ export default {
|
||||
isEmpty() {
|
||||
return this.results.length === 0;
|
||||
},
|
||||
searchHelpText() {
|
||||
return [
|
||||
this.$t("search.helpText1", { minSearchLength: globalVars.minSearchLength }),
|
||||
this.$t("search.helpText2"),
|
||||
].join("\n\n");
|
||||
},
|
||||
text() {
|
||||
if (this.ongoing > 0) {
|
||||
return "";
|
||||
@@ -580,21 +586,14 @@ export default {
|
||||
this.noneMessage = this.$t("search.noResults");
|
||||
}
|
||||
},
|
||||
showHelpTooltip(event) {
|
||||
const helpText = [
|
||||
this.$t("search.helpText1", { minSearchLength: globalVars.minSearchLength }),
|
||||
this.$t("search.helpText2"),
|
||||
].join("\n\n");
|
||||
mutations.showTooltip({
|
||||
content: helpText,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
},
|
||||
clearContext() {
|
||||
clearContext(event) {
|
||||
const target = event?.target;
|
||||
if (target instanceof Element && target.closest(".tooltip-info-icon, .floating-tooltip")) {
|
||||
return;
|
||||
}
|
||||
if (shouldIgnoreOutsideTap()) {
|
||||
return;
|
||||
}
|
||||
mutations.closeHovers();
|
||||
},
|
||||
getThumbnailUrl(s) {
|
||||
@@ -907,7 +906,7 @@ export default {
|
||||
border-top-right-radius: 0px;
|
||||
border: var(--borderWidth) solid var(--surfaceSecondary);
|
||||
box-shadow: 0px 2em 50px 10px rgba(0, 0, 0, 0.3);
|
||||
background-color: lightgray;
|
||||
background-color: var(--surfacePrimary);
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -973,7 +972,7 @@ export default {
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
color: var(--divider);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
@@ -983,7 +982,7 @@ export default {
|
||||
}
|
||||
|
||||
#search.active .search-input-container input {
|
||||
color: inherit;
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
|
||||
#search .search-input-container input::placeholder {
|
||||
@@ -1107,11 +1106,11 @@ body.rtl #search #result ul>* {
|
||||
}
|
||||
|
||||
#search .search-input-container input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: color-mix(in srgb, var(--divider) 50%, transparent);
|
||||
}
|
||||
|
||||
#search.active .search-input-container input::placeholder {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
color: var(--textSecondary);
|
||||
}
|
||||
|
||||
#search.dark-mode .search-input-container {
|
||||
@@ -1269,6 +1268,7 @@ body.rtl #search .boxes h3 {
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(6px);
|
||||
height: 4em;
|
||||
background: var(--surfacePrimary)
|
||||
}
|
||||
|
||||
#search.active>div {
|
||||
|
||||
@@ -239,7 +239,7 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
#status-bar {
|
||||
background-color: rgb(37 49 55 / 5%) !important;
|
||||
background-color: color-mix(in srgb, var(--alt-background) 15%, transparent);
|
||||
height: 2.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -314,9 +314,6 @@ input[type="range"] {
|
||||
#status-bar {
|
||||
backdrop-filter: blur(16px) invert(0.1);
|
||||
}
|
||||
#status-bar.dark-mode-header {
|
||||
background-color: rgb(37 49 55 / 33%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { getters, state } from "@/store";
|
||||
import { hideInteractiveTooltip } from "@/utils/tooltipHelp.js";
|
||||
|
||||
export default {
|
||||
name: "Tooltip",
|
||||
@@ -63,7 +64,7 @@ export default {
|
||||
watch: {
|
||||
$route: {
|
||||
handler() {
|
||||
mutations.hideTooltip();
|
||||
hideInteractiveTooltip(true);
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
@@ -149,5 +150,17 @@ export default {
|
||||
font-size: 1em !important;
|
||||
padding: 0.1em !important;
|
||||
padding-left: 0.5em !important;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.tooltip-info-icon[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tooltip-info-icon:active,
|
||||
.tooltip-info-icon.tooltip-info-icon--pressed,
|
||||
.tooltip-info-icon:hover {
|
||||
font-variation-settings: 'FILL' 1;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@
|
||||
</div>
|
||||
<div v-else>
|
||||
<PathPickerButton
|
||||
class="path-picker"
|
||||
v-model:path="currentPath"
|
||||
v-model:source="currentSource"
|
||||
aria-label="access-path"
|
||||
@@ -26,17 +27,16 @@
|
||||
:placeholder="$t('sidebar.chooseSource')"
|
||||
@navigate="onPathPickerNavigate"
|
||||
/>
|
||||
<ActivityViewerButton :href="activityViewerHref" />
|
||||
<div class="settings-items">
|
||||
<ActivityViewerButton class="item" :href="activityViewerHref" />
|
||||
</div>
|
||||
<!-- Default behavior banner -->
|
||||
<div class="card item">
|
||||
<div class="card-content banner-content">
|
||||
<i class="material-symbols">{{ sourceDenyDefault ? 'do_not_disturb_on' : 'check_circle' }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ $t("access.defaultBehavior", { suffix: ":" }) }} {{ sourceDenyDefault ? $t("access.deny") : $t("access.allow")
|
||||
}}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('access.defaultBehaviorDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('access.defaultBehaviorDescription')" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -110,7 +110,7 @@
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { accessApi } from "@/api";
|
||||
import { mutations } from "@/store";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import FileList from "../files/FileList.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
@@ -123,6 +123,7 @@ import { eventBus } from "@/store/eventBus";
|
||||
export default {
|
||||
name: "access",
|
||||
components: {
|
||||
HelpTooltipIcon,
|
||||
FileList,
|
||||
ToggleSwitch,
|
||||
LoadingSpinner,
|
||||
@@ -333,16 +334,6 @@ export default {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
mutations.showTooltip({
|
||||
content: text,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -358,6 +349,12 @@ export default {
|
||||
align-items: center;
|
||||
padding: 0.25em !important;
|
||||
gap: 0.5em;
|
||||
margin-top: 0.70rem;
|
||||
}
|
||||
|
||||
.path-picker,
|
||||
.banner-content {
|
||||
margin-bottom: 0.70rem;
|
||||
}
|
||||
|
||||
.cascade-toggle-section {
|
||||
|
||||
@@ -117,10 +117,7 @@
|
||||
<div v-if="!showMoreExpanded">
|
||||
<p>
|
||||
{{ $t("files.duration") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.shareDurationDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.shareDurationDescription')" />
|
||||
</p>
|
||||
<div class="form-flex-group">
|
||||
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="0"
|
||||
@@ -134,10 +131,7 @@
|
||||
</div>
|
||||
<p>
|
||||
{{ $t("prompts.optionalPassword") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.passwordDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.passwordDescription')" />
|
||||
</p>
|
||||
<div v-if="hasExistingPassword && !isChangingPassword" class="password-change-section">
|
||||
<button
|
||||
@@ -152,10 +146,7 @@
|
||||
<input v-else class="input" type="password" autocomplete="new-password" v-model.trim="password" />
|
||||
<p>
|
||||
{{ $t("share.shareType") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.shareTypeDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.shareTypeDescription')" />
|
||||
</p>
|
||||
<ExpandDropdown
|
||||
v-model="shareType"
|
||||
@@ -187,10 +178,7 @@
|
||||
<div class="settings-items">
|
||||
<p>
|
||||
{{ shareThemeLabel() }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.shareThemeDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.shareThemeDescription')" />
|
||||
</p>
|
||||
<div v-if="Object.keys(availableThemes).length > 0" class="form-flex-group">
|
||||
<ExpandDropdown
|
||||
@@ -202,10 +190,7 @@
|
||||
<div v-if="shareType === 'normal'">
|
||||
<p>
|
||||
{{ $t("share.defaultViewMode") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.defaultViewModeDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.defaultViewModeDescription')" />
|
||||
</p>
|
||||
<ExpandDropdown
|
||||
v-model="viewMode"
|
||||
@@ -238,11 +223,7 @@
|
||||
:disabled="sourceReadOnly" />
|
||||
<p>
|
||||
{{ $t("share.enforceDarkLightMode") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.enforceDarkLightModeDescription'))"
|
||||
@mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.enforceDarkLightModeDescription')" />
|
||||
</p>
|
||||
<ExpandDropdown
|
||||
v-model="enforceDarkLightMode"
|
||||
@@ -259,11 +240,7 @@
|
||||
<div>
|
||||
<p>
|
||||
{{ $t("profileSettings.hideFileExt") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('profileSettings.hideFileExtDescription'))"
|
||||
@mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('profileSettings.hideFileExtDescription')" />
|
||||
</p>
|
||||
<input class="input"
|
||||
:class="{ 'form-invalid': !validateExtensions(hideFileExt) }"
|
||||
@@ -289,18 +266,12 @@
|
||||
<div v-if="shareType === 'normal'">
|
||||
<p>
|
||||
{{ $t("prompts.downloadsLimit") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.downloadsLimitDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.downloadsLimitDescription')" />
|
||||
</p>
|
||||
<input class="input" type="number" min="0" v-model.number="downloadsLimit" />
|
||||
<p>
|
||||
{{ $t("prompts.maxBandwidth") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.maxBandwidthDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.maxBandwidthDescription')" />
|
||||
</p>
|
||||
<input class="input" type="number" min="0" v-model.number="maxBandwidth" />
|
||||
</div>
|
||||
@@ -308,37 +279,25 @@
|
||||
|
||||
<p>
|
||||
{{ $t("prompts.shareThemeColor") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.shareThemeColorDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.shareThemeColorDescription')" />
|
||||
</p>
|
||||
<input class="input" type="text" v-model.trim="themeColor" />
|
||||
|
||||
<p>
|
||||
{{ shareTitleLabel() }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.shareTitleDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.shareTitleDescription')" />
|
||||
</p>
|
||||
<input class="input" type="text" v-model.trim="title" />
|
||||
|
||||
<p>
|
||||
{{ $t("prompts.shareDescription") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.shareDescriptionHelp'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.shareDescriptionHelp')" />
|
||||
</p>
|
||||
<textarea class="input" v-model.trim="description"></textarea>
|
||||
|
||||
<p>
|
||||
{{ $t("prompts.shareBanner") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.shareBannerDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.shareBannerDescription')" />
|
||||
</p>
|
||||
<div class="file-picker-input-group">
|
||||
<input class="input file-picker-input" type="text" v-model.trim="banner" />
|
||||
@@ -349,10 +308,7 @@
|
||||
|
||||
<p>
|
||||
{{ $t("prompts.shareFavicon") }}
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.shareFaviconDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('share.shareFaviconDescription')" />
|
||||
</p>
|
||||
<div class="file-picker-input-group">
|
||||
<input class="input file-picker-input" type="text" v-model.trim="favicon" />
|
||||
@@ -395,6 +351,7 @@ import { shareApi } from "@/api";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import { fromNow } from "@/utils/moment";
|
||||
import { buildItemUrl } from "@/utils/url";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
import SettingsTable from "@/components/settings/Table.vue";
|
||||
@@ -409,6 +366,7 @@ import { eventBus } from "@/store/eventBus";
|
||||
export default {
|
||||
name: "share",
|
||||
components: {
|
||||
HelpTooltipIcon,
|
||||
ToggleSwitch,
|
||||
SettingsItem,
|
||||
SettingsTable,
|
||||
@@ -777,20 +735,6 @@ export default {
|
||||
async copyToClipboard(text) {
|
||||
await copyToClipboard(text);
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
* @param {string} text
|
||||
*/
|
||||
showTooltip(event, text) {
|
||||
mutations.showTooltip({
|
||||
content: text,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
},
|
||||
async submit() {
|
||||
if (this.hideFileExt !== "" && !this.validateExtensions(this.hideFileExt)) {
|
||||
notify.showError("Invalid input, does not match requirement.");
|
||||
|
||||
@@ -1002,6 +1002,7 @@ export default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Link item styles */
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
<div class="settings-number-input item">
|
||||
<div class="no-padding">
|
||||
<label for="maxConcurrentUpload">{{ $t("fileLoading.maxConcurrentUpload") }}</label>
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('fileLoading.maxConcurrentUploadHelp'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('fileLoading.maxConcurrentUploadHelp')" />
|
||||
</div>
|
||||
<div>
|
||||
<input v-model.number="maxConcurrentUpload" type="range" min="1" max="10" @change="updateUploadSettings" />
|
||||
@@ -20,10 +17,7 @@
|
||||
<div class="settings-number-input item">
|
||||
<div class="no-padding">
|
||||
<label for="uploadChunkSizeMb">{{ $t("fileLoading.uploadChunkSizeMb") }}</label>
|
||||
<i class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('fileLoading.uploadChunkSizeMbHelp'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('fileLoading.uploadChunkSizeMbHelp')" />
|
||||
</div>
|
||||
<div class="no-padding">
|
||||
<input class="sizeInput input" v-model.number="uploadChunkSizeMb" type="number" min="0" @change="updateUploadSettings" />
|
||||
@@ -189,6 +183,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { readAllDirectoryEntries, uploadManager } from "@/utils/upload";
|
||||
import { mutations, state } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import ProgressBar from "@/components/ProgressBar.vue";
|
||||
import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
@@ -198,6 +193,7 @@ import i18n from "@/i18n";
|
||||
export default {
|
||||
name: "UploadFiles",
|
||||
components: {
|
||||
HelpTooltipIcon,
|
||||
ProgressBar,
|
||||
SettingsItem,
|
||||
ToggleSwitch,
|
||||
@@ -254,18 +250,6 @@ export default {
|
||||
const uploadChunkSizeMb = ref(state.user.fileLoading?.uploadChunkSizeMb);
|
||||
const clearAll = ref(state.user.fileLoading?.clearAll || false);
|
||||
|
||||
const showTooltip = (event, text) => {
|
||||
mutations.showTooltip({
|
||||
content: text,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
mutations.hideTooltip();
|
||||
};
|
||||
|
||||
const updateUploadSettings = async () => {
|
||||
try {
|
||||
const data = {
|
||||
@@ -698,8 +682,6 @@ export default {
|
||||
maxConcurrentUpload,
|
||||
uploadChunkSizeMb,
|
||||
clearAll,
|
||||
showTooltip,
|
||||
hideTooltip,
|
||||
getStatusText,
|
||||
getHelpText,
|
||||
updateUploadSettings,
|
||||
|
||||
@@ -125,10 +125,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="stateUser.username !== user.username">
|
||||
<label for="locale">{{ $t("general.language") }}</label>
|
||||
<languages id="locale" v-model:locale="user.locale" @input="emitUpdate"></languages>
|
||||
</p>
|
||||
<div v-if="stateUser.permissions.admin">
|
||||
<label for="loginMethod">{{ $t("settings.loginMethod") }}</label>
|
||||
<ExpandDropdown
|
||||
@@ -141,12 +137,27 @@
|
||||
</div>
|
||||
|
||||
<UserDefaultsAccountSection
|
||||
v-if="stateUser.permissions.admin"
|
||||
v-if="stateUser.permissions.admin && loaded"
|
||||
:enforceable="false"
|
||||
:start-collapsed="false"
|
||||
:account="editAccount"
|
||||
:enforced="enforcedAccount"
|
||||
:enforced-permissions="enforcedAccountPermissions"
|
||||
respect-enforced-policy
|
||||
@account-change="onEditAccountChange"
|
||||
/>
|
||||
|
||||
<UserProfilePreferences
|
||||
v-if="stateUser.permissions.admin && loaded"
|
||||
:key="profileLoadKey"
|
||||
v-model="profileSections"
|
||||
:enforced="enforcedPreferences"
|
||||
:default-expanded-section="null"
|
||||
respect-enforced-policy
|
||||
show-extension-inputs
|
||||
:show-thumbnail-master="false"
|
||||
@change="onPreferenceChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,28 +179,66 @@
|
||||
<script>
|
||||
import { mutations, state } from "@/store";
|
||||
import { usersApi, settingsApi, authApi } from "@/api";
|
||||
import Languages from "@/components/settings/Languages.vue";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import SourceFilePermissions from "@/components/settings/SourceFilePermissions.vue";
|
||||
import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import UserDefaultsAccountSection from "@/components/settings/UserDefaultsAccountSection.vue";
|
||||
import UserProfilePreferences from "@/components/settings/UserProfilePreferences.vue";
|
||||
import Errors from "@/views/Errors.vue";
|
||||
import { notify } from "@/notify";
|
||||
import { validateLogin } from "@/utils/auth";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { setObjectProperty } from '@/utils/object.js';
|
||||
import {
|
||||
sectionsFromFlatUser,
|
||||
applySectionsToFlatUser,
|
||||
isFlatProfileFieldEnforced,
|
||||
} from "@/utils/userProfileSections.js";
|
||||
|
||||
/** Flat user fields editable via admin user edit (matches profile PATCH surface). */
|
||||
const PROFILE_SNAPSHOT_FIELDS = [
|
||||
"locale",
|
||||
"dateFormat",
|
||||
"themeColor",
|
||||
"customTheme",
|
||||
"quickDownload",
|
||||
"preview",
|
||||
"stickySidebar",
|
||||
"singleClick",
|
||||
"darkMode",
|
||||
"showHidden",
|
||||
"showToolsInSidebar",
|
||||
"fileLoading",
|
||||
"deleteAfterArchive",
|
||||
"deleteWithoutConfirming",
|
||||
"preferEditorForMarkdown",
|
||||
"disablePreviewExt",
|
||||
"disableViewingExt",
|
||||
"disableOnlyOfficeExt",
|
||||
"hideFileExt",
|
||||
"disableQuickToggles",
|
||||
"disableSearchOptions",
|
||||
"hideSidebarFileActions",
|
||||
"showCopyPath",
|
||||
"hideFilesInTree",
|
||||
"editorQuickSave",
|
||||
"showSelectMultiple",
|
||||
"debugOffice",
|
||||
"viewMode",
|
||||
"gallerySize",
|
||||
];
|
||||
|
||||
export default {
|
||||
name: "user-edit",
|
||||
components: {
|
||||
Languages,
|
||||
ExpandDropdown,
|
||||
SourceFilePermissions,
|
||||
SettingsItem,
|
||||
ToggleSwitch,
|
||||
UserDefaultsAccountSection,
|
||||
UserProfilePreferences,
|
||||
Errors,
|
||||
},
|
||||
props: {
|
||||
@@ -218,6 +267,8 @@ export default {
|
||||
showDelete: false,
|
||||
createUserDir: false,
|
||||
loaded: false,
|
||||
profileLoadKey: 0,
|
||||
profileUser: { preview: {}, permissions: {}, fileLoading: {} },
|
||||
originalUserScope: ".",
|
||||
sourceList: [],
|
||||
selectedSources: [],
|
||||
@@ -241,6 +292,7 @@ export default {
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
await mutations.syncEnforcedUserDefaults();
|
||||
await this.fetchData();
|
||||
await this.initializeForm();
|
||||
},
|
||||
@@ -374,6 +426,23 @@ export default {
|
||||
if (this.globalVars.ldapAvailable) return "ldap";
|
||||
return "password"; // fallback
|
||||
},
|
||||
profileSections: {
|
||||
get() {
|
||||
return sectionsFromFlatUser(this.profileUser);
|
||||
},
|
||||
set(sections) {
|
||||
applySectionsToFlatUser(this.profileUser, sections);
|
||||
},
|
||||
},
|
||||
enforcedPreferences() {
|
||||
return state.enforcedUserDefaults || {};
|
||||
},
|
||||
enforcedAccount() {
|
||||
return this.enforcedPreferences.account || {};
|
||||
},
|
||||
enforcedAccountPermissions() {
|
||||
return this.enforcedAccount.permissions || {};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
stateUser() {
|
||||
@@ -391,6 +460,28 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
cloneUserRecord(user) {
|
||||
return JSON.parse(
|
||||
JSON.stringify(user ?? { preview: {}, permissions: {}, fileLoading: {} })
|
||||
);
|
||||
},
|
||||
syncProfileUserFromFormUser() {
|
||||
const prepared = this.prepareProfileUser(this.cloneUserRecord(this.user));
|
||||
if (typeof prepared.showToolsInSidebar !== "boolean") {
|
||||
prepared.showToolsInSidebar = true;
|
||||
}
|
||||
this.profileUser = prepared;
|
||||
this.profileLoadKey += 1;
|
||||
},
|
||||
applyProfileUserToFormUser() {
|
||||
applySectionsToFlatUser(this.user, sectionsFromFlatUser(this.profileUser));
|
||||
},
|
||||
async fetchExistingUserRecord(username) {
|
||||
if (username === state.user?.username) {
|
||||
return this.cloneUserRecord(state.user);
|
||||
}
|
||||
return await usersApi.get(username);
|
||||
},
|
||||
defaultPermissions() {
|
||||
return {
|
||||
admin: false,
|
||||
@@ -461,8 +552,21 @@ export default {
|
||||
sourceBlockTitle(source) {
|
||||
return source?.name || "";
|
||||
},
|
||||
normalizeFormUser(raw) {
|
||||
normalizeFormUser(raw, { fromDefaultsTemplate = false } = {}) {
|
||||
const user = { ...(raw ?? {}) };
|
||||
// Flat API users always have `preview`; only merge nested defaults for template payloads.
|
||||
if (fromDefaultsTemplate) {
|
||||
applySectionsToFlatUser(user, {
|
||||
sidebar: user.sidebar || {},
|
||||
listing: user.listing || {},
|
||||
preview: user.preview || {},
|
||||
fileViewer: user.fileViewer || {},
|
||||
search: user.search || {},
|
||||
ui: user.ui || {},
|
||||
account: user.account || {},
|
||||
fileLoading: user.fileLoading || {},
|
||||
});
|
||||
}
|
||||
if (user.account && typeof user.account === "object") {
|
||||
const account = user.account;
|
||||
user.lockPassword = !!account.lockPassword;
|
||||
@@ -496,13 +600,23 @@ export default {
|
||||
delete user.account;
|
||||
delete user.sidebar;
|
||||
delete user.listing;
|
||||
delete user.preview;
|
||||
delete user.fileViewer;
|
||||
delete user.search;
|
||||
delete user.ui;
|
||||
delete user.fileLoading;
|
||||
user.scopes = this.normalizeScopesForForm(user.scopes, user.sourcePermissions);
|
||||
delete user.sourcePermissions;
|
||||
return this.prepareProfileUser(user);
|
||||
},
|
||||
prepareProfileUser(user) {
|
||||
if (!user.preview || typeof user.preview !== "object") {
|
||||
user.preview = {};
|
||||
}
|
||||
if (!user.fileLoading || typeof user.fileLoading !== "object") {
|
||||
user.fileLoading = {};
|
||||
}
|
||||
if (typeof user.showToolsInSidebar !== "boolean") {
|
||||
user.showToolsInSidebar = true;
|
||||
}
|
||||
return user;
|
||||
},
|
||||
normalizeScopesForForm(scopes, legacySourcePermissions) {
|
||||
@@ -542,7 +656,7 @@ export default {
|
||||
if (this.isNew) {
|
||||
const response = await settingsApi.getUserDefaults();
|
||||
const defaults = response?.values ?? response;
|
||||
this.user = this.normalizeFormUser(defaults);
|
||||
this.user = this.normalizeFormUser(defaults, { fromDefaultsTemplate: true });
|
||||
this.user.password = "";
|
||||
// Ensure loginMethod is valid, set to first available method if not set or invalid
|
||||
const validMethods = [];
|
||||
@@ -555,12 +669,13 @@ export default {
|
||||
if (!this.user.loginMethod || !validMethods.includes(this.user.loginMethod)) {
|
||||
this.user.loginMethod = this.firstAvailableLoginMethod;
|
||||
}
|
||||
this.syncProfileUserFromFormUser();
|
||||
} else {
|
||||
const uname = this.targetUsername;
|
||||
if (!uname) {
|
||||
return;
|
||||
}
|
||||
this.user = this.normalizeFormUser(await usersApi.get(uname));
|
||||
this.user = this.normalizeFormUser(await this.fetchExistingUserRecord(uname));
|
||||
this.user.password = "";
|
||||
// Normalize scopes to ensure they're in {name, scope} format only
|
||||
if (this.user.scopes && Array.isArray(this.user.scopes)) {
|
||||
@@ -577,12 +692,12 @@ export default {
|
||||
if (!this.user.loginMethod || !validMethods.includes(this.user.loginMethod)) {
|
||||
this.user.loginMethod = this.firstAvailableLoginMethod;
|
||||
}
|
||||
this.syncProfileUserFromFormUser();
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e;
|
||||
} finally {
|
||||
mutations.setLoading("users", false);
|
||||
this.loaded = true;
|
||||
// Update prompt name after user data is loaded
|
||||
this.updatePromptTitle();
|
||||
}
|
||||
@@ -617,6 +732,7 @@ export default {
|
||||
if (!this.isNew) {
|
||||
this.originalSnapshot = JSON.parse(JSON.stringify(this.buildEditableSnapshot()));
|
||||
}
|
||||
this.loaded = true;
|
||||
},
|
||||
buildScopesPayload() {
|
||||
return this.selectedSources.map((source) => ({
|
||||
@@ -634,13 +750,25 @@ export default {
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
buildProfileSnapshot(user) {
|
||||
const snapshot = {};
|
||||
for (const field of PROFILE_SNAPSHOT_FIELDS) {
|
||||
snapshot[field] = user?.[field];
|
||||
}
|
||||
if (snapshot.preview && typeof snapshot.preview === "object") {
|
||||
snapshot.preview = { ...snapshot.preview };
|
||||
}
|
||||
if (snapshot.fileLoading && typeof snapshot.fileLoading === "object") {
|
||||
snapshot.fileLoading = { ...snapshot.fileLoading };
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
buildEditableSnapshot() {
|
||||
this.applyEditAccountToUser();
|
||||
const permissions = this.user.permissions || this.defaultPermissions();
|
||||
return {
|
||||
scopes: this.normalizeScopesForCompare(this.buildScopesPayload()),
|
||||
loginMethod: this.user.loginMethod ?? null,
|
||||
locale: this.user.locale ?? "",
|
||||
otpEnabled: !!this.user.otpEnabled,
|
||||
lockPassword: !!this.user.lockPassword,
|
||||
disableSettings: !!this.user.disableSettings,
|
||||
@@ -651,6 +779,7 @@ export default {
|
||||
api: !!permissions.api,
|
||||
realtime: !!permissions.realtime,
|
||||
},
|
||||
profile: this.buildProfileSnapshot(this.profileUser),
|
||||
};
|
||||
},
|
||||
computeChangedFields() {
|
||||
@@ -666,23 +795,40 @@ export default {
|
||||
if (current.loginMethod !== orig.loginMethod) {
|
||||
fields.push("loginMethod");
|
||||
}
|
||||
if (current.locale !== orig.locale) {
|
||||
fields.push("locale");
|
||||
}
|
||||
if (current.otpEnabled !== orig.otpEnabled) {
|
||||
fields.push("otpEnabled");
|
||||
}
|
||||
if (current.lockPassword !== orig.lockPassword) {
|
||||
if (current.lockPassword !== orig.lockPassword && !this.enforcedAccount.lockPassword) {
|
||||
fields.push("lockPassword");
|
||||
}
|
||||
if (current.disableSettings !== orig.disableSettings) {
|
||||
if (current.disableSettings !== orig.disableSettings && !this.enforcedAccount.disableSettings) {
|
||||
fields.push("disableSettings");
|
||||
}
|
||||
if (current.disableUpdateNotifications !== orig.disableUpdateNotifications) {
|
||||
if (
|
||||
current.disableUpdateNotifications !== orig.disableUpdateNotifications
|
||||
&& !this.enforcedAccount.disableUpdateNotifications
|
||||
) {
|
||||
fields.push("disableUpdateNotifications");
|
||||
}
|
||||
if (JSON.stringify(current.permissions) !== JSON.stringify(orig.permissions)) {
|
||||
fields.push("permissions");
|
||||
const permissionFields = ["admin", "share", "api", "realtime"];
|
||||
for (const perm of permissionFields) {
|
||||
if (
|
||||
current.permissions[perm] !== orig.permissions[perm]
|
||||
&& !this.enforcedAccountPermissions[perm]
|
||||
) {
|
||||
fields.push("permissions");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const field of PROFILE_SNAPSHOT_FIELDS) {
|
||||
if (isFlatProfileFieldEnforced(this.enforcedPreferences, field)) {
|
||||
continue;
|
||||
}
|
||||
if (JSON.stringify(current.profile?.[field]) !== JSON.stringify(orig.profile?.[field])) {
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
},
|
||||
@@ -714,6 +860,9 @@ export default {
|
||||
this.applyEditAccountToUser();
|
||||
this.emitUpdate();
|
||||
},
|
||||
onPreferenceChange() {
|
||||
this.emitUpdate();
|
||||
},
|
||||
deletePrompt() {
|
||||
mutations.showPrompt({
|
||||
name: "generic",
|
||||
@@ -745,6 +894,9 @@ export default {
|
||||
async save(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
this.applyEditAccountToUser();
|
||||
this.applyProfileUserToFormUser();
|
||||
// Profile sections carry a stale account snapshot; restore admin-edited account fields.
|
||||
this.applyEditAccountToUser();
|
||||
const scopesToSend = this.buildScopesPayload();
|
||||
const payload = {
|
||||
@@ -960,6 +1112,10 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
label + .form-flex-group {
|
||||
margin-top: 0.35em;
|
||||
}
|
||||
|
||||
.scope-blocks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -989,8 +1145,9 @@ export default {
|
||||
|
||||
.scope-block :deep(.settings-group-title) {
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid var(--borderColor, #ddd);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--borderRadius, 4px);
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
|
||||
.scope-path-display {
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
<div class="setting-row item">
|
||||
<div class="setting-label">
|
||||
<label for="vis-bar-count">{{ $t("player.visualizer.barCount") }}</label>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('player.visualizer.barCountDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>help</i>
|
||||
<HelpTooltipIcon :text="$t('player.visualizer.barCountDescription')" />
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input
|
||||
@@ -24,11 +20,7 @@
|
||||
<div class="setting-row item">
|
||||
<div class="setting-label">
|
||||
<label id="vis-fft-size-label">{{ $t("player.visualizer.fftSize") }}</label>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('player.visualizer.fftSizeDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>help</i>
|
||||
<HelpTooltipIcon :text="$t('player.visualizer.fftSizeDescription')" />
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<ExpandDropdown
|
||||
@@ -41,11 +33,7 @@
|
||||
<div class="setting-row item">
|
||||
<div class="setting-label">
|
||||
<label id="vis-min-freq-label">{{ $t("player.visualizer.minFrequency") }}</label>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('player.visualizer.minFrequencyDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>help</i>
|
||||
<HelpTooltipIcon :text="$t('player.visualizer.minFrequencyDescription')" />
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<ExpandDropdown
|
||||
@@ -58,11 +46,7 @@
|
||||
<div class="setting-row item">
|
||||
<div class="setting-label">
|
||||
<label id="vis-max-freq-label">{{ $t("player.visualizer.maxFrequency") }}</label>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('player.visualizer.maxFrequencyDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>help</i>
|
||||
<HelpTooltipIcon :text="$t('player.visualizer.maxFrequencyDescription')" />
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<ExpandDropdown
|
||||
@@ -75,11 +59,7 @@
|
||||
<div class="setting-row slider-setting item">
|
||||
<div class="setting-label">
|
||||
<label for="vis-smoothing">{{ $t("player.visualizer.smoothing") }}</label>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('player.visualizer.smoothingDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>help</i>
|
||||
<HelpTooltipIcon :text="$t('player.visualizer.smoothingDescription')" />
|
||||
</div>
|
||||
<div class="setting-control slider-setting-value">
|
||||
<input id="vis-smoothing" type="range" min="0" max="1" step="0.01" v-model.number="smoothing" />
|
||||
@@ -89,11 +69,7 @@
|
||||
<div class="setting-row slider-setting item">
|
||||
<div class="setting-label">
|
||||
<label for="vis-min-db">{{ $t("player.visualizer.minDecibels") }}</label>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('player.visualizer.minDecibelsDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>help</i>
|
||||
<HelpTooltipIcon :text="$t('player.visualizer.minDecibelsDescription')" />
|
||||
</div>
|
||||
<div class="setting-control slider-setting-value">
|
||||
<input id="vis-min-db" type="range" min="-120" max="-60" step="1" v-model.number="minDecibels" />
|
||||
@@ -103,11 +79,7 @@
|
||||
<div class="setting-row slider-setting item">
|
||||
<div class="setting-label">
|
||||
<label for="vis-max-db">{{ $t("player.visualizer.maxDecibels") }}</label>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('player.visualizer.maxDecibelsDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>help</i>
|
||||
<HelpTooltipIcon :text="$t('player.visualizer.maxDecibelsDescription')" />
|
||||
</div>
|
||||
<div class="setting-control slider-setting-value">
|
||||
<input id="vis-max-db" type="range" min="-40" max="20" step="1" v-model.number="maxDecibels" />
|
||||
@@ -148,6 +120,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import { mutations } from "@/store";
|
||||
import { visualizerConfig, saveVisualizerConfig, resetVisualizerConfig } from "@/utils/visualizerConfig.js";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
@@ -159,7 +132,7 @@ function freqOptions(values) {
|
||||
|
||||
export default {
|
||||
name: "VisualizerSettings",
|
||||
components: { ExpandDropdown, ToggleSwitch },
|
||||
components: { HelpTooltipIcon, ExpandDropdown, ToggleSwitch },
|
||||
data() {
|
||||
return {
|
||||
fftSizeOptions: [1024, 2048, 4096, 8192, 16384, 32768].map((value) => ({ value, label: `${value}` })),
|
||||
@@ -215,16 +188,6 @@ export default {
|
||||
resetVisualizerConfig() {
|
||||
resetVisualizerConfig();
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
mutations.showTooltip({
|
||||
content: text,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
<div class="toggle-container" :class="{ disabled }">
|
||||
<div class="toggle-name-container">
|
||||
<span class="toggle-name">{{ name }}</span>
|
||||
<i
|
||||
v-if="description"
|
||||
class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon v-if="description" :text="description" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -24,10 +17,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mutations } from "@/store";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "SettingsButton",
|
||||
components: { HelpTooltipIcon },
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
@@ -47,20 +41,6 @@ export default {
|
||||
},
|
||||
},
|
||||
emits: ["click"],
|
||||
methods: {
|
||||
showTooltip(event) {
|
||||
if (this.description) {
|
||||
mutations.showTooltip({
|
||||
content: this.description,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
}
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ export default {
|
||||
background: var(--alt-background) !important;
|
||||
color: var(--textPrimary) !important;
|
||||
padding: 0.5em;
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.settings-group-title h3 {
|
||||
|
||||
@@ -1,89 +1,91 @@
|
||||
<template>
|
||||
<table
|
||||
class="settings-table border-radius"
|
||||
:class="{ 'settings-table--loading': loading }"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-busy="loading ? 'true' : undefined"
|
||||
>
|
||||
<thead>
|
||||
<tr v-if="headerTitle">
|
||||
<th
|
||||
:colspan="emptyColumnSpan"
|
||||
scope="col"
|
||||
class="settings-table__align-center settings-table__th--nosort settings-table__th--unified"
|
||||
>
|
||||
{{ headerTitle }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
scope="col"
|
||||
:class="[
|
||||
alignClass(column),
|
||||
headerSortClass(column),
|
||||
]"
|
||||
:aria-sort="ariaSortState(column)"
|
||||
@click="column.sortable === true ? toggleSort(column) : undefined"
|
||||
>
|
||||
{{ column.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="settings-table__loading-row">
|
||||
<td :colspan="emptyColumnSpan" class="settings-table__loading-cell">
|
||||
<div class="settings-table__loading-inner">
|
||||
<LoadingSpinner size="small" mode="placeholder" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr
|
||||
v-for="item in sortedItems"
|
||||
:key="resolvedKey(item)"
|
||||
:class="{ 'settings-table__row--clickable': rowClickable }"
|
||||
:tabindex="rowClickable ? 0 : undefined"
|
||||
:role="rowClickable ? 'button' : undefined"
|
||||
@click="onRowClick(item, $event)"
|
||||
@keydown.enter.prevent="onRowClick(item, $event)"
|
||||
@keydown.space.prevent="onRowClick(item, $event)"
|
||||
>
|
||||
<td
|
||||
<div class="settings-table-wrapper">
|
||||
<table
|
||||
class="settings-table border-radius"
|
||||
:class="{ 'settings-table--loading': loading }"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-busy="loading ? 'true' : undefined"
|
||||
>
|
||||
<thead>
|
||||
<tr v-if="headerTitle">
|
||||
<th
|
||||
:colspan="emptyColumnSpan"
|
||||
scope="col"
|
||||
class="settings-table__align-center settings-table__th--nosort settings-table__th--unified"
|
||||
>
|
||||
{{ headerTitle }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
scope="col"
|
||||
:class="[
|
||||
alignClass(column),
|
||||
column.narrow === true ? 'settings-table__td--narrow' : '',
|
||||
headerSortClass(column),
|
||||
]"
|
||||
:aria-sort="ariaSortState(column)"
|
||||
@click="column.sortable === true ? toggleSort(column) : undefined"
|
||||
>
|
||||
<slot
|
||||
:name="cellSlot(column.key)"
|
||||
:row="item"
|
||||
:value="cellValue(item, column)"
|
||||
:column="column"
|
||||
{{ column.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="settings-table__loading-row">
|
||||
<td :colspan="emptyColumnSpan" class="settings-table__loading-cell">
|
||||
<div class="settings-table__loading-inner">
|
||||
<LoadingSpinner size="small" mode="placeholder" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr
|
||||
v-for="item in sortedItems"
|
||||
:key="resolvedKey(item)"
|
||||
:class="{ 'settings-table__row--clickable': rowClickable }"
|
||||
:tabindex="rowClickable ? 0 : undefined"
|
||||
:role="rowClickable ? 'button' : undefined"
|
||||
@click="onRowClick(item, $event)"
|
||||
@keydown.enter.prevent="onRowClick(item, $event)"
|
||||
@keydown.space.prevent="onRowClick(item, $event)"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
alignClass(column),
|
||||
column.narrow === true ? 'settings-table__td--narrow' : '',
|
||||
]"
|
||||
>
|
||||
{{ renderCell(item, column) }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="sortedItems.length === 0">
|
||||
<td
|
||||
:colspan="emptyColumnSpan"
|
||||
class="settings-table__empty settings-table__empty-cell"
|
||||
>
|
||||
<slot name="empty">
|
||||
<h2 class="message settings-table__lonely">
|
||||
<i class="material-symbols-outlined" aria-hidden="true">sentiment_dissatisfied</i>
|
||||
<span>{{ lonelyCaption }}</span>
|
||||
</h2>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<slot
|
||||
:name="cellSlot(column.key)"
|
||||
:row="item"
|
||||
:value="cellValue(item, column)"
|
||||
:column="column"
|
||||
>
|
||||
{{ renderCell(item, column) }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="sortedItems.length === 0">
|
||||
<td
|
||||
:colspan="emptyColumnSpan"
|
||||
class="settings-table__empty settings-table__empty-cell"
|
||||
>
|
||||
<slot name="empty">
|
||||
<h2 class="message settings-table__lonely">
|
||||
<i class="material-symbols-outlined" aria-hidden="true">sentiment_dissatisfied</i>
|
||||
<span>{{ lonelyCaption }}</span>
|
||||
</h2>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -355,6 +357,11 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
/* Radius: separate .border-radius utility (_variables.css) so it stays easy to override */
|
||||
.settings-table-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.settings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -545,4 +552,3 @@ body.rtl .settings-table__align-center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,14 +13,7 @@
|
||||
>
|
||||
<div class="toggle-name-container">
|
||||
<span class="toggle-name">{{ name }}</span>
|
||||
<i
|
||||
v-if="description"
|
||||
class="material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon v-if="description" :text="description" />
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
@@ -55,12 +48,17 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mutations } from "@/store";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import {
|
||||
hideInteractiveTooltip,
|
||||
showHoverTooltip,
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
|
||||
let enforcedIdCounter = 0;
|
||||
|
||||
export default {
|
||||
name: "ToggleSwitch",
|
||||
components: { HelpTooltipIcon },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
@@ -125,37 +123,20 @@ export default {
|
||||
updateEnforced(event) {
|
||||
this.$emit("update:enforced", event.target.checked);
|
||||
},
|
||||
showTooltip(event) {
|
||||
if (this.description) {
|
||||
mutations.showTooltip({
|
||||
content: this.description,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
}
|
||||
},
|
||||
showValueRowTooltipIfNeeded(event) {
|
||||
if (!this.disabled) {
|
||||
return;
|
||||
}
|
||||
if (this.valueTooltip) {
|
||||
mutations.showTooltip({
|
||||
content: this.valueTooltip,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
showHoverTooltip(this.valueTooltip, event);
|
||||
return;
|
||||
}
|
||||
if (this.enforcementLocked) {
|
||||
mutations.showTooltip({
|
||||
content: this.$t("profileSettings.enforcedByAdmin"),
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
showHoverTooltip(this.$t("profileSettings.enforcedByAdmin"), event);
|
||||
}
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
hideInteractiveTooltip();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
v-model="account.lockPassword"
|
||||
@change="$emit('account-change', 'lockPassword')"
|
||||
@update:enforced="(v) => emitEnforced('lockPassword', v)"
|
||||
:disabled="isFieldLocked('lockPassword')"
|
||||
:value-tooltip="configLockTooltipForField('lockPassword')"
|
||||
:disabled="isFieldDisabled('lockPassword')"
|
||||
:value-tooltip="fieldDisabledTooltip('lockPassword')"
|
||||
:name="$t('settings.lockPassword')"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
@@ -19,8 +19,8 @@
|
||||
v-model="account.disableSettings"
|
||||
@change="$emit('account-change', 'disableSettings')"
|
||||
@update:enforced="(v) => emitEnforced('disableSettings', v)"
|
||||
:disabled="isFieldLocked('disableSettings')"
|
||||
:value-tooltip="configLockTooltipForField('disableSettings')"
|
||||
:disabled="isFieldDisabled('disableSettings')"
|
||||
:value-tooltip="fieldDisabledTooltip('disableSettings')"
|
||||
:name="$t('settings.disableUserSettings')"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
@@ -30,8 +30,8 @@
|
||||
v-model="account.disableUpdateNotifications"
|
||||
@change="$emit('account-change', 'disableUpdateNotifications')"
|
||||
@update:enforced="(v) => emitEnforced('disableUpdateNotifications', v)"
|
||||
:disabled="isFieldLocked('disableUpdateNotifications')"
|
||||
:value-tooltip="configLockTooltipForField('disableUpdateNotifications')"
|
||||
:disabled="isFieldDisabled('disableUpdateNotifications')"
|
||||
:value-tooltip="fieldDisabledTooltip('disableUpdateNotifications')"
|
||||
:name="$t('profileSettings.disableUpdateNotifications')"
|
||||
:description="$t('profileSettings.disableUpdateNotificationsDescription')"
|
||||
/>
|
||||
@@ -46,8 +46,8 @@
|
||||
v-model="account.permissions.admin"
|
||||
@change="$emit('account-change', 'permissions.admin')"
|
||||
@update:enforced="(v) => emitEnforcedPermission('admin', v)"
|
||||
:disabled="isPermissionLocked('admin')"
|
||||
:value-tooltip="configLockTooltipForPermission('admin')"
|
||||
:disabled="isPermissionDisabled('admin')"
|
||||
:value-tooltip="permissionDisabledTooltip('admin')"
|
||||
:name="$t('settings.permissions.admin')"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
@@ -57,8 +57,8 @@
|
||||
v-model="account.permissions.share"
|
||||
@change="$emit('account-change', 'permissions.share')"
|
||||
@update:enforced="(v) => emitEnforcedPermission('share', v)"
|
||||
:disabled="isPermissionLocked('share')"
|
||||
:value-tooltip="configLockTooltipForPermission('share')"
|
||||
:disabled="isPermissionDisabled('share')"
|
||||
:value-tooltip="permissionDisabledTooltip('share')"
|
||||
:name="$t('general.shareFiles')"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
@@ -68,8 +68,8 @@
|
||||
v-model="account.permissions.api"
|
||||
@change="$emit('account-change', 'permissions.api')"
|
||||
@update:enforced="(v) => emitEnforcedPermission('api', v)"
|
||||
:disabled="isPermissionLocked('api')"
|
||||
:value-tooltip="configLockTooltipForPermission('api')"
|
||||
:disabled="isPermissionDisabled('api')"
|
||||
:value-tooltip="permissionDisabledTooltip('api')"
|
||||
:name="$t('settings.permissions.api')"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
@@ -79,8 +79,8 @@
|
||||
v-model="account.permissions.realtime"
|
||||
@change="$emit('account-change', 'permissions.realtime')"
|
||||
@update:enforced="(v) => emitEnforcedPermission('realtime', v)"
|
||||
:disabled="isPermissionLocked('realtime')"
|
||||
:value-tooltip="configLockTooltipForPermission('realtime')"
|
||||
:disabled="isPermissionDisabled('realtime')"
|
||||
:value-tooltip="permissionDisabledTooltip('realtime')"
|
||||
:name="$t('settings.permissions.realtime')"
|
||||
/>
|
||||
</div>
|
||||
@@ -122,6 +122,10 @@ export default {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
respectEnforcedPolicy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["account-change", "enforced-change", "enforced-permission-change"],
|
||||
methods: {
|
||||
@@ -131,6 +135,30 @@ export default {
|
||||
isPermissionLocked(field) {
|
||||
return this.configLockedPaths.includes(`account.permissions.${field}`);
|
||||
},
|
||||
isFieldDisabled(field) {
|
||||
if (this.isFieldLocked(field)) {
|
||||
return true;
|
||||
}
|
||||
return this.respectEnforcedPolicy && !!this.enforced[field];
|
||||
},
|
||||
isPermissionDisabled(field) {
|
||||
if (this.isPermissionLocked(field)) {
|
||||
return true;
|
||||
}
|
||||
return this.respectEnforcedPolicy && !!this.enforcedPermissions[field];
|
||||
},
|
||||
fieldDisabledTooltip(field) {
|
||||
if (this.respectEnforcedPolicy && this.enforced[field]) {
|
||||
return this.$t("profileSettings.enforcedByAdmin");
|
||||
}
|
||||
return this.configLockTooltipForField(field);
|
||||
},
|
||||
permissionDisabledTooltip(field) {
|
||||
if (this.respectEnforcedPolicy && this.enforcedPermissions[field]) {
|
||||
return this.$t("profileSettings.enforcedByAdmin");
|
||||
}
|
||||
return this.configLockTooltipForPermission(field);
|
||||
},
|
||||
configLockTooltipForField(field) {
|
||||
if (this.isFieldLocked(field)) {
|
||||
return this.$t("settings.userDefaultFieldLockedFromConfig");
|
||||
|
||||
@@ -56,6 +56,62 @@
|
||||
:name="$t('profileSettings.deleteAfterArchive')"
|
||||
:description="$t('profileSettings.deleteAfterArchiveDescription')"
|
||||
/>
|
||||
<div
|
||||
class="preference-field-block"
|
||||
:class="{ 'preference-field-block--enforceable': enforceable }"
|
||||
>
|
||||
<div class="centered-with-tooltip">
|
||||
<h3>{{ $t("profileSettings.defaultViewMode") }}</h3>
|
||||
<HelpTooltipIcon :text="$t('profileSettings.defaultViewModeDescription')" />
|
||||
</div>
|
||||
<div
|
||||
@mouseenter="showEnforcedTooltipIfLocked($event, 'listing', 'viewMode')"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<ViewMode
|
||||
:view-mode="listingViewMode"
|
||||
:disabled="fieldDisabled('listing', 'viewMode')"
|
||||
@update:view-mode="onListingViewModeChange"
|
||||
/>
|
||||
</div>
|
||||
<ProfileEnforceSwitch
|
||||
:visible="enforceable"
|
||||
:enforced="enforcedFlag('listing', 'viewMode')"
|
||||
:disabled="disabled"
|
||||
@update:enforced="(v) => emitEnforced('listing', 'viewMode', v)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="preference-field-block"
|
||||
:class="{ 'preference-field-block--enforceable': enforceable }"
|
||||
>
|
||||
<div class="centered-with-tooltip">
|
||||
<h3>{{ $t("profileSettings.defaultGallerySize") }}</h3>
|
||||
<HelpTooltipIcon :text="$t('profileSettings.defaultGallerySizeDescription')" />
|
||||
</div>
|
||||
<div
|
||||
class="gallery-size-field"
|
||||
@mouseenter="showEnforcedTooltipIfLocked($event, 'listing', 'gallerySize')"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<span class="size-label">{{ $t("general.size") }}</span>
|
||||
<input
|
||||
v-model.number="listingGallerySize"
|
||||
type="range"
|
||||
min="1"
|
||||
max="9"
|
||||
:disabled="fieldDisabled('listing', 'gallerySize')"
|
||||
@change="() => emitSectionChange('listing', 'gallerySize')"
|
||||
/>
|
||||
<span class="size-value">{{ listingGallerySize }}</span>
|
||||
</div>
|
||||
<ProfileEnforceSwitch
|
||||
:visible="enforceable"
|
||||
:enforced="enforcedFlag('listing', 'gallerySize')"
|
||||
:disabled="disabled"
|
||||
@update:enforced="(v) => emitEnforced('listing', 'gallerySize', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="showExtensionInputs">
|
||||
<div
|
||||
@@ -64,13 +120,7 @@
|
||||
>
|
||||
<div class="centered-with-tooltip">
|
||||
<h3>{{ $t("profileSettings.hideFileExt") }}</h3>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showFieldHelp($event, 'listing', 'hideFileExt', $t('profileSettings.hideFileExtDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('profileSettings.hideFileExtDescription')" />
|
||||
</div>
|
||||
<div
|
||||
class="form-flex-group"
|
||||
@@ -187,13 +237,7 @@
|
||||
>
|
||||
<div class="centered-with-tooltip">
|
||||
<h3>{{ $t("profileSettings.disableThumbnailPreviews") }}</h3>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showFieldHelp($event, 'preview', 'disablePreviewExt', $t('profileSettings.disableThumbnailPreviewsDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('profileSettings.disableThumbnailPreviewsDescription')" />
|
||||
</div>
|
||||
<div
|
||||
class="form-flex-group"
|
||||
@@ -340,13 +384,7 @@
|
||||
>
|
||||
<div class="centered-with-tooltip">
|
||||
<h3>{{ $t("profileSettings.disableViewingFiles") }}</h3>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showFieldHelp($event, 'fileViewer', 'disableViewingExt', $t('profileSettings.disableViewingFilesDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('profileSettings.disableViewingFilesDescription')" />
|
||||
</div>
|
||||
<div
|
||||
class="form-flex-group"
|
||||
@@ -384,13 +422,7 @@
|
||||
>
|
||||
<div class="centered-with-tooltip">
|
||||
<h3>{{ $t("profileSettings.disableOfficeEditor") }}</h3>
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showFieldHelp($event, 'fileViewer', 'disableOnlyOfficeExt', $t('profileSettings.disableOfficeEditorDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
help
|
||||
</i>
|
||||
<HelpTooltipIcon :text="$t('profileSettings.disableOfficeEditorDescription')" />
|
||||
</div>
|
||||
<div
|
||||
class="form-flex-group"
|
||||
@@ -552,8 +584,13 @@
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { globalVars } from "@/utils/constants.js";
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import {
|
||||
hideInteractiveTooltip,
|
||||
showHoverTooltip,
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { getObjectProperty, setObjectProperty } from "@/utils/object.js";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import ProfilePreferenceToggle from "@/components/settings/ProfilePreferenceToggle.vue";
|
||||
import ProfileEnforceSwitch from "@/components/settings/ProfileEnforceSwitch.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
@@ -561,10 +598,12 @@ import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
import Languages from "@/components/settings/Languages.vue";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import ButtonGroup from "@/components/ButtonGroup.vue";
|
||||
import ViewMode from "@/components/settings/ViewMode.vue";
|
||||
|
||||
export default {
|
||||
name: "UserProfilePreferences",
|
||||
components: {
|
||||
HelpTooltipIcon,
|
||||
ToggleSwitch,
|
||||
SettingsItem,
|
||||
Languages,
|
||||
@@ -572,6 +611,7 @@ export default {
|
||||
ButtonGroup,
|
||||
ProfilePreferenceToggle,
|
||||
ProfileEnforceSwitch,
|
||||
ViewMode,
|
||||
},
|
||||
provide() {
|
||||
return { profilePrefs: this };
|
||||
@@ -605,11 +645,21 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** When true, enforced policy disables fields even for admin actors (e.g. admin user edit). */
|
||||
respectEnforcedPolicy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** Which settings group is expanded initially; null collapses all. */
|
||||
defaultExpandedSection: {
|
||||
type: String,
|
||||
default: "listingOptions",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "change", "enforced-change", "theme-color", "locale-change"],
|
||||
data() {
|
||||
return {
|
||||
expandedSection: "listingOptions",
|
||||
expandedSection: null,
|
||||
formDisablePreviews: "",
|
||||
formDisabledViewing: "",
|
||||
formDisableOfficeViewing: "",
|
||||
@@ -723,6 +773,19 @@ export default {
|
||||
this.sections = next;
|
||||
},
|
||||
},
|
||||
listingViewMode() {
|
||||
return this.sections.listing?.viewMode || "normal";
|
||||
},
|
||||
listingGallerySize: {
|
||||
get() {
|
||||
const size = this.sections.listing?.gallerySize;
|
||||
return typeof size === "number" ? size : 3;
|
||||
},
|
||||
set(value) {
|
||||
const nextSize = Math.min(9, Math.max(1, Number(value) || 3));
|
||||
this.setSectionBool("listing", "gallerySize", nextSize);
|
||||
},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
@@ -732,6 +795,9 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.expandedSection = this.defaultExpandedSection || null;
|
||||
},
|
||||
mounted() {
|
||||
this.syncExtensionFormsFromSections();
|
||||
},
|
||||
@@ -771,7 +837,7 @@ export default {
|
||||
return this.configLockedPaths.includes(this.configLockedPath(section, field));
|
||||
},
|
||||
fieldLocked(section, field) {
|
||||
if (getters.isAdmin()) {
|
||||
if (getters.isAdmin() && !this.respectEnforcedPolicy) {
|
||||
return false;
|
||||
}
|
||||
if (this.isConfigLocked(section, field)) {
|
||||
@@ -807,22 +873,25 @@ export default {
|
||||
return "";
|
||||
},
|
||||
isEnforcementLocked(section, field) {
|
||||
if (getters.isAdmin() || this.enforceable) {
|
||||
if (this.enforceable) {
|
||||
return false;
|
||||
}
|
||||
if (this.isConfigLocked(section, field)) {
|
||||
return false;
|
||||
}
|
||||
if (this.respectEnforcedPolicy && this.enforcedFlag(section, field)) {
|
||||
return true;
|
||||
}
|
||||
if (getters.isAdmin()) {
|
||||
return false;
|
||||
}
|
||||
return this.enforcedFlag(section, field);
|
||||
},
|
||||
showEnforcedTooltipIfLocked(event, section, field) {
|
||||
if (!this.fieldDisabled(section, field) || !this.isEnforcementLocked(section, field)) {
|
||||
return;
|
||||
}
|
||||
this.showTooltip(event, this.$t("profileSettings.enforcedByAdmin"));
|
||||
},
|
||||
showFieldHelp(event, section, field, description) {
|
||||
this.showTooltip(event, this.helpText(section, field, description));
|
||||
showHoverTooltip(this.$t("profileSettings.enforcedByAdmin"), event);
|
||||
},
|
||||
sectionBool(section, field) {
|
||||
if (section === "account" && field.includes(".")) {
|
||||
@@ -913,15 +982,12 @@ export default {
|
||||
this.$emit("locale-change", locale);
|
||||
this.emitSectionChange("ui", "locale");
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
mutations.showTooltip({
|
||||
content: text,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
onListingViewModeChange(viewMode) {
|
||||
this.setSectionBool("listing", "viewMode", viewMode);
|
||||
this.emitSectionChange("listing", "viewMode");
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
hideInteractiveTooltip();
|
||||
},
|
||||
validateExtensions(value) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
@@ -1001,4 +1067,16 @@ export default {
|
||||
border-radius: var(--borderRadius);
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.gallery-size-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
}
|
||||
.gallery-size-field input[type="range"] {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.gallery-size-field .size-value {
|
||||
min-width: 1.25em;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
<ExpandDropdown
|
||||
:model-value="viewMode"
|
||||
:options="viewModeOptions"
|
||||
:disabled="disabled"
|
||||
:aria-label="$t('buttons.normalView')"
|
||||
@update:model-value="$emit('update:viewMode', $event)"
|
||||
/>
|
||||
@@ -17,7 +18,16 @@ export default {
|
||||
components: {
|
||||
ExpandDropdown,
|
||||
},
|
||||
props: ["viewMode"],
|
||||
props: {
|
||||
viewMode: {
|
||||
type: String,
|
||||
default: "normal",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:viewMode"],
|
||||
computed: {
|
||||
viewModeOptions() {
|
||||
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user