mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 13:12:00 -05:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79bbda72dd | ||
|
|
8493fe582b | ||
|
|
a615ba7814 | ||
|
|
a2fb87a79c | ||
|
|
b9679d03a3 | ||
|
|
4b9f03ad35 | ||
|
|
4b465f5855 | ||
|
|
0d68ad5d7c | ||
|
|
d417da2b83 | ||
|
|
ba04611565 | ||
|
|
0a2265980e | ||
|
|
1d08a4549b | ||
|
|
b12add4317 | ||
|
|
5610cdd2bf |
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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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,39 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v2.0.6
|
||||
|
||||
**Security**:
|
||||
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
|
||||
|
||||
**Notes**:
|
||||
- renaming sources updates sidebar links (#2878)
|
||||
- disabling/deleting a user source removes that source from sidebar links (#2942)
|
||||
|
||||
**Bugfixes**:
|
||||
- Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2894)
|
||||
- Support non-ASCII share passwords (#2933)
|
||||
- Fall back to buffered copies when FUSE rejects fast paths (#2938) (#2924)
|
||||
- Preserve deleted sidebar links across restarts (#2935)
|
||||
|
||||
## v2.0.5
|
||||
|
||||
**New Features**:
|
||||
- Users can set default view mode and thumbnail size from Profile settings (Listing options). Admins can set the same defaults for existing users from the user edit prompt. Values persist to the account and apply on new browsers or devices; they do not override view preferences already saved locally ([#2884](https://github.com/gtsteffaniak/filebrowser/issues/2884)).
|
||||
- Admins can edit all profile preference defaults for existing users from the user management panel (listing, preview, sidebar, theme, and related options), matching what was previously available only for new users and global user defaults.
|
||||
|
||||
**Security**:
|
||||
- [Moderate] public metadata api returns file content to anonymous share visitors, ignoring the share's download limit and file-viewer setting (GHSA-55mw-cwg7-m8f5) -- thanks @kta1kri
|
||||
|
||||
**Bugfixes**:
|
||||
- Fixed infinite loading spinner on `/` and `/login` after CSP security hardening ([#2886](https://github.com/gtsteffaniak/filebrowser/issues/2886), [#2890](https://github.com/gtsteffaniak/filebrowser/issues/2890))
|
||||
- fixed darkmode styling bug where background wouldn't respect configured settings when not logged in
|
||||
- fixed styling bug where padding was missing from right side of listing view.
|
||||
- tilde paths for sources were not properly expanded. (eg "~" for home)
|
||||
- fixed tooltip not showing up for some circumstances on mobile, improved consistency.
|
||||
- a few styling fixes for inconsistencies (#2908)
|
||||
- Disk-usage widget no longer reports PB/TB-scale garbage on Docker Desktop for Mac (virtiofs) bind mounts; space is computed from the filesystem fragment size (`f_frsize`) instead of the optimal-transfer block size (`f_bsize`) on Linux ([#2894](https://github.com/gtsteffaniak/filebrowser/issues/2894)).
|
||||
|
||||
## v2.0.4
|
||||
|
||||
**Bugfixes**:
|
||||
|
||||
+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",
|
||||
|
||||
+10
-20
@@ -7,10 +7,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,8 @@ func validateUserInfo(newDB bool) {
|
||||
changedFields := make([]string, 0, 8)
|
||||
changePass := false
|
||||
|
||||
if updateUserScopes(user) {
|
||||
scopesChanged := updateUserScopes(user)
|
||||
if scopesChanged {
|
||||
changedFields = append(changedFields, "backendScopes")
|
||||
}
|
||||
if updatePermissions(user) {
|
||||
@@ -45,7 +46,7 @@ func validateUserInfo(newDB bool) {
|
||||
if updateShowFirstLogin(user) {
|
||||
changedFields = append(changedFields, "showFirstLogin")
|
||||
}
|
||||
if updateSidebarLinks(user) {
|
||||
if updateSidebarLinks(user, scopesChanged) {
|
||||
changedFields = append(changedFields, "sidebarLinks")
|
||||
}
|
||||
if updateTokens(user) {
|
||||
@@ -232,28 +233,17 @@ func updatePreviewSettings(user *users.User) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
|
||||
func updateSidebarLinks(user *users.User) bool {
|
||||
needsEnsure := usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
|
||||
validBefore := usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks)
|
||||
|
||||
links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
|
||||
// updateSidebarLinks normalizes saved links and adds defaults for new or changed scopes.
|
||||
func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
|
||||
links, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks)
|
||||
if scopesChanged || user.SidebarLinks == nil {
|
||||
links, changed = usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
|
||||
}
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) {
|
||||
|
||||
updateUserScopes(graham)
|
||||
updateSourcePermissions(graham)
|
||||
updateSidebarLinks(graham)
|
||||
updateSidebarLinks(graham, true)
|
||||
|
||||
foundScope := false
|
||||
for _, scope := range graham.BackendScopes {
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestUpdateSidebarLinks_dedupesDuplicateMigratedLinks(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user) {
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user) {
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
@@ -77,3 +77,37 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
|
||||
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSidebarLinks_keepsDeletedSourceLinkAbsent(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
{Name: "playwright + files", Category: "source", SourceName: fixturePlaywrightSource, Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: fixtureDockerSource, Target: "/"},
|
||||
},
|
||||
},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: fixturePlaywrightSource, Scope: "/"},
|
||||
{Path: fixtureDockerSource, Scope: "/"},
|
||||
{Path: fixtureAccessSource, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected default source names to be blanked on normalize")
|
||||
}
|
||||
if len(user.SidebarLinks) != 2 {
|
||||
t.Fatalf("got %d sidebar links, want 2: %v", len(user.SidebarLinks), user.SidebarLinks)
|
||||
}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if link.Name != "" {
|
||||
t.Fatalf("expected blank default name, got %#v", link)
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-35
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -126,8 +128,7 @@ func copySingleFile(source, dest string) error {
|
||||
defer dst.Close()
|
||||
|
||||
// Copy the contents of the file.
|
||||
_, err = io.Copy(dst, src)
|
||||
if err != nil {
|
||||
if err = copyFileContents(dst, src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -146,6 +147,18 @@ func copySingleFile(source, dest string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFileContents(dst io.Writer, src io.Reader) error {
|
||||
_, err := io.Copy(dst, src)
|
||||
if !errors.Is(err, syscall.EBADF) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some FUSE filesystems reject copy_file_range. Hide the optional fast-path
|
||||
// methods so io.Copy retries from the current offsets with buffered I/O.
|
||||
_, err = io.Copy(struct{ io.Writer }{dst}, struct{ io.Reader }{src})
|
||||
return err
|
||||
}
|
||||
|
||||
// copyDirectory handles copying directories recursively.
|
||||
func copyDirectory(source, dest string) error {
|
||||
srcInfo, err := os.Stat(source)
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type copyFileRangeRejectingWriter struct {
|
||||
bytes.Buffer
|
||||
readFromCalls int
|
||||
}
|
||||
|
||||
func (w *copyFileRangeRejectingWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
w.readFromCalls++
|
||||
buf := make([]byte, 4)
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
_, _ = w.Write(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
return int64(n), err
|
||||
}
|
||||
return int64(n), syscall.EBADF
|
||||
}
|
||||
|
||||
func TestUnixModeToFileMode_setgid(t *testing.T) {
|
||||
m := unixModeToFileMode(0o2770)
|
||||
if got := m.Perm(); got != 0o770 {
|
||||
@@ -117,3 +138,19 @@ func TestCopyFilePreservesModTime(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
|
||||
const content = "complete file contents"
|
||||
src := struct{ io.Reader }{bytes.NewBufferString(content)}
|
||||
dst := ©FileRangeRejectingWriter{}
|
||||
|
||||
if err := copyFileContents(dst, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dst.String(); got != content {
|
||||
t.Fatalf("copied contents = %q, want %q", got, content)
|
||||
}
|
||||
if dst.readFromCalls != 1 {
|
||||
t.Fatalf("ReadFrom calls = %d, want 1", dst.readFromCalls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//go:build linux
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func blockSize(stat *syscall.Statfs_t) uint64 {
|
||||
if stat.Frsize > 0 {
|
||||
return uint64(stat.Frsize)
|
||||
}
|
||||
return uint64(stat.Bsize)
|
||||
}
|
||||
|
||||
// GetPartitionSize returns the filesystem size for Linux systems
|
||||
func GetPartitionSize(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(stat.Blocks) * blockSize(&stat), nil
|
||||
}
|
||||
|
||||
// GetFreeSpace returns the available free space for Linux systems
|
||||
func GetFreeSpace(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(stat.Bavail) * blockSize(&stat), nil
|
||||
}
|
||||
|
||||
// GetPartitionUsed returns the used space for Linux systems (total - free)
|
||||
func GetPartitionUsed(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
bsize := blockSize(&stat)
|
||||
total := uint64(stat.Blocks) * bsize
|
||||
free := uint64(stat.Bavail) * bsize
|
||||
return total - free, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//go:build linux
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBlockSizePrefersFrsize(t *testing.T) {
|
||||
// virtiofs (Docker Desktop for Mac) reports Bsize as the 1 MiB optimal
|
||||
// transfer size while Blocks are still counted in the 4 KiB fragment unit.
|
||||
stat := &syscall.Statfs_t{
|
||||
Bsize: 1048576,
|
||||
Frsize: 4096,
|
||||
Blocks: 1465121792,
|
||||
Bavail: 1054717543,
|
||||
}
|
||||
|
||||
got := blockSize(stat)
|
||||
if got != 4096 {
|
||||
t.Fatalf("blockSize = %d, want 4096 (Frsize)", got)
|
||||
}
|
||||
|
||||
total := uint64(stat.Blocks) * blockSize(stat)
|
||||
const wantTotal = 1465121792 * 4096 // ~5.5 TiB, matches df
|
||||
if total != wantTotal {
|
||||
t.Fatalf("total = %d bytes, want %d (~5.5 TiB)", total, uint64(wantTotal))
|
||||
}
|
||||
// Sanity: the old Bsize-based math overstates by exactly 256x.
|
||||
if inflated := uint64(stat.Blocks) * uint64(stat.Bsize); inflated != total*256 {
|
||||
t.Fatalf("expected 256x inflation from Bsize, got %d vs %d", inflated, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockSizeFallsBackToBsize(t *testing.T) {
|
||||
// Normal filesystems report Frsize == Bsize (or leave Frsize unset on
|
||||
// exotic drivers); either way the accounting unit is recovered.
|
||||
stat := &syscall.Statfs_t{Bsize: 4096, Frsize: 4096}
|
||||
if got := blockSize(stat); got != 4096 {
|
||||
t.Fatalf("blockSize = %d, want 4096", got)
|
||||
}
|
||||
|
||||
zero := &syscall.Statfs_t{Bsize: 4096, Frsize: 0}
|
||||
if got := blockSize(zero); got != 4096 {
|
||||
t.Fatalf("blockSize with Frsize=0 = %d, want 4096 (Bsize fallback)", got)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package fileutils
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package share
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
@@ -62,31 +61,34 @@ func prepForFrontendOne(link *Share, viewer *users.User, r *http.Request, public
|
||||
}
|
||||
out.SingleFileShare = snap.IsSingleFileShare()
|
||||
if r != nil && publicHost != "" {
|
||||
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, snap.Token)
|
||||
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, snap.Token)
|
||||
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, out.HasPassword)
|
||||
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, out.HasPassword)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// PublicShareURL builds share/download URLs using pre-resolved host and scheme (see web/proxy.go).
|
||||
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, token string) string {
|
||||
tokenParam := ""
|
||||
if token != "" && isDirectDownload {
|
||||
tokenParam = fmt.Sprintf("&token=%s", url.QueryEscape(token))
|
||||
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, hasPassword bool) string {
|
||||
if isDirectDownload && hasPassword {
|
||||
if settings.Config.Http.ExternalUrl != "" {
|
||||
return fmt.Sprintf("%s%spublic/share/%s?download=true",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%spublic/share/%s?download=true", scheme, host, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
|
||||
if settings.Config.Http.ExternalUrl != "" {
|
||||
if isDirectDownload {
|
||||
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash, tokenParam)
|
||||
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
return fmt.Sprintf("%s%spublic/share/%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
|
||||
if isDirectDownload {
|
||||
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s",
|
||||
scheme, host, settings.Config.Http.BaseURL, hash, tokenParam)
|
||||
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s",
|
||||
scheme, host, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestPublicShareURLPasswordProtectedDirectLink(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.BaseURL = "/files/"
|
||||
|
||||
got := PublicShareURL("example.com", "https", "abc123", true, true)
|
||||
want := "https://example.com/files/public/share/abc123?download=true"
|
||||
if got != want {
|
||||
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicShareURLDirectDownloadWithoutPassword(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.BaseURL = "/files/"
|
||||
|
||||
got := PublicShareURL("example.com", "https", "abc123", true, false)
|
||||
want := "https://example.com/files/public/api/resources/download?hash=abc123"
|
||||
if got != want {
|
||||
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,6 @@ type Share struct {
|
||||
ShareColumns
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
UserID uint64 `json:"userID,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
UserDownloads map[string]int `json:"userDownloads,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
|
||||
@@ -5,9 +5,8 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// currentSchemaVersion is the SQLite schema marker for this codebase (version 1).
|
||||
// BoltDB has no schema_version; importing via cmd/migrate builds this SQLite shape directly.
|
||||
const currentSchemaVersion = 1
|
||||
// currentSchemaVersion is the SQLite schema marker for this codebase.
|
||||
const currentSchemaVersion = 2
|
||||
|
||||
// Schema creates all tables for the SQLite database
|
||||
func createSchema(db *sql.DB) error {
|
||||
@@ -39,7 +38,6 @@ func createSchema(db *sql.DB) error {
|
||||
expire INTEGER NOT NULL DEFAULT 0,
|
||||
downloads INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
token TEXT,
|
||||
user_downloads TEXT,
|
||||
share_settings TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 0
|
||||
@@ -183,6 +181,10 @@ func runMigrations(db *sql.DB, fromVersion int) error {
|
||||
switch v {
|
||||
case 1:
|
||||
// Canonical schema is createSchema + Bolt import; no step migrations.
|
||||
case 2:
|
||||
if err := normalizeLegacyShareTokens(db); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown schema version: %d", v)
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@ func scanShareUserID(s string, dest *uint64) error {
|
||||
|
||||
// GetShareByHash retrieves a share by hash
|
||||
func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE hash = ?`
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE hash = ?`
|
||||
|
||||
var link share.Share
|
||||
var userIDStr string
|
||||
@@ -56,7 +54,6 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
|
||||
&link.Expire,
|
||||
&link.Downloads,
|
||||
&link.PasswordHash,
|
||||
&link.Token,
|
||||
&userDownloadsJSON,
|
||||
&shareSettingsJSON,
|
||||
&link.Version,
|
||||
@@ -87,9 +84,7 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
|
||||
// GetSharesByUserID retrieves all non-expired shares for an owner user id.
|
||||
func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
|
||||
now := time.Now().Unix()
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
|
||||
ORDER BY path`
|
||||
|
||||
rows, err := s.db.Query(query, shareUserIDDB(userID), now)
|
||||
@@ -103,9 +98,7 @@ func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
|
||||
|
||||
// GetSharesBySourcePath retrieves shares for a specific source and path
|
||||
func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, error) {
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE source = ? AND path = ? ORDER BY hash`
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? ORDER BY hash`
|
||||
|
||||
rows, err := s.db.Query(query, source, path)
|
||||
if err != nil {
|
||||
@@ -119,9 +112,7 @@ func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, e
|
||||
// GetSharesBySourcePathUser retrieves shares for a specific source, path, and owner user id.
|
||||
func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64) ([]*share.Share, error) {
|
||||
now := time.Now().Unix()
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE source = ? AND path = ? AND user_id = ?
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ?
|
||||
AND (expire = 0 OR expire > ?) ORDER BY hash`
|
||||
|
||||
rows, err := s.db.Query(query, source, path, shareUserIDDB(userID), now)
|
||||
@@ -135,9 +126,7 @@ func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64)
|
||||
|
||||
// GetPermanentShare retrieves a permanent share (expire = 0) for source, path, and owner.
|
||||
func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share.Share, error) {
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
|
||||
LIMIT 1`
|
||||
|
||||
var link share.Share
|
||||
@@ -152,7 +141,6 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
|
||||
&link.Expire,
|
||||
&link.Downloads,
|
||||
&link.PasswordHash,
|
||||
&link.Token,
|
||||
&userDownloadsJSON,
|
||||
&shareSettingsJSON,
|
||||
&link.Version,
|
||||
@@ -183,9 +171,7 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
|
||||
// ListAllShares retrieves all non-expired shares
|
||||
func (s *SQLStore) ListAllShares() ([]*share.Share, error) {
|
||||
now := time.Now().Unix()
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
|
||||
|
||||
rows, err := s.db.Query(query, now)
|
||||
if err != nil {
|
||||
@@ -207,24 +193,59 @@ func (s *SQLStore) SaveShare(link *share.Share) error {
|
||||
return fmt.Errorf("failed to marshal share settings: %w", err)
|
||||
}
|
||||
|
||||
query := `INSERT OR REPLACE INTO shares
|
||||
hasLegacyToken, err := s.sharesHasLegacyTokenColumn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to inspect shares schema: %w", err)
|
||||
}
|
||||
|
||||
legacyToken := ""
|
||||
if hasLegacyToken {
|
||||
legacyToken, err = s.legacyShareToken(link.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var query string
|
||||
var args []any
|
||||
if hasLegacyToken {
|
||||
query = `INSERT OR REPLACE INTO shares
|
||||
(hash, user_id, source, path, expire, downloads, password_hash,
|
||||
token, user_downloads, share_settings, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
args = []any{
|
||||
link.Hash,
|
||||
shareUserIDDB(link.UserID),
|
||||
link.SourcePath,
|
||||
link.Path,
|
||||
link.Expire,
|
||||
link.Downloads,
|
||||
link.PasswordHash,
|
||||
legacyToken,
|
||||
userDownloadsJSON,
|
||||
shareSettingsJSON,
|
||||
link.Version,
|
||||
}
|
||||
} else {
|
||||
query = `INSERT OR REPLACE INTO shares
|
||||
(hash, user_id, source, path, expire, downloads, password_hash,
|
||||
user_downloads, share_settings, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
args = []any{
|
||||
link.Hash,
|
||||
shareUserIDDB(link.UserID),
|
||||
link.SourcePath,
|
||||
link.Path,
|
||||
link.Expire,
|
||||
link.Downloads,
|
||||
link.PasswordHash,
|
||||
userDownloadsJSON,
|
||||
shareSettingsJSON,
|
||||
link.Version,
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(query,
|
||||
link.Hash,
|
||||
shareUserIDDB(link.UserID),
|
||||
link.SourcePath,
|
||||
link.Path,
|
||||
link.Expire,
|
||||
link.Downloads,
|
||||
link.PasswordHash,
|
||||
link.Token,
|
||||
userDownloadsJSON,
|
||||
shareSettingsJSON,
|
||||
link.Version,
|
||||
)
|
||||
_, err = s.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save share: %w", err)
|
||||
}
|
||||
@@ -299,7 +320,6 @@ func (s *SQLStore) scanShares(rows *sql.Rows) ([]*share.Share, error) {
|
||||
&link.Expire,
|
||||
&link.Downloads,
|
||||
&link.PasswordHash,
|
||||
&link.Token,
|
||||
&userDownloadsJSON,
|
||||
&shareSettingsJSON,
|
||||
&link.Version,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const shareSelectColumns = `hash, user_id, source, path, expire, downloads,
|
||||
password_hash, user_downloads, share_settings, version`
|
||||
|
||||
func sharesTableHasColumn(db *sql.DB, column string) (bool, error) {
|
||||
rows, err := db.Query(`PRAGMA table_info(shares)`)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read shares schema: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
colType string
|
||||
notnull int
|
||||
dfltValue sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &colType, ¬null, &dfltValue, &pk); err != nil {
|
||||
return false, fmt.Errorf("scan shares schema: %w", err)
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, fmt.Errorf("iterate shares schema: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// normalizeLegacyShareTokens keeps rollback compatibility with releases that still
|
||||
// scan shares.token into a Go string (NULL is unsupported there).
|
||||
func normalizeLegacyShareTokens(db *sql.DB) error {
|
||||
hasToken, err := sharesTableHasColumn(db, "token")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasToken {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE shares SET token = '' WHERE token IS NULL`); err != nil {
|
||||
return fmt.Errorf("normalize legacy share tokens: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) sharesHasLegacyTokenColumn() (bool, error) {
|
||||
return sharesTableHasColumn(s.db, "token")
|
||||
}
|
||||
|
||||
func (s *SQLStore) legacyShareToken(hash string) (string, error) {
|
||||
hasToken, err := s.sharesHasLegacyTokenColumn()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasToken {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var token sql.NullString
|
||||
err = s.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, hash).Scan(&token)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read legacy share token: %w", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return "", nil
|
||||
}
|
||||
return token.String, nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
)
|
||||
|
||||
func createLegacySharesDB(t *testing.T) *SQLStore {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-shares.db")
|
||||
store, _, err := NewSQLStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLStore: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.db.Exec(`DROP TABLE shares`)
|
||||
if err != nil {
|
||||
t.Fatalf("drop shares: %v", err)
|
||||
}
|
||||
_, err = store.db.Exec(`
|
||||
CREATE TABLE shares (
|
||||
hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
expire INTEGER NOT NULL DEFAULT 0,
|
||||
downloads INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
token TEXT,
|
||||
user_downloads TEXT,
|
||||
share_settings TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 0
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create legacy shares table: %v", err)
|
||||
}
|
||||
_, err = store.db.Exec(`
|
||||
INSERT INTO shares (
|
||||
hash, user_id, source, path, expire, downloads, password_hash, token,
|
||||
user_downloads, share_settings, version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"legacy-null-token",
|
||||
"1",
|
||||
"/data",
|
||||
"/docs/file.txt",
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
nil,
|
||||
"{}",
|
||||
`{"shareType":"normal"}`,
|
||||
1,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert legacy share: %v", err)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func scanLegacyShareToken(t *testing.T, store *SQLStore, hash string) string {
|
||||
t.Helper()
|
||||
var token string
|
||||
err := store.db.QueryRow(`
|
||||
SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE hash = ?`, hash).Scan(
|
||||
new(string),
|
||||
new(string),
|
||||
new(string),
|
||||
new(string),
|
||||
new(int64),
|
||||
new(int),
|
||||
new(string),
|
||||
&token,
|
||||
new([]byte),
|
||||
new([]byte),
|
||||
new(int),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("scan legacy share row: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func TestMigrationNormalizesNullLegacyShareToken(t *testing.T) {
|
||||
store := createLegacySharesDB(t)
|
||||
|
||||
if err := runMigrations(store.db, 1); err != nil {
|
||||
t.Fatalf("runMigrations: %v", err)
|
||||
}
|
||||
|
||||
token := scanLegacyShareToken(t, store, "legacy-null-token")
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty string after migration", token)
|
||||
}
|
||||
|
||||
links, err := store.ListAllShares()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllShares: %v", err)
|
||||
}
|
||||
if len(links) != 1 || links[0].Hash != "legacy-null-token" {
|
||||
t.Fatalf("unexpected shares loaded: %#v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSharePreservesLegacyTokenColumn(t *testing.T) {
|
||||
store := createLegacySharesDB(t)
|
||||
if err := normalizeLegacyShareTokens(store.db); err != nil {
|
||||
t.Fatalf("normalizeLegacyShareTokens: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.db.Exec(`UPDATE shares SET token = ? WHERE hash = ?`, "keep-me", "legacy-null-token")
|
||||
if err != nil {
|
||||
t.Fatalf("seed legacy token: %v", err)
|
||||
}
|
||||
|
||||
link, err := store.GetShareByHash("legacy-null-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetShareByHash: %v", err)
|
||||
}
|
||||
link.Downloads = 2
|
||||
|
||||
if err := store.SaveShare(link); err != nil {
|
||||
t.Fatalf("SaveShare: %v", err)
|
||||
}
|
||||
|
||||
token := scanLegacyShareToken(t, store, "legacy-null-token")
|
||||
if token != "keep-me" {
|
||||
t.Fatalf("token = %q, want keep-me", token)
|
||||
}
|
||||
|
||||
var tokenSQL sql.NullString
|
||||
if err := store.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, "legacy-null-token").Scan(&tokenSQL); err != nil {
|
||||
t.Fatalf("select token: %v", err)
|
||||
}
|
||||
if !tokenSQL.Valid {
|
||||
t.Fatal("expected legacy token column to remain non-null after save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveShareSetsEmptyLegacyTokenForNewShare(t *testing.T) {
|
||||
store := createLegacySharesDB(t)
|
||||
if err := normalizeLegacyShareTokens(store.db); err != nil {
|
||||
t.Fatalf("normalizeLegacyShareTokens: %v", err)
|
||||
}
|
||||
|
||||
newShare := &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
FrontendShareInfo: share.FrontendShareInfo{ShareType: "normal"},
|
||||
},
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "brand-new-share",
|
||||
Path: "/new/file.txt",
|
||||
},
|
||||
SourcePath: "/data",
|
||||
UserID: 1,
|
||||
Version: 1,
|
||||
}
|
||||
if err := store.SaveShare(newShare); err != nil {
|
||||
t.Fatalf("SaveShare: %v", err)
|
||||
}
|
||||
|
||||
token := scanLegacyShareToken(t, store, "brand-new-share")
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty string for new share", token)
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestUpdateUserScopesAddsMissingSidebarLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserScopesKeepsStaleSidebarLinks(t *testing.T) {
|
||||
func TestUpdateUserScopesPrunesSidebarLinks(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
u := &users.User{
|
||||
@@ -127,8 +127,8 @@ func TestUpdateUserScopesKeepsStaleSidebarLinks(t *testing.T) {
|
||||
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)
|
||||
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 1 {
|
||||
t.Fatalf("expected revoked include link pruned, got %d links: %#v", count, loaded.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ func FrontendLinks(links []users.SidebarLink, showToolsInSidebar bool) []users.S
|
||||
}
|
||||
}
|
||||
link.SourceName = source.Name
|
||||
if link.Name == "" {
|
||||
link.Name = source.Name
|
||||
}
|
||||
} else if link.Category == "tool" && link.Target == "/tools" {
|
||||
hasTools = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package usersidebar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestFrontendLinks_populatesEmptySourceName(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
in := []users.SidebarLink{
|
||||
{Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "My Vault", Category: string(users.SidebarLinkSourceMinimal), Icon: "folder", SourceName: "../frontend/tests/playwright-files", Target: "/docs"},
|
||||
}
|
||||
|
||||
out := FrontendLinks(in, false)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("len(out) = %d, want 2", len(out))
|
||||
}
|
||||
if out[0].Name != "docker" || out[0].SourceName != "docker" {
|
||||
t.Fatalf("default source link = %#v", out[0])
|
||||
}
|
||||
if out[1].Name != "My Vault" || out[1].SourceName != "playwright + files" {
|
||||
t.Fatalf("custom source link = %#v", out[1])
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ 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; custom Name, Icon, and Category are preserved.
|
||||
// Empty Name means "use live source display name"; names matching the current source display name
|
||||
// are blanked so renames propagate without overwriting custom labels.
|
||||
// Unresolvable source links are dropped. Source links are deduped by canonical path plus target
|
||||
// (first occurrence wins), so multiple folder shortcuts on one source are kept. Non-source links
|
||||
// pass through; duplicate Tools links are deduped.
|
||||
@@ -36,8 +38,8 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
|
||||
normalized := link
|
||||
normalized.Category = users.NormalizeSidebarLinkCategory(normalized.Category)
|
||||
normalized.SourceName = source.Path
|
||||
if strings.TrimSpace(normalized.Name) == "" {
|
||||
normalized.Name = source.Name
|
||||
if strings.TrimSpace(normalized.Name) == source.Name {
|
||||
normalized.Name = ""
|
||||
}
|
||||
normalized.Target = normalizeSidebarTarget(normalized.Target)
|
||||
|
||||
|
||||
@@ -69,9 +69,9 @@ func TestNormalizeSidebarLinks_dedupesLinuxAndDockerPaths(t *testing.T) {
|
||||
t.Fatalf("len(out) = %d, want 3", len(out))
|
||||
}
|
||||
want := []users.SidebarLink{
|
||||
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
|
||||
{Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
|
||||
{Category: "source", SourceName: ".", Target: "/"},
|
||||
{Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
|
||||
}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("got %#v, want %#v", out, want)
|
||||
@@ -95,8 +95,24 @@ func TestNormalizeSidebarLinks_nameFallbackRemapsStalePath(t *testing.T) {
|
||||
if out[0].SourceName != "../frontend/tests/playwright-files" {
|
||||
t.Fatalf("SourceName = %q", out[0].SourceName)
|
||||
}
|
||||
if out[0].Name != "playwright + files" {
|
||||
t.Fatalf("Name = %q", out[0].Name)
|
||||
if out[0].Name != "" {
|
||||
t.Fatalf("Name = %q, want blanked default name", out[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSidebarLinks_blanksDefaultSourceName(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
in := []users.SidebarLink{
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
}
|
||||
|
||||
out, changed := NormalizeSidebarLinks(in)
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true")
|
||||
}
|
||||
if out[0].Name != "" {
|
||||
t.Fatalf("Name = %q, want blanked when matching source display name", out[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,18 +126,20 @@ func TestNormalizeSidebarLinks_idempotentOnCanonicalLinks(t *testing.T) {
|
||||
}
|
||||
|
||||
out, changed := NormalizeSidebarLinks(in)
|
||||
if changed {
|
||||
t.Fatal("expected changed=false for canonical links")
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true when blanking default names")
|
||||
}
|
||||
if !reflect.DeepEqual(out, in) {
|
||||
t.Fatalf("got %#v, want %#v", out, in)
|
||||
for _, link := range out {
|
||||
if link.Name != "" {
|
||||
t.Fatalf("expected blank default name, got %#v", link)
|
||||
}
|
||||
}
|
||||
|
||||
out2, changed2 := NormalizeSidebarLinks(out)
|
||||
if changed2 {
|
||||
t.Fatal("expected second pass unchanged")
|
||||
}
|
||||
if !reflect.DeepEqual(out2, in) {
|
||||
if !reflect.DeepEqual(out2, out) {
|
||||
t.Fatalf("second pass got %#v", out2)
|
||||
}
|
||||
}
|
||||
@@ -141,8 +159,8 @@ func TestNormalizeSidebarLinks_dropsUnresolvableSourceLink(t *testing.T) {
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len(out) = %d, want 1", len(out))
|
||||
}
|
||||
if out[0].Name != "docker" {
|
||||
t.Fatalf("remaining link = %#v", out[0])
|
||||
if out[0].Name != "" {
|
||||
t.Fatalf("remaining link should have blanked default name: %#v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,12 +193,15 @@ func TestNormalizeSidebarLinks_preservesMultipleFolderShortcutsOnSameSource(t *t
|
||||
}
|
||||
|
||||
out, changed := NormalizeSidebarLinks(in)
|
||||
if changed {
|
||||
t.Fatal("expected canonical folder shortcuts unchanged")
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true when blanking default root source name")
|
||||
}
|
||||
if len(out) != 4 {
|
||||
t.Fatalf("len(out) = %d, want 4", len(out))
|
||||
}
|
||||
if out[0].Name != "" {
|
||||
t.Fatalf("root source link should have blank default name: %#v", out[0])
|
||||
}
|
||||
if out[1].Name != "Photos" || out[1].Icon != "photo" || out[1].Target != "/photos" {
|
||||
t.Fatalf("photos link = %#v", out[1])
|
||||
}
|
||||
@@ -204,8 +225,8 @@ func TestNormalizeSidebarLinks_dedupesSameSourceAndTarget(t *testing.T) {
|
||||
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])
|
||||
if out[0].Name != "" {
|
||||
t.Fatalf("first link wins with blanked default name: %#v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ 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.
|
||||
// PrepareSidebarLinksForPersist normalizes sidebar links, prunes links for revoked scopes,
|
||||
// and adds missing scoped source entries. Sources absent from config are dropped by NormalizeSidebarLinks.
|
||||
func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
|
||||
updated := false
|
||||
|
||||
@@ -14,6 +14,11 @@ func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.Bac
|
||||
updated = true
|
||||
}
|
||||
|
||||
if pruned, changed := PruneSidebarLinksForScopes(links, scopes); changed {
|
||||
links = pruned
|
||||
updated = true
|
||||
}
|
||||
|
||||
merged, changed := EnsureSidebarLinksFromScopes(links, scopes)
|
||||
if changed {
|
||||
links = merged
|
||||
|
||||
@@ -28,12 +28,12 @@ func TestPrepareSidebarLinksForPersist_addsScopedSourcesAndPreservesFolders(t *t
|
||||
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])
|
||||
if out[2].Name != "" {
|
||||
t.Fatalf("added scope link should have empty default name, got %#v", out[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSidebarLinksForPersist_keepsLinksWhenScopeRemoved(t *testing.T) {
|
||||
func TestPrepareSidebarLinksForPersist_prunesLinksWhenScopeRemoved(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
links := []users.SidebarLink{
|
||||
@@ -46,10 +46,10 @@ func TestPrepareSidebarLinksForPersist_keepsLinksWhenScopeRemoved(t *testing.T)
|
||||
}
|
||||
|
||||
out, changed := PrepareSidebarLinksForPersist(links, scopes)
|
||||
if changed {
|
||||
t.Fatal("expected changed=false when only pruning is not performed")
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true when revoked scope link is pruned")
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len(out) = %d, want 3 (stale scope link kept)", len(out))
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("len(out) = %d, want 2 (revoked scope link removed)", len(out))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package usersidebar
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
// PruneSidebarLinksForScopes removes source-type links whose source is not in the user's scopes.
|
||||
// Non-source links (tool, custom, divider) are preserved.
|
||||
func PruneSidebarLinksForScopes(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
|
||||
if !users.SourceConfigLoaded() || len(scopes) == 0 {
|
||||
return links, false
|
||||
}
|
||||
|
||||
allowed := make(map[string]struct{}, len(scopes))
|
||||
for _, path := range uniqueScopedSourcePaths(scopes) {
|
||||
allowed[path] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]users.SidebarLink, 0, len(links))
|
||||
changed := false
|
||||
for _, link := range links {
|
||||
if users.IsSourceSidebarCategory(link.Category) {
|
||||
source, ok := resolveSourceLink(link)
|
||||
if !ok {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[source.Path]; !ok {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, link)
|
||||
}
|
||||
|
||||
return out, changed
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package usersidebar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestPruneSidebarLinksForScopes_removesRevokedSourceAndFolderShortcut(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
links := []users.SidebarLink{
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
|
||||
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
|
||||
{Name: "Docs", Category: string(users.SidebarLinkCustom), Target: "https://example.com", Icon: "link"},
|
||||
}
|
||||
scopes := []users.BackendScope{
|
||||
{Path: "."},
|
||||
}
|
||||
|
||||
out, changed := PruneSidebarLinksForScopes(links, scopes)
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true")
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len(out) = %d, want 3 (playwright link removed, docker + folder + custom kept)", len(out))
|
||||
}
|
||||
if out[2].Category != string(users.SidebarLinkCustom) {
|
||||
t.Fatalf("custom link lost: %#v", out[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSidebarLinksForScopes_noChangeWhenAllScoped(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
links := []users.SidebarLink{
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
}
|
||||
scopes := []users.BackendScope{
|
||||
{Path: "."},
|
||||
}
|
||||
|
||||
out, changed := PruneSidebarLinksForScopes(links, scopes)
|
||||
if changed {
|
||||
t.Fatal("expected changed=false")
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len(out) = %d, want 1", len(out))
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ func EnsureSidebarLinksFromScopes(links []users.SidebarLink, scopes []users.Back
|
||||
continue
|
||||
}
|
||||
out = append(out, users.SidebarLink{
|
||||
Name: source.Name,
|
||||
Category: string(users.SidebarLinkSource),
|
||||
Target: "/",
|
||||
SourceName: source.Path,
|
||||
|
||||
@@ -67,8 +67,8 @@ func TestEnsureSidebarLinksFromScopes_addsMissingScopedSources(t *testing.T) {
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len(out) = %d, want 3", len(out))
|
||||
}
|
||||
if out[2].Name != "playwright + files" {
|
||||
t.Fatalf("added link = %#v", out[2])
|
||||
if out[2].Name != "" {
|
||||
t.Fatalf("added link should have empty default name, got %#v", out[2])
|
||||
}
|
||||
if out[1].Category != string(users.SidebarLinkCustom) {
|
||||
t.Fatalf("custom link lost: %#v", out[1])
|
||||
|
||||
@@ -14,4 +14,5 @@ var (
|
||||
JwtCache = cache.NewCache[string](1*time.Hour, 72*time.Hour)
|
||||
ViewGrantsCache = cache.NewCache[ViewGrant](15 * time.Minute)
|
||||
ViewGrantIndex = cache.NewCache[string](15 * time.Minute)
|
||||
ShareAccessGrantsCache = cache.NewCache[ShareAccessGrant](24 * time.Hour)
|
||||
)
|
||||
@@ -1,12 +1,14 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -56,6 +58,37 @@ func HashSHA256(data string) string {
|
||||
return hex.EncodeToString(bytes[:])
|
||||
}
|
||||
|
||||
// SignHMACSHA256Base64URL returns base64url(payload).base64url(hmac-sha256 signature).
|
||||
func SignHMACSHA256Base64URL(key, payload []byte) string {
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(payloadB64))
|
||||
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return payloadB64 + "." + signature
|
||||
}
|
||||
|
||||
// VerifyHMACSHA256Base64URL validates token form and returns the decoded payload.
|
||||
func VerifyHMACSHA256Base64URL(key []byte, token string) ([]byte, bool) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return nil, false
|
||||
}
|
||||
payloadB64, signature := parts[0], parts[1]
|
||||
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(payloadB64))
|
||||
expectedSignature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
// RandomUint64ID returns a non-zero cryptographically random uint64 (e.g. new user rows after migration).
|
||||
// Small ids 1, 2, 3, … remain valid for migrated Bolt-era users.
|
||||
func RandomUint64ID() (uint64, error) {
|
||||
|
||||
@@ -6,3 +6,8 @@ type ViewGrant struct {
|
||||
Source string
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
// ShareAccessGrant tracks remaining uses for count-limited share download tokens.
|
||||
type ShareAccessGrant struct {
|
||||
RemainingUses int
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
libError "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -367,40 +365,23 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int,
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
|
||||
// AuthenticateShareRequest validates access to a password-protected share.
|
||||
// UI sessions are minted only after successful X-SHARE-PASSWORD auth, never from download tokens.
|
||||
func AuthenticateShareRequest(w http.ResponseWriter, r *http.Request, l share.Share) (int, error) {
|
||||
if l.PasswordHash == "" {
|
||||
return 200, nil
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
if validateShareUISessionCookie(r, l.Hash) {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
tokenParam := r.URL.Query().Get("token")
|
||||
if tokenParam != "" {
|
||||
// Verify the token signature if it's in the new signed format
|
||||
if strings.Contains(tokenParam, ".") {
|
||||
parts := strings.Split(tokenParam, ".")
|
||||
if len(parts) == 2 {
|
||||
payload := parts[0]
|
||||
signature := parts[1]
|
||||
|
||||
// Verify HMAC signature
|
||||
mac := hmac.New(sha256.New, []byte(settings.Config.Auth.Key))
|
||||
mac.Write([]byte(payload))
|
||||
expectedSignature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
if hmac.Equal([]byte(signature), []byte(expectedSignature)) {
|
||||
// Token signature is valid, now check if it matches stored token
|
||||
if tokenParam == l.Token {
|
||||
return 200, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy token format (plain base64) - direct comparison
|
||||
if tokenParam == l.Token {
|
||||
return 200, nil
|
||||
}
|
||||
if shareRequestAllowsDownloadToken(r) && authorizeShareDownloadAccessToken(tokenParam, l.Hash) {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
logger.Debugf("share auth failed: hash=%s reason=invalid_token", l.Hash)
|
||||
logger.Debugf("share auth failed: hash=%s reason=invalid_or_disallowed_token", l.Hash)
|
||||
}
|
||||
|
||||
password := r.Header.Get("X-SHARE-PASSWORD")
|
||||
@@ -408,17 +389,80 @@ func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
|
||||
logger.Debugf("share auth failed: hash=%s reason=missing_password", l.Hash)
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
|
||||
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
logger.Debugf("share auth failed: hash=%s reason=wrong_password", l.Hash)
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
return 401, err
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
return 200, nil
|
||||
if w != nil {
|
||||
if cookieErr := SetShareUISessionCookie(w, r, l.Hash); cookieErr != nil {
|
||||
logger.Debugf("share session cookie: hash=%s err=%v", l.Hash, cookieErr)
|
||||
}
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
const sessionCookieName = "filebrowser_quantum_jwt"
|
||||
const shareUISessionCookieName = "filebrowser_share_session"
|
||||
|
||||
// validateShareUISessionCookie checks the HttpOnly share UI session cookie for a hash.
|
||||
func validateShareUISessionCookie(r *http.Request, shareHash string) bool {
|
||||
if r == nil || shareHash == "" {
|
||||
return false
|
||||
}
|
||||
cookie, err := r.Cookie(shareUISessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
return validateShareUISessionToken(cookie.Value, shareHash)
|
||||
}
|
||||
|
||||
// SetShareUISessionCookie stores a short-lived session after successful X-SHARE-PASSWORD auth.
|
||||
// Enables native browser streaming downloads without putting tokens in download URLs.
|
||||
func SetShareUISessionCookie(w http.ResponseWriter, r *http.Request, shareHash string) error {
|
||||
token, expiresAt, err := mintShareUISessionToken(shareHash, maxShareUISessionTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiresTime := time.Unix(expiresAt, 0)
|
||||
http.SetCookie(w, shareUISessionCookie(r, token, expiresTime))
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxShareUISessionTTL = 24 * time.Hour
|
||||
|
||||
// shareUISessionCookiePath scopes the share session cookie to the configured HTTP base URL path.
|
||||
func shareUISessionCookiePath() string {
|
||||
base := settings.Config.Http.BaseURL
|
||||
if base == "" {
|
||||
return "/"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// shareUISessionCookie builds the HttpOnly share UI session cookie for a validated password entry.
|
||||
func shareUISessionCookie(r *http.Request, token string, expiresTime time.Time) *http.Cookie {
|
||||
maxAge := int(time.Until(expiresTime).Seconds())
|
||||
if maxAge < 0 {
|
||||
maxAge = 0
|
||||
}
|
||||
return &http.Cookie{
|
||||
Name: shareUISessionCookieName,
|
||||
Value: token,
|
||||
Path: shareUISessionCookiePath(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
HttpOnly: true,
|
||||
Secure: requestScheme(r) == "https",
|
||||
Expires: expiresTime,
|
||||
MaxAge: maxAge,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionCookie builds the JWT cookie. HttpOnly prevents srcdoc XSS from reading
|
||||
// document.cookie; Secure is set when the request is HTTPS (including via proxy).
|
||||
|
||||
@@ -83,7 +83,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
// Authenticate the share request if needed
|
||||
var status int
|
||||
if link.Hash != "" {
|
||||
status, err = AuthenticateShareRequest(r, link)
|
||||
status, err = AuthenticateShareRequest(w, r, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return status, fmt.Errorf("could not authenticate share request")
|
||||
}
|
||||
@@ -149,7 +149,6 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
|
||||
return ErrToStatus(err), fmt.Errorf("error fetching share from server")
|
||||
}
|
||||
file.Token = link.Token
|
||||
file.Source = link.Hash
|
||||
file.Hash = link.Hash
|
||||
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
|
||||
|
||||
@@ -322,6 +322,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" // bcrypt hashed password
|
||||
const accentedPasswordBcrypt = "$2b$04$JaVUkztbmWKgV8GsSvRorettpNHrc0BvYV3/oARpepfoanKbqt5zi"
|
||||
|
||||
// Create and save a dummy user (shares reference owner by UserID)
|
||||
dummyUser := &users.User{
|
||||
@@ -363,7 +364,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
|
||||
},
|
||||
{
|
||||
name: "Private share, valid password when token exists",
|
||||
name: "Private share, valid password",
|
||||
share: &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
ShareLimits: share.ShareLimits{SourceName: "srv"},
|
||||
@@ -372,7 +373,6 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
SourcePath: "/srv",
|
||||
UserID: 1,
|
||||
PasswordHash: passwordBcrypt,
|
||||
Token: "some_random_token",
|
||||
},
|
||||
extraHeaders: map[string]string{
|
||||
"X-SHARE-PASSWORD": "password",
|
||||
@@ -389,12 +389,11 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
SourcePath: "/srv",
|
||||
UserID: 1,
|
||||
PasswordHash: passwordBcrypt,
|
||||
Token: "123",
|
||||
},
|
||||
expectedStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "Private share, valid token",
|
||||
name: "Private share, download token rejected on listing route",
|
||||
share: &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
ShareLimits: share.ShareLimits{SourceName: "srv"},
|
||||
@@ -403,10 +402,9 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
SourcePath: "/srv",
|
||||
UserID: 1,
|
||||
PasswordHash: passwordBcrypt,
|
||||
Token: "123",
|
||||
},
|
||||
token: "123",
|
||||
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
|
||||
token: "download-token-placeholder",
|
||||
expectedStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "Private share, invalid password",
|
||||
@@ -418,30 +416,49 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
SourcePath: "/srv",
|
||||
UserID: 1,
|
||||
PasswordHash: passwordBcrypt,
|
||||
Token: "123",
|
||||
},
|
||||
extraHeaders: map[string]string{
|
||||
"X-SHARE-PASSWORD": "wrong-password",
|
||||
},
|
||||
expectedStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "Private share, valid encoded non-ASCII password",
|
||||
share: &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
ShareLimits: share.ShareLimits{SourceName: "srv"},
|
||||
},
|
||||
ShareColumns: share.ShareColumns{Hash: "accented_password_hash", Path: "/"},
|
||||
SourcePath: "/srv",
|
||||
UserID: 1,
|
||||
PasswordHash: accentedPasswordBcrypt,
|
||||
},
|
||||
extraHeaders: map[string]string{
|
||||
"X-SHARE-PASSWORD": "pass%C3%A9",
|
||||
},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Save the share in the state
|
||||
if err := state.CreateShare(tc.share); err != nil {
|
||||
t.Fatal("failed to save share:", err)
|
||||
}
|
||||
|
||||
// Create a response recorder to capture handler output
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
// Wrap the handler with authentication middleware
|
||||
handler := withHashFile(publicGetResourceHandler)
|
||||
|
||||
// Prepare the request with query parameters and optional headers
|
||||
req := newTestRequest(t, tc.share.Hash, tc.token, tc.password, tc.extraHeaders)
|
||||
token := tc.token
|
||||
if tc.name == "Private share, download token rejected on listing route" {
|
||||
minted, _, err := mintShareDownloadAccessToken(tc.share.Hash, time.Hour, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", err)
|
||||
}
|
||||
token = minted
|
||||
}
|
||||
|
||||
req := newTestRequest(t, tc.share.Hash, token, tc.password, tc.extraHeaders)
|
||||
|
||||
// Serve the request
|
||||
handler(recorder, req)
|
||||
|
||||
@@ -298,13 +298,17 @@ func joinOnlyOfficeAPIURL(baseURL, apiPath string) string {
|
||||
return base + "/" + path
|
||||
}
|
||||
|
||||
// shareTokenForOnlyOffice returns the share download token for OnlyOffice server URLs.
|
||||
// Only password-protected shares have a token; callers must already have passed share auth.
|
||||
// shareTokenForOnlyOffice mints a short-lived download token for OnlyOffice server URLs.
|
||||
func shareTokenForOnlyOffice(d *Context) string {
|
||||
if d.Share.Hash == "" || d.Share.PasswordHash == "" || d.Share.Token == "" {
|
||||
if d.Share.Hash == "" || d.Share.PasswordHash == "" {
|
||||
return ""
|
||||
}
|
||||
return d.Share.Token
|
||||
token, _, err := mintShareDownloadAccessToken(d.Share.Hash, 2*time.Hour, 0)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to mint OnlyOffice share download token: hash=%s error=%v", d.Share.Hash, err)
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// buildOnlyOfficeViewURL constructs the view URL that OnlyOffice server uses to fetch the document.
|
||||
|
||||
@@ -83,7 +83,7 @@ func requestPageFullURL(r *http.Request) string {
|
||||
}
|
||||
|
||||
// ShareURLFromRequest builds a public share or direct-download URL from the request and server config.
|
||||
func ShareURLFromRequest(r *http.Request, hash string, isDirectDownload bool, token string) string {
|
||||
func ShareURLFromRequest(r *http.Request, hash string, isDirectDownload bool, hasPassword bool) string {
|
||||
host, scheme := shareURLParams(r)
|
||||
return share.PublicShareURL(host, scheme, hash, isDirectDownload, token)
|
||||
return share.PublicShareURL(host, scheme, hash, isDirectDownload, hasPassword)
|
||||
}
|
||||
+84
-168
@@ -1,9 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -260,19 +258,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return status, err2
|
||||
}
|
||||
stringHash := ""
|
||||
var token string
|
||||
if len(hash) > 0 {
|
||||
payloadBuffer := make([]byte, 24)
|
||||
if _, err = rand.Read(payloadBuffer); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
payload := base64.URLEncoding.EncodeToString(payloadBuffer)
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(settings.Config.Auth.Key))
|
||||
mac.Write([]byte(payload))
|
||||
signature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
token = payload + "." + signature
|
||||
stringHash = string(hash)
|
||||
}
|
||||
if req.Hash != "" {
|
||||
@@ -295,7 +281,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
shouldResetCounts := link.DownloadsLimit != req.DownloadsLimit ||
|
||||
link.PerUserDownloadLimit != req.PerUserDownloadLimit
|
||||
|
||||
if err = applySharePasswordUpdate(link, req.Password, stringHash, token); err != nil {
|
||||
if err = applySharePasswordUpdate(link, req.Password, stringHash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -392,7 +378,6 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
SourcePath: source.Path,
|
||||
UserID: d.User.ID,
|
||||
PasswordHash: stringHash,
|
||||
Token: token,
|
||||
Version: 1,
|
||||
}
|
||||
s.DownloadURL = ""
|
||||
@@ -421,174 +406,107 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return RenderJSON(w, r, prepared)
|
||||
}
|
||||
|
||||
// DirectDownloadResponse represents the response for direct download endpoint
|
||||
// DirectDownloadResponse represents the response for direct download endpoint.
|
||||
type DirectDownloadResponse struct {
|
||||
Status string `json:"status"`
|
||||
Hash string `json:"hash"`
|
||||
DownloadURL string `json:"url"`
|
||||
ShareURL string `json:"shareUrl"`
|
||||
Hash string `json:"hash"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// shareDirectDownloadHandler creates a direct download link for files only.
|
||||
// @Summary Create direct download link
|
||||
// @Description Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.
|
||||
// parseShareDownloadTokenParams reads duration, unit, and optional use-count from the direct-download API query.
|
||||
func parseShareDownloadTokenParams(r *http.Request) (time.Duration, int, error) {
|
||||
durationStr := r.URL.Query().Get("duration")
|
||||
if durationStr == "" {
|
||||
durationStr = "60"
|
||||
}
|
||||
durationNum, err := strconv.Atoi(durationStr)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
unit := r.URL.Query().Get("unit")
|
||||
if unit == "" {
|
||||
unit = "minutes"
|
||||
}
|
||||
ttl, err := parseShareDownloadAccessDuration(durationNum, unit)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
maxUses := 0
|
||||
if countStr := r.URL.Query().Get("count"); countStr != "" {
|
||||
maxUses, err = strconv.Atoi(countStr)
|
||||
if err != nil || maxUses <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid count: %v", err)
|
||||
}
|
||||
}
|
||||
return ttl, maxUses, nil
|
||||
}
|
||||
|
||||
// mintShareDownloadTokenResponse builds the JSON payload for GET /api/share/direct.
|
||||
func mintShareDownloadTokenResponse(r *http.Request, hash string, ttl time.Duration, maxUses int) (DirectDownloadResponse, error) {
|
||||
token, expiresAt, err := mintShareDownloadAccessToken(hash, ttl, maxUses)
|
||||
if err != nil {
|
||||
return DirectDownloadResponse{}, err
|
||||
}
|
||||
host, scheme := shareURLParams(r)
|
||||
return DirectDownloadResponse{
|
||||
Hash: hash,
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
URL: directDownloadURL(host, scheme, hash, token),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// shareDirectDownloadHandler mints a download-only ephemeral token for a password-protected share.
|
||||
// @Summary Get direct download link
|
||||
// @Description Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "File path to create download link for"
|
||||
// @Param source query string true "Source name for the file"
|
||||
// @Param duration query string false "Duration in minutes for link validity (default: 60)"
|
||||
// @Param count query string false "Maximum number of downloads allowed (default: unlimited)"
|
||||
// @Param speed query string false "Download speed limit in kbps (default: unlimited)"
|
||||
// @Success 201 {object} DirectDownloadResponse "Direct download link created"
|
||||
// @Failure 400 {object} map[string]string "Bad request - invalid parameters or path is not a file"
|
||||
// @Failure 403 {object} map[string]string "Forbidden - access denied"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Param hash query string true "Share hash"
|
||||
// @Param duration query string false "Duration value (default: 60)"
|
||||
// @Param unit query string false "Duration unit: minutes or hours (default: minutes)"
|
||||
// @Param count query string false "Maximum number of downloads allowed with this token (default: unlimited within TTL)"
|
||||
// @Param X-SHARE-PASSWORD header string true "Share password"
|
||||
// @Success 200 {object} DirectDownloadResponse "Ephemeral download link"
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 401 {object} map[string]string "Invalid or missing share password"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Share not found"
|
||||
// @Router /api/share/direct [get]
|
||||
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
// Extract query parameters
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
duration := r.URL.Query().Get("duration")
|
||||
downloadCountStr := r.URL.Query().Get("count")
|
||||
downloadSpeedStr := r.URL.Query().Get("speed")
|
||||
|
||||
// Validate required parameters
|
||||
if path == "" || source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and source are required")
|
||||
hash := r.URL.Query().Get("hash")
|
||||
if hash == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("hash is required")
|
||||
}
|
||||
|
||||
cleanPath, err := utils.SanitizePath(path)
|
||||
link, err := state.GetShare(hash)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %w", err)
|
||||
return http.StatusNotFound, fmt.Errorf("share not found")
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
// Validate source exists
|
||||
sourceInfo, ok := settings.Config.Server.NameToSource[source]
|
||||
if !ok {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
|
||||
if !link.HasPassword() {
|
||||
return http.StatusBadRequest, fmt.Errorf("share is not password protected")
|
||||
}
|
||||
if !link.UserCanEdit(d.User) {
|
||||
return http.StatusForbidden, fmt.Errorf("you are not allowed to mint tokens for this share")
|
||||
}
|
||||
|
||||
// Get user scope for this source
|
||||
userscope, err := d.User.GetScopeForSourceName(source)
|
||||
status, err := AuthenticateShareRequest(w, r, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid share password")
|
||||
}
|
||||
|
||||
ttl, maxUses, err := parseShareDownloadTokenParams(r)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
// Validate the path exists and is a file (not a folder)
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", source)
|
||||
}
|
||||
|
||||
metadata, exists := idx.GetReducedMetadata(path, false)
|
||||
if !exists {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is either not a file or not found: %s", path)
|
||||
}
|
||||
|
||||
// Check if it's a file (not a directory)
|
||||
if metadata.Type == "directory" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path must be a file, not a directory: %s", path)
|
||||
}
|
||||
|
||||
// Set default duration to 60 minutes if not provided
|
||||
if duration == "" {
|
||||
duration = "60"
|
||||
}
|
||||
|
||||
// Parse download count
|
||||
var downloadCount int
|
||||
if downloadCountStr != "" {
|
||||
downloadCount, err = strconv.Atoi(downloadCountStr)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid downloadCount: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse download speed (in bytes per second)
|
||||
var downloadSpeed int
|
||||
if downloadSpeedStr != "" {
|
||||
downloadSpeed, err = strconv.Atoi(downloadSpeedStr)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid downloadSpeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiration time
|
||||
durationNum, err := strconv.Atoi(duration)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
expire := time.Now().Add(time.Minute * time.Duration(durationNum)).Unix()
|
||||
|
||||
// Generate secure hash for the share
|
||||
secureHash, err := generateShortUUID()
|
||||
response, err := mintShareDownloadTokenResponse(r, hash, ttl, maxUses)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Create the scope path (file: no trailing slash; matches share cache normalization)
|
||||
scopePath := utils.JoinPathAsUnix(userscope, path)
|
||||
|
||||
// Check if an existing share already matches these parameters
|
||||
existingShares, err := state.GetSharesInScope(scopePath, sourceInfo.Path, d.User.ID)
|
||||
if err == nil && len(existingShares) > 0 {
|
||||
for _, existing := range existingShares {
|
||||
if existing.DownloadsLimit == downloadCount &&
|
||||
existing.MaxBandwidth == downloadSpeed &&
|
||||
existing.QuickDownload &&
|
||||
(existing.Expire == 0 || existing.Expire >= expire) { // Existing expires later or never
|
||||
|
||||
response := DirectDownloadResponse{
|
||||
Status: "201",
|
||||
Hash: existing.Hash,
|
||||
DownloadURL: ShareURLFromRequest(r, existing.Hash, true, existing.Token),
|
||||
ShareURL: ShareURLFromRequest(r, existing.Hash, false, existing.Token),
|
||||
}
|
||||
return RenderJSON(w, r, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No matching existing share found, create a new one
|
||||
shareLink := &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
FrontendShareInfo: share.FrontendShareInfo{
|
||||
QuickDownload: true,
|
||||
},
|
||||
ShareLimits: share.ShareLimits{
|
||||
MaxBandwidth: downloadSpeed,
|
||||
DownloadsLimit: downloadCount,
|
||||
SourceName: sourceInfo.Name,
|
||||
},
|
||||
},
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: secureHash,
|
||||
Path: scopePath,
|
||||
Expire: expire,
|
||||
},
|
||||
SourcePath: idx.Path,
|
||||
UserID: d.User.ID,
|
||||
Version: 1,
|
||||
}
|
||||
|
||||
if err = state.CreateShare(shareLink); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
snap, err := state.GetShare(secureHash)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
response := DirectDownloadResponse{
|
||||
Status: "200",
|
||||
Hash: secureHash,
|
||||
DownloadURL: ShareURLFromRequest(r, secureHash, true, snap.Token),
|
||||
ShareURL: ShareURLFromRequest(r, secureHash, false, snap.Token),
|
||||
}
|
||||
|
||||
activity.RecordShareMutation(r, toActor(d), activitydb.EventShareCreate, secureHash, snap.SourceName, snap.Path, nil)
|
||||
return RenderJSON(w, r, response)
|
||||
}
|
||||
|
||||
@@ -677,7 +595,7 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return http.StatusNotFound, fmt.Errorf("share hash not found")
|
||||
}
|
||||
frontendShareInfo := shareInfo.FrontendShareInfo
|
||||
frontendShareInfo.ShareURL = ShareURLFromRequest(r, hash, false, "")
|
||||
frontendShareInfo.ShareURL = ShareURLFromRequest(r, hash, false, shareInfo.HasPassword())
|
||||
frontendShareInfo.BannerUrl = shareInfo.BannerURL()
|
||||
frontendShareInfo.FaviconUrl = shareInfo.FaviconURL()
|
||||
filtered := make([]users.SidebarLink, 0, len(frontendShareInfo.SidebarLinks))
|
||||
@@ -822,17 +740,15 @@ func sharePasswordFromRequest(password *string) ([]byte, int, error) {
|
||||
|
||||
// applySharePasswordUpdate sets or preserves password credentials on share update.
|
||||
// Nil password omits the field (keep existing); empty string clears; non-empty replaces.
|
||||
func applySharePasswordUpdate(link *share.Share, password *string, hashedPassword, token string) error {
|
||||
func applySharePasswordUpdate(link *share.Share, password *string, hashedPassword string) error {
|
||||
if password == nil {
|
||||
return nil
|
||||
}
|
||||
if *password == "" {
|
||||
link.PasswordHash = ""
|
||||
link.Token = ""
|
||||
return nil
|
||||
}
|
||||
link.PasswordHash = hashedPassword
|
||||
link.Token = token
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
shareAccessScopeDownload = "download"
|
||||
shareAccessScopeUISession = "ui"
|
||||
maxShareAccessTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// shareDownloadGrantMu serializes use-count decrements for limited download tokens.
|
||||
var shareDownloadGrantMu sync.Mutex
|
||||
|
||||
type shareAccessClaims struct {
|
||||
Hash string `json:"h"`
|
||||
Exp int64 `json:"exp"`
|
||||
Scope string `json:"s"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// parseShareAccessDuration converts API duration parameters into a time.Duration.
|
||||
func parseShareAccessDuration(duration int, unit string) (time.Duration, error) {
|
||||
if duration <= 0 {
|
||||
return 0, fmt.Errorf("duration must be positive")
|
||||
}
|
||||
switch unit {
|
||||
case "minutes", "minute", "":
|
||||
return time.Duration(duration) * time.Minute, nil
|
||||
case "hours", "hour":
|
||||
return time.Duration(duration) * time.Hour, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid unit: %s", unit)
|
||||
}
|
||||
}
|
||||
|
||||
// parseShareDownloadAccessDuration validates share direct-download TTL capped at 24 hours.
|
||||
func parseShareDownloadAccessDuration(duration int, unit string) (time.Duration, error) {
|
||||
ttl, err := parseShareAccessDuration(duration, unit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ttl > maxShareAccessTTL {
|
||||
return 0, fmt.Errorf("duration exceeds maximum of 24 hours")
|
||||
}
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
// shareAccessAuthKey returns the HMAC signing key for share access tokens.
|
||||
func shareAccessAuthKey() []byte {
|
||||
return []byte(settings.Config.Auth.Key)
|
||||
}
|
||||
|
||||
// mintSignedShareAccessToken signs share access claims as a base64url.payload.signature token.
|
||||
func mintSignedShareAccessToken(claims shareAccessClaims) (token string, expiresAt int64, err error) {
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return utils.SignHMACSHA256Base64URL(shareAccessAuthKey(), payload), claims.Exp, nil
|
||||
}
|
||||
|
||||
// verifySignedShareAccessToken parses and verifies a signed share access token payload.
|
||||
func verifySignedShareAccessToken(tokenParam string) (*shareAccessClaims, bool) {
|
||||
payload, ok := utils.VerifyHMACSHA256Base64URL(shareAccessAuthKey(), tokenParam)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
var claims shareAccessClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &claims, true
|
||||
}
|
||||
|
||||
// mintShareDownloadAccessToken creates a signed, download-scoped ephemeral token for a share.
|
||||
func mintShareDownloadAccessToken(shareHash string, ttl time.Duration, maxUses int) (token string, expiresAt int64, err error) {
|
||||
if shareHash == "" {
|
||||
return "", 0, fmt.Errorf("share hash is required")
|
||||
}
|
||||
if ttl <= 0 || ttl > maxShareAccessTTL {
|
||||
return "", 0, fmt.Errorf("invalid token duration")
|
||||
}
|
||||
if maxUses < 0 {
|
||||
return "", 0, fmt.Errorf("invalid max uses")
|
||||
}
|
||||
|
||||
expiresAt = time.Now().Add(ttl).Unix()
|
||||
claims := shareAccessClaims{
|
||||
Hash: shareHash,
|
||||
Exp: expiresAt,
|
||||
Scope: shareAccessScopeDownload,
|
||||
}
|
||||
|
||||
remainingUses := -1
|
||||
if maxUses > 0 {
|
||||
idBytes := make([]byte, 12)
|
||||
if _, err = rand.Read(idBytes); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
claims.ID = base64.RawURLEncoding.EncodeToString(idBytes)
|
||||
remainingUses = maxUses
|
||||
}
|
||||
|
||||
token, expiresAt, err = mintSignedShareAccessToken(claims)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
if claims.ID != "" {
|
||||
utils.ShareAccessGrantsCache.Set(claims.ID, utils.ShareAccessGrant{RemainingUses: remainingUses})
|
||||
}
|
||||
|
||||
return token, expiresAt, nil
|
||||
}
|
||||
|
||||
// mintShareUISessionToken creates a signed cookie-scoped token for UI share access after password entry.
|
||||
func mintShareUISessionToken(shareHash string, ttl time.Duration) (token string, expiresAt int64, err error) {
|
||||
if shareHash == "" {
|
||||
return "", 0, fmt.Errorf("share hash is required")
|
||||
}
|
||||
if ttl <= 0 || ttl > maxShareAccessTTL {
|
||||
return "", 0, fmt.Errorf("invalid token duration")
|
||||
}
|
||||
|
||||
expiresAt = time.Now().Add(ttl).Unix()
|
||||
return mintSignedShareAccessToken(shareAccessClaims{
|
||||
Hash: shareHash,
|
||||
Exp: expiresAt,
|
||||
Scope: shareAccessScopeUISession,
|
||||
})
|
||||
}
|
||||
|
||||
// validateShareUISessionToken checks a UI session token for the given share hash.
|
||||
func validateShareUISessionToken(tokenParam, shareHash string) bool {
|
||||
claims, ok := verifySignedShareAccessToken(tokenParam)
|
||||
if !ok || claims == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Scope != shareAccessScopeUISession || claims.Hash != shareHash {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix() <= claims.Exp
|
||||
}
|
||||
|
||||
// validateShareDownloadAccessToken checks a download token without consuming a limited use.
|
||||
func validateShareDownloadAccessToken(tokenParam, shareHash string) bool {
|
||||
claims, ok := verifySignedShareAccessToken(tokenParam)
|
||||
if !ok || claims == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Scope != shareAccessScopeDownload || claims.Hash != shareHash {
|
||||
return false
|
||||
}
|
||||
if time.Now().Unix() > claims.Exp {
|
||||
return false
|
||||
}
|
||||
if claims.ID == "" {
|
||||
return true
|
||||
}
|
||||
grant, ok := utils.ShareAccessGrantsCache.Get(claims.ID)
|
||||
if !ok || grant.RemainingUses <= 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// authorizeShareDownloadAccessToken validates a download token and atomically consumes one use when limited.
|
||||
func authorizeShareDownloadAccessToken(tokenParam, shareHash string) bool {
|
||||
claims, ok := verifySignedShareAccessToken(tokenParam)
|
||||
if !ok || claims == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Scope != shareAccessScopeDownload || claims.Hash != shareHash {
|
||||
return false
|
||||
}
|
||||
if time.Now().Unix() > claims.Exp {
|
||||
return false
|
||||
}
|
||||
if claims.ID == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
shareDownloadGrantMu.Lock()
|
||||
defer shareDownloadGrantMu.Unlock()
|
||||
|
||||
grant, ok := utils.ShareAccessGrantsCache.Get(claims.ID)
|
||||
if !ok || grant.RemainingUses <= 0 {
|
||||
return false
|
||||
}
|
||||
grant.RemainingUses--
|
||||
if grant.RemainingUses <= 0 {
|
||||
utils.ShareAccessGrantsCache.Delete(claims.ID)
|
||||
} else {
|
||||
utils.ShareAccessGrantsCache.Set(claims.ID, grant)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// shareRouteAllowsDownloadToken reports whether an ephemeral download token may authorize this path.
|
||||
func shareRouteAllowsDownloadToken(path string) bool {
|
||||
return strings.Contains(path, "/resources/download") ||
|
||||
strings.Contains(path, "/resources/view") ||
|
||||
strings.Contains(path, "/media/stream") ||
|
||||
strings.Contains(path, "/raw")
|
||||
}
|
||||
|
||||
// shareRequestAllowsDownloadToken reports whether an ephemeral download token may authorize this request.
|
||||
func shareRequestAllowsDownloadToken(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
return shareRouteAllowsDownloadToken(r.URL.Path)
|
||||
}
|
||||
|
||||
// directDownloadURL builds a public download URL with an ephemeral token query parameter.
|
||||
func directDownloadURL(host, scheme, hash, token string) string {
|
||||
tokenParam := ""
|
||||
if token != "" {
|
||||
tokenParam = fmt.Sprintf("&token=%s", token)
|
||||
}
|
||||
if settings.Config.Http.ExternalUrl != "" {
|
||||
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash, tokenParam)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s",
|
||||
scheme, host, settings.Config.Http.BaseURL, hash, tokenParam)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestMintAndValidateShareDownloadAccessToken(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
token, expiresAt, err := mintShareDownloadAccessToken("share123", time.Hour, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", err)
|
||||
}
|
||||
if token == "" || expiresAt <= time.Now().Unix() {
|
||||
t.Fatalf("unexpected token response: token=%q expiresAt=%d", token, expiresAt)
|
||||
}
|
||||
if !validateShareDownloadAccessToken(token, "share123") {
|
||||
t.Fatal("expected valid token for matching hash")
|
||||
}
|
||||
if validateShareDownloadAccessToken(token, "other-hash") {
|
||||
t.Fatal("expected invalid token for different hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareDownloadTokenRejectedOnListingRoute(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
token, _, err := mintShareDownloadAccessToken("share123", time.Hour, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=share123&token="+token, nil)
|
||||
if shareRequestAllowsDownloadToken(req) {
|
||||
t.Fatal("listing route should not allow download token auth")
|
||||
}
|
||||
|
||||
downloadReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=share123&token="+token, nil)
|
||||
if !shareRequestAllowsDownloadToken(downloadReq) {
|
||||
t.Fatal("download route should allow download token auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeShareDownloadAccessTokenUseCount(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
token, _, err := mintShareDownloadAccessToken("share123", time.Hour, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", err)
|
||||
}
|
||||
if !authorizeShareDownloadAccessToken(token, "share123") {
|
||||
t.Fatal("expected first authorization to succeed")
|
||||
}
|
||||
if authorizeShareDownloadAccessToken(token, "share123") {
|
||||
t.Fatal("expected token to be invalid after single use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintAndValidateShareUISessionToken(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
token, expiresAt, err := mintShareUISessionToken("share123", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareUISessionToken: %v", err)
|
||||
}
|
||||
if token == "" || expiresAt <= time.Now().Unix() {
|
||||
t.Fatalf("unexpected token response: token=%q expiresAt=%d", token, expiresAt)
|
||||
}
|
||||
if !validateShareUISessionToken(token, "share123") {
|
||||
t.Fatal("expected valid UI session token for matching hash")
|
||||
}
|
||||
if validateShareUISessionToken(token, "other-hash") {
|
||||
t.Fatal("expected invalid UI session token for different hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeShareDownloadAccessTokenConcurrentSingleUse(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
token, _, mintErr := mintShareDownloadAccessToken("share123", time.Hour, 1)
|
||||
if mintErr != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", mintErr)
|
||||
}
|
||||
|
||||
allowed := make(chan bool, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func() {
|
||||
allowed <- authorizeShareDownloadAccessToken(token, "share123")
|
||||
}()
|
||||
}
|
||||
|
||||
successes := 0
|
||||
for i := 0; i < 2; i++ {
|
||||
if <-allowed {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("expected exactly one concurrent authorization, got %d", successes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShareDownloadAccessDurationMax24Hours(t *testing.T) {
|
||||
if _, err := parseShareDownloadAccessDuration(25, "hours"); err == nil {
|
||||
t.Fatal("expected error for duration over 24 hours")
|
||||
}
|
||||
ttl, err := parseShareDownloadAccessDuration(2, "hours")
|
||||
if err != nil {
|
||||
t.Fatalf("parseShareDownloadAccessDuration: %v", err)
|
||||
}
|
||||
if ttl != 2*time.Hour {
|
||||
t.Fatalf("ttl = %v, want 2h", ttl)
|
||||
}
|
||||
}
|
||||
@@ -9,43 +9,40 @@ import (
|
||||
func TestApplySharePasswordUpdatePreservesWhenOmitted(t *testing.T) {
|
||||
link := &share.Share{
|
||||
PasswordHash: "hashed",
|
||||
Token: "tok",
|
||||
}
|
||||
|
||||
if err := applySharePasswordUpdate(link, nil, "", ""); err != nil {
|
||||
if err := applySharePasswordUpdate(link, nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.PasswordHash != "hashed" || link.Token != "tok" {
|
||||
t.Fatalf("expected preserved secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
|
||||
if link.PasswordHash != "hashed" {
|
||||
t.Fatalf("expected preserved password hash, got hash=%q", link.PasswordHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySharePasswordUpdateClearsWhenEmpty(t *testing.T) {
|
||||
link := &share.Share{
|
||||
PasswordHash: "hashed",
|
||||
Token: "tok",
|
||||
}
|
||||
empty := ""
|
||||
|
||||
if err := applySharePasswordUpdate(link, &empty, "", ""); err != nil {
|
||||
if err := applySharePasswordUpdate(link, &empty, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.PasswordHash != "" || link.Token != "" {
|
||||
t.Fatalf("expected cleared secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
|
||||
if link.PasswordHash != "" {
|
||||
t.Fatalf("expected cleared password hash, got hash=%q", link.PasswordHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySharePasswordUpdateReplacesWhenProvided(t *testing.T) {
|
||||
link := &share.Share{
|
||||
PasswordHash: "old",
|
||||
Token: "oldtok",
|
||||
}
|
||||
next := "new-password"
|
||||
|
||||
if err := applySharePasswordUpdate(link, &next, "newhash", "newtok"); err != nil {
|
||||
if err := applySharePasswordUpdate(link, &next, "newhash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.PasswordHash != "newhash" || link.Token != "newtok" {
|
||||
t.Fatalf("expected replaced secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
|
||||
if link.PasswordHash != "newhash" {
|
||||
t.Fatalf("expected replaced password hash, got hash=%q", link.PasswordHash)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestAuthenticateShareRequestUISessionCookie(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("bcrypt: %v", err)
|
||||
}
|
||||
link := share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "ui_session_share"},
|
||||
PasswordHash: string(passwordHash),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=ui_session_share", nil)
|
||||
req.Header.Set("X-SHARE-PASSWORD", "secret")
|
||||
status, err := AuthenticateShareRequest(rec, req, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("expected password auth to succeed, status=%d err=%v", status, err)
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != shareUISessionCookieName {
|
||||
t.Fatalf("expected share session cookie, got %#v", cookies)
|
||||
}
|
||||
|
||||
downloadReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=ui_session_share", nil)
|
||||
downloadReq.AddCookie(cookies[0])
|
||||
status, err = AuthenticateShareRequest(nil, downloadReq, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("expected cookie auth to succeed, status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
token, _, mintErr := mintShareUISessionToken("other_share", time.Hour)
|
||||
if mintErr != nil {
|
||||
t.Fatalf("mintShareUISessionToken: %v", mintErr)
|
||||
}
|
||||
wrongReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=ui_session_share", nil)
|
||||
wrongReq.AddCookie(&http.Cookie{Name: shareUISessionCookieName, Value: token})
|
||||
status, err = AuthenticateShareRequest(nil, wrongReq, link)
|
||||
if err != nil || status == http.StatusOK {
|
||||
t.Fatalf("expected mismatched hash cookie to fail, status=%d err=%v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateShareRequestDownloadTokenDoesNotMintUISession(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("bcrypt: %v", err)
|
||||
}
|
||||
link := share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "dl_token_share"},
|
||||
PasswordHash: string(passwordHash),
|
||||
}
|
||||
|
||||
downloadToken, _, err := mintShareDownloadAccessToken(link.Hash, time.Hour, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash="+link.Hash+"&token="+downloadToken, nil)
|
||||
req.Header.Set("X-SHARE-PASSWORD", "secret")
|
||||
status, err := AuthenticateShareRequest(rec, req, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("expected download token auth to succeed, status=%d err=%v", status, err)
|
||||
}
|
||||
if len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatal("download token auth must not mint a UI session cookie when password header is also present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateShareRequestDownloadTokenWrongPasswordNoUISession(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
passwordHash, bcryptErr := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
|
||||
if bcryptErr != nil {
|
||||
t.Fatalf("bcrypt: %v", bcryptErr)
|
||||
}
|
||||
link := share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "dl_wrong_pw_share"},
|
||||
PasswordHash: string(passwordHash),
|
||||
}
|
||||
|
||||
downloadToken, _, mintErr := mintShareDownloadAccessToken(link.Hash, time.Hour, 0)
|
||||
if mintErr != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", mintErr)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash="+link.Hash+"&token="+downloadToken, nil)
|
||||
req.Header.Set("X-SHARE-PASSWORD", "wrong-password")
|
||||
status, authErr := AuthenticateShareRequest(rec, req, link)
|
||||
if authErr != nil || status != http.StatusOK {
|
||||
t.Fatalf("expected download token auth to succeed, status=%d err=%v", status, authErr)
|
||||
}
|
||||
if len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatal("valid download token with wrong password header must not mint a UI session cookie")
|
||||
}
|
||||
}
|
||||
@@ -222,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,
|
||||
@@ -247,8 +248,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"minSearchLength": settings.Config.Server.MinSearchLength,
|
||||
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.Config.UserDefaults.UI.DarkMode,
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"baseURL": settings.Config.Http.BaseURL,
|
||||
"version": versionString,
|
||||
"commitSHA": commitSHAString,
|
||||
|
||||
@@ -417,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)
|
||||
}
|
||||
@@ -975,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)
|
||||
}
|
||||
}
|
||||
@@ -140,12 +140,7 @@ func ApplyUserDefaultsFrom(u *users.User, d UserDefaults) {
|
||||
u.BackendScopes = MergeDefaultEnabledBackendScopes(u.BackendScopes)
|
||||
if len(u.SidebarLinks) == 0 && len(u.BackendScopes) > 0 {
|
||||
scope := u.BackendScopes[0]
|
||||
name := scope.Path
|
||||
if src := Config.Server.SourceMap[scope.Path]; src != nil && src.Name != "" {
|
||||
name = src.Name
|
||||
}
|
||||
u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{
|
||||
Name: name,
|
||||
Category: "source",
|
||||
Target: "/",
|
||||
Icon: "",
|
||||
|
||||
@@ -119,9 +119,14 @@ func readCustomCSS(path string) (string, error) {
|
||||
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 Config.UserDefaults.UI.DarkMode != nil && *Config.UserDefaults.UI.DarkMode {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3088,7 +3088,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/share/direct": {
|
||||
"get": {
|
||||
"description": "Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.",
|
||||
"description": "Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3098,50 +3098,59 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Shares"
|
||||
],
|
||||
"summary": "Create direct download link",
|
||||
"summary": "Get direct download link",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "File path to create download link for",
|
||||
"name": "path",
|
||||
"description": "Share hash",
|
||||
"name": "hash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source name for the file",
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Duration in minutes for link validity (default: 60)",
|
||||
"description": "Duration value (default: 60)",
|
||||
"name": "duration",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Maximum number of downloads allowed (default: unlimited)",
|
||||
"description": "Duration unit: minutes or hours (default: minutes)",
|
||||
"name": "unit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Maximum number of downloads allowed with this token (default: unlimited within TTL)",
|
||||
"name": "count",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Download speed limit in kbps (default: unlimited)",
|
||||
"name": "speed",
|
||||
"in": "query"
|
||||
"description": "Share password",
|
||||
"name": "X-SHARE-PASSWORD",
|
||||
"in": "header",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Direct download link created",
|
||||
"200": {
|
||||
"description": "Ephemeral download link",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.DirectDownloadResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request - invalid parameters or path is not a file",
|
||||
"description": "Bad request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid or missing share password",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -3150,7 +3159,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - access denied",
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -3158,8 +3167,8 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"404": {
|
||||
"description": "Share not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -6830,9 +6839,6 @@ const docTemplate = `{
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"userDownloads": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -8014,13 +8020,13 @@ const docTemplate = `{
|
||||
"web.DirectDownloadResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
|
||||
@@ -3077,7 +3077,7 @@
|
||||
},
|
||||
"/api/share/direct": {
|
||||
"get": {
|
||||
"description": "Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.",
|
||||
"description": "Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3087,50 +3087,59 @@
|
||||
"tags": [
|
||||
"Shares"
|
||||
],
|
||||
"summary": "Create direct download link",
|
||||
"summary": "Get direct download link",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "File path to create download link for",
|
||||
"name": "path",
|
||||
"description": "Share hash",
|
||||
"name": "hash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source name for the file",
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Duration in minutes for link validity (default: 60)",
|
||||
"description": "Duration value (default: 60)",
|
||||
"name": "duration",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Maximum number of downloads allowed (default: unlimited)",
|
||||
"description": "Duration unit: minutes or hours (default: minutes)",
|
||||
"name": "unit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Maximum number of downloads allowed with this token (default: unlimited within TTL)",
|
||||
"name": "count",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Download speed limit in kbps (default: unlimited)",
|
||||
"name": "speed",
|
||||
"in": "query"
|
||||
"description": "Share password",
|
||||
"name": "X-SHARE-PASSWORD",
|
||||
"in": "header",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Direct download link created",
|
||||
"200": {
|
||||
"description": "Ephemeral download link",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.DirectDownloadResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request - invalid parameters or path is not a file",
|
||||
"description": "Bad request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid or missing share password",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -3139,7 +3148,7 @@
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - access denied",
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -3147,8 +3156,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"404": {
|
||||
"description": "Share not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -6819,9 +6828,6 @@
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"userDownloads": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -8003,13 +8009,13 @@
|
||||
"web.DirectDownloadResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
|
||||
@@ -1280,8 +1280,6 @@ definitions:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
userDownloads:
|
||||
additionalProperties:
|
||||
type: integer
|
||||
@@ -2115,11 +2113,11 @@ definitions:
|
||||
type: object
|
||||
web.DirectDownloadResponse:
|
||||
properties:
|
||||
expiresAt:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
shareUrl:
|
||||
type: string
|
||||
status:
|
||||
token:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
@@ -4443,58 +4441,66 @@ paths:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Creates a direct download link for a specific file with configurable
|
||||
duration, download count, and speed limits. If a share already exists with
|
||||
matching parameters, the existing share will be reused.
|
||||
description: Mints a download-only ephemeral token for an existing password-protected
|
||||
share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD
|
||||
header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes.
|
||||
Maximum token lifetime is 24 hours regardless of unit.
|
||||
parameters:
|
||||
- description: File path to create download link for
|
||||
- description: Share hash
|
||||
in: query
|
||||
name: path
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: Source name for the file
|
||||
in: query
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: 'Duration in minutes for link validity (default: 60)'
|
||||
- description: 'Duration value (default: 60)'
|
||||
in: query
|
||||
name: duration
|
||||
type: string
|
||||
- description: 'Maximum number of downloads allowed (default: unlimited)'
|
||||
- description: 'Duration unit: minutes or hours (default: minutes)'
|
||||
in: query
|
||||
name: unit
|
||||
type: string
|
||||
- description: 'Maximum number of downloads allowed with this token (default:
|
||||
unlimited within TTL)'
|
||||
in: query
|
||||
name: count
|
||||
type: string
|
||||
- description: 'Download speed limit in kbps (default: unlimited)'
|
||||
in: query
|
||||
name: speed
|
||||
- description: Share password
|
||||
in: header
|
||||
name: X-SHARE-PASSWORD
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Direct download link created
|
||||
"200":
|
||||
description: Ephemeral download link
|
||||
schema:
|
||||
$ref: '#/definitions/web.DirectDownloadResponse'
|
||||
"400":
|
||||
description: Bad request - invalid parameters or path is not a file
|
||||
description: Bad request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Invalid or missing share password
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden - access denied
|
||||
description: Forbidden
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
"404":
|
||||
description: Share not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Create direct download link
|
||||
summary: Get direct download link
|
||||
tags:
|
||||
- Shares
|
||||
/api/share/list:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="{{ if .htmlVars.defaultDarkMode }}dark-mode{{ end }}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -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 -->
|
||||
<!-- 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.globalVars = {{ .globalVars }};
|
||||
window.__pwaDeferredPrompt = null;
|
||||
window.addEventListener("beforeinstallprompt", function (event) {
|
||||
event.preventDefault();
|
||||
@@ -74,12 +74,9 @@
|
||||
}
|
||||
/* paint safe areas + iOS overscroll with app background (notched PWAs) */
|
||||
html {
|
||||
background-color: {{ .htmlVars.lightBackground }};
|
||||
background-color: var(--background);
|
||||
min-height: calc(100% + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
html:has(.dark-mode) {
|
||||
background-color: {{ .htmlVars.darkBackground }};
|
||||
}
|
||||
.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,11 +19,10 @@ export async function getSubtitleContent(source, path, subtitleName, embedded =
|
||||
const apiPath = getPublicApiPath('media/subtitles', {
|
||||
hash,
|
||||
...baseParams,
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
})
|
||||
const sharePassword = localStorage.getItem(`sharepass:${hash}`) || ''
|
||||
res = await fetchURL(apiPath, {
|
||||
headers: { 'X-SHARE-PASSWORD': sharePassword },
|
||||
headers: { 'X-SHARE-PASSWORD': encodeURIComponent(sharePassword) },
|
||||
})
|
||||
} else {
|
||||
const apiPath = getApiPath('media/subtitles', {
|
||||
@@ -57,11 +56,10 @@ export async function getLyricsPublic(path, hash, password = "") {
|
||||
const params = {
|
||||
path,
|
||||
hash,
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
};
|
||||
const apiPath = getPublicApiPath("media/lyrics", params);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: { "X-SHARE-PASSWORD": password || "" },
|
||||
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error(response.statusText);
|
||||
@@ -98,11 +96,10 @@ export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "
|
||||
path,
|
||||
hash,
|
||||
...(albumArt ? { albumArt: "true" } : {}),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
};
|
||||
const apiPath = getPublicApiPath("media/metadata", params);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: { "X-SHARE-PASSWORD": password || "" },
|
||||
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error(response.statusText);
|
||||
@@ -179,7 +176,6 @@ export function getStreamURLPublic(share, files, viewToken) {
|
||||
const params = {
|
||||
file: fileArray,
|
||||
hash: share.hash,
|
||||
token: share.token,
|
||||
viewToken: viewToken,
|
||||
sessionId: state.sessionId,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { notify } from '@/notify'
|
||||
import { state } from '@/store'
|
||||
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
|
||||
import { fetchURL } from './utils'
|
||||
|
||||
@@ -10,14 +9,13 @@ export async function getConfig(req) {
|
||||
path: req.path,
|
||||
...(req.hash && { hash: req.hash }),
|
||||
...(req.source && { source: req.source }),
|
||||
...(req.hash && state.shareInfo?.token && { token: state.shareInfo.token }),
|
||||
}
|
||||
|
||||
const headers = {}
|
||||
if (req.hash) {
|
||||
const sharePassword = localStorage.getItem(`sharepass:${req.hash}`)
|
||||
if (sharePassword) {
|
||||
headers['X-SHARE-PASSWORD'] = sharePassword
|
||||
headers['X-SHARE-PASSWORD'] = encodeURIComponent(sharePassword)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@ export async function signalUploadPause(source, path, shareHash) {
|
||||
const apiPath = getPublicApiPath('resources/pause', {
|
||||
hash: shareHash,
|
||||
path: path,
|
||||
...sharePublicAuthQuery(shareHash),
|
||||
})
|
||||
await fetchURL(apiPath, { method: 'POST', headers: sharePublicAuthHeaders(shareHash) })
|
||||
return
|
||||
@@ -242,7 +241,6 @@ export async function download(format, files, shareHash = "") {
|
||||
algo: format,
|
||||
...(shareHash && { hash: shareHash }),
|
||||
...(!shareHash && source && { source: source }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
sessionId: state.sessionId,
|
||||
}
|
||||
|
||||
@@ -264,6 +262,17 @@ export async function download(format, files, shareHash = "") {
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function shareDownloadFetchInit(method = "GET", extraHeaders = {}) {
|
||||
return {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
...sharePublicAuthHeaders(state.shareInfo?.hash || ""),
|
||||
...extraHeaders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Best-effort text from a failed download response (consumes body). */
|
||||
async function readDownloadErrorBody(response) {
|
||||
try {
|
||||
@@ -295,7 +304,7 @@ function appendArchiveTokenToUrl(url, token) {
|
||||
/** @returns {Promise<{ size: number, token: string | null }>} */
|
||||
async function resolveDownloadContentLength(url) {
|
||||
let token = null
|
||||
let res = await fetch(url, { method: 'HEAD', credentials: 'same-origin' })
|
||||
let res = await fetch(url, shareDownloadFetchInit('HEAD'))
|
||||
if (res.ok) {
|
||||
token = res.headers.get('X-Archive-Token')
|
||||
const cl = res.headers.get('Content-Length')
|
||||
@@ -306,11 +315,7 @@ async function resolveDownloadContentLength(url) {
|
||||
}
|
||||
}
|
||||
}
|
||||
res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Range: 'bytes=0-0' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
res = await fetch(url, shareDownloadFetchInit('GET', { Range: 'bytes=0-0' }))
|
||||
const cancelBody = () => {
|
||||
if (res.body && typeof res.body.cancel === 'function') {
|
||||
res.body.cancel().catch(() => {})
|
||||
@@ -395,10 +400,7 @@ async function chunkedRangeDownloadToBlob(
|
||||
const rangeHeader = `bytes=${offset}-${end}`
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
headers: {
|
||||
Range: rangeHeader,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
...shareDownloadFetchInit('GET', { Range: rangeHeader }),
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
@@ -610,7 +612,6 @@ async function downloadChunked(file, shareHash = "") {
|
||||
file: file.path,
|
||||
...(shareHash && { hash: shareHash }),
|
||||
...(!shareHash && file.source && { source: file.source }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
sessionId: state.sessionId
|
||||
}
|
||||
|
||||
@@ -983,7 +984,6 @@ export function getRawViewURLPublic(share, files, viewToken) {
|
||||
const params = {
|
||||
file: fileArray,
|
||||
hash: share.hash,
|
||||
token: share.token,
|
||||
viewToken: viewToken,
|
||||
sessionId: state.sessionId,
|
||||
}
|
||||
@@ -1126,19 +1126,12 @@ function getSharePasswordFromStorage(hash) {
|
||||
return localStorage.getItem(`sharepass:${hash}`) || "";
|
||||
}
|
||||
|
||||
function sharePublicAuthQuery(hash) {
|
||||
if (state.shareInfo?.hash === hash && state.shareInfo.token) {
|
||||
return { token: state.shareInfo.token };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function sharePublicAuthHeaders(hash) {
|
||||
const password = getSharePasswordFromStorage(hash);
|
||||
if (!password) {
|
||||
return {};
|
||||
}
|
||||
return { "X-SHARE-PASSWORD": password };
|
||||
return { "X-SHARE-PASSWORD": encodeURIComponent(password) };
|
||||
}
|
||||
|
||||
// Fetch public share data
|
||||
@@ -1157,13 +1150,12 @@ export async function fetchFilesPublic(path, hash, password = "", content = fals
|
||||
...(skipExtendedAttrs && { skipExtendedAttrs: 'true' }),
|
||||
...(content && { content: 'true' }),
|
||||
...(metadata && { metadata: 'true' }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token })
|
||||
}
|
||||
const apiPath = getPublicApiPath("resources", params);
|
||||
const sharePassword = password || getSharePasswordFromStorage(hash);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: {
|
||||
"X-SHARE-PASSWORD": password || "",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
headers: sharePassword ? { "X-SHARE-PASSWORD": encodeURIComponent(sharePassword) } : {},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1195,7 +1187,6 @@ export async function getItemsPublic(hash, path, only = "") {
|
||||
path: path,
|
||||
hash: hash,
|
||||
...(only && { only: only }),
|
||||
...sharePublicAuthQuery(hash),
|
||||
})
|
||||
const response = await fetch(apiPath, { headers: sharePublicAuthHeaders(hash) })
|
||||
const data = await response.json()
|
||||
@@ -1208,16 +1199,15 @@ export async function getItemsPublic(hash, path, only = "") {
|
||||
|
||||
// Generate a download URL
|
||||
/**
|
||||
* @param {{ path: string; hash: string; token: string; inline?: boolean }} share
|
||||
* @param {{ path: string; hash: string; inline?: boolean }} share
|
||||
* @param {string[]} files - Array of file paths (will be converted to repeated 'file' parameters)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getDownloadURLPublic(share, files, inline=false) {
|
||||
const fileArray = Array.isArray(files) ? files : [files]
|
||||
const params = {
|
||||
file: fileArray, // Array will be converted to repeated 'file' params by getPublicApiPath
|
||||
file: fileArray,
|
||||
hash: share.hash,
|
||||
token: share.token,
|
||||
...(inline && { inline: 'true' })
|
||||
}
|
||||
const apiPath = getPublicApiPath("resources/download", params)
|
||||
@@ -1230,14 +1220,14 @@ export function getDownloadURLPublic(share, files, inline=false) {
|
||||
* @param {string} size - The size parameter (small, large, original). Omit for default (small).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPreviewURLPublic(path, size) {
|
||||
export function getPreviewURLPublic(path, size, viewToken = state.req?.viewToken) {
|
||||
try {
|
||||
const params = {
|
||||
path: path,
|
||||
hash: state.shareInfo.hash,
|
||||
inline: 'true',
|
||||
...(size && size !== 'small' && { size: size }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token })
|
||||
...(viewToken && { viewToken }),
|
||||
}
|
||||
const apiPath = getPublicApiPath('resources/preview', params)
|
||||
return window.origin + apiPath
|
||||
@@ -1266,7 +1256,6 @@ export function postPublic(
|
||||
hash: hash,
|
||||
override: overwrite,
|
||||
...(isDir && { isDir: 'true' }),
|
||||
...sharePublicAuthQuery(hash),
|
||||
});
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
@@ -1356,7 +1345,6 @@ async function resourceActionPublic(hash, path, method, content, token = "") {
|
||||
path,
|
||||
hash: hash,
|
||||
...(token && { token }),
|
||||
...sharePublicAuthQuery(hash),
|
||||
})
|
||||
const response = await fetch(apiPath, {
|
||||
method,
|
||||
@@ -1400,7 +1388,6 @@ export async function bulkDeletePublic(items) {
|
||||
|
||||
const params = {
|
||||
hash: hash,
|
||||
...sharePublicAuthQuery(hash),
|
||||
sessionId: state.sessionId
|
||||
}
|
||||
const apiPath = getPublicApiPath("resources/bulk", params)
|
||||
@@ -1471,7 +1458,7 @@ export async function moveCopyPublic(
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(state.shareInfo.token && { 'X-Auth-Token': state.shareInfo.token })
|
||||
...sharePublicAuthHeaders(hash),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
@@ -901,7 +901,6 @@ export default {
|
||||
? resourcesApi.getOpenFileURL(source, path, {
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
})
|
||||
: resourcesApi.getOpenFileURL(source, path);
|
||||
window.open(openUrl, "_blank");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div v-if="showWrongCredentials" class="form-invalid">
|
||||
{{ $t("login.wrongCredentials") }}
|
||||
</div>
|
||||
<div v-if="infoText">
|
||||
<div v-if="infoText" class="password-info">
|
||||
<p>{{ infoText }}</p>
|
||||
</div>
|
||||
<div class="form-flex-group">
|
||||
@@ -85,6 +85,15 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.password-info {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.password-info p {
|
||||
margin: 0;
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
|
||||
.wrong__password {
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
|
||||
@@ -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 { getObjectProperty, 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) {
|
||||
let snapshot = {};
|
||||
for (const field of PROFILE_SNAPSHOT_FIELDS) {
|
||||
snapshot = setObjectProperty(snapshot, field, getObjectProperty(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 (
|
||||
getObjectProperty(current.permissions, perm) !== getObjectProperty(orig.permissions, perm)
|
||||
&& !getObjectProperty(this.enforcedAccountPermissions, perm)
|
||||
) {
|
||||
fields.push("permissions");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const field of PROFILE_SNAPSHOT_FIELDS) {
|
||||
if (isFlatProfileFieldEnforced(this.enforcedPreferences, field)) {
|
||||
continue;
|
||||
}
|
||||
if (JSON.stringify(getObjectProperty(current.profile, field)) !== JSON.stringify(getObjectProperty(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>
|
||||
@@ -90,6 +90,7 @@
|
||||
<script>
|
||||
import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import { getObjectProperty } from "@/utils/object.js";
|
||||
|
||||
export default {
|
||||
name: "UserDefaultsAccountSection",
|
||||
@@ -122,6 +123,10 @@ export default {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
respectEnforcedPolicy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["account-change", "enforced-change", "enforced-permission-change"],
|
||||
methods: {
|
||||
@@ -131,6 +136,30 @@ export default {
|
||||
isPermissionLocked(field) {
|
||||
return this.configLockedPaths.includes(`account.permissions.${field}`);
|
||||
},
|
||||
isFieldDisabled(field) {
|
||||
if (this.isFieldLocked(field)) {
|
||||
return true;
|
||||
}
|
||||
return this.respectEnforcedPolicy && !!getObjectProperty(this.enforced, field);
|
||||
},
|
||||
isPermissionDisabled(field) {
|
||||
if (this.isPermissionLocked(field)) {
|
||||
return true;
|
||||
}
|
||||
return this.respectEnforcedPolicy && !!getObjectProperty(this.enforcedPermissions, field);
|
||||
},
|
||||
fieldDisabledTooltip(field) {
|
||||
if (this.respectEnforcedPolicy && getObjectProperty(this.enforced, field)) {
|
||||
return this.$t("profileSettings.enforcedByAdmin");
|
||||
}
|
||||
return this.configLockTooltipForField(field);
|
||||
},
|
||||
permissionDisabledTooltip(field) {
|
||||
if (this.respectEnforcedPolicy && getObjectProperty(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() {
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
import { logout } from "@/utils/auth";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import {
|
||||
hideInteractiveTooltip,
|
||||
showHoverTooltip,
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
import SidebarLinks from "./Links.vue";
|
||||
import { url } from "@/utils";
|
||||
|
||||
@@ -233,15 +237,11 @@ export default {
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
if (text) {
|
||||
mutations.showTooltip({
|
||||
content: text,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
showHoverTooltip(text, event);
|
||||
}
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
hideInteractiveTooltip();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,13 +60,22 @@
|
||||
<circle class="center" cx="50%" cy="50%" r="7px"></circle>
|
||||
<circle class="pulse" cx="50%" cy="50%" r="10px"></circle>
|
||||
</svg>
|
||||
<i v-else class="material-symbols warning-icon"
|
||||
@mouseenter="showTooltip($event, $t('sidebar.sourceNotAccessible'))" @mouseleave="hideTooltip">
|
||||
warning
|
||||
</i>
|
||||
<HelpTooltipIcon
|
||||
v-else
|
||||
icon="warning"
|
||||
icon-style="symbols"
|
||||
icon-class="warning-icon"
|
||||
:text="$t('sidebar.sourceNotAccessible')"
|
||||
/>
|
||||
<span>{{ link.name }}</span>
|
||||
<i v-if="hasUsageInfo(link)" class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@click="onSourceInfoClick($event, sourceInfo[link.sourceName] || {})"
|
||||
:class="{ 'tooltip-info-icon--pressed': pressedSourceInfo === link.sourceName }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@touchstart.stop="onSourceInfoTouchStart(link.sourceName)"
|
||||
@touchend.stop="onSourceInfoTouchEnd($event, sourceInfo[link.sourceName] || {})"
|
||||
@touchcancel.stop="onSourceInfoTouchCancel"
|
||||
@click.stop="onSourceInfoClick($event, sourceInfo[link.sourceName] || {})"
|
||||
@mouseenter="onSourceInfoMouseEnter($event, sourceInfo[link.sourceName] || {})"
|
||||
@mouseleave="onSourceInfoMouseLeave">
|
||||
info
|
||||
@@ -131,15 +140,24 @@
|
||||
<circle class="center" cx="50%" cy="50%" r="7px"></circle>
|
||||
<circle class="pulse" cx="50%" cy="50%" r="10px"></circle>
|
||||
</svg>
|
||||
<i v-else class="material-symbols warning-icon"
|
||||
@mouseenter="showTooltip($event, $t('sidebar.sourceNotAccessible'))" @mouseleave="hideTooltip">
|
||||
warning
|
||||
</i>
|
||||
<HelpTooltipIcon
|
||||
v-else
|
||||
icon="warning"
|
||||
icon-style="symbols"
|
||||
icon-class="warning-icon"
|
||||
:text="$t('sidebar.sourceNotAccessible')"
|
||||
/>
|
||||
<!-- Source name -->
|
||||
<span>{{ activeSourceLink.name }}</span>
|
||||
<i v-if="hasUsageInfo(activeSourceLink)"
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@click="onSourceInfoClick($event, activeSourceInfo)"
|
||||
:class="{ 'tooltip-info-icon--pressed': pressedSourceInfo === activeSource }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@touchstart.stop="onSourceInfoTouchStart(activeSource)"
|
||||
@touchend.stop="onSourceInfoTouchEnd($event, activeSourceInfo)"
|
||||
@touchcancel.stop="onSourceInfoTouchCancel"
|
||||
@click.stop="onSourceInfoClick($event, activeSourceInfo)"
|
||||
@mouseenter="onSourceInfoMouseEnter($event, activeSourceInfo)"
|
||||
@mouseleave="onSourceInfoMouseLeave">
|
||||
info
|
||||
@@ -184,20 +202,30 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import {
|
||||
hideInteractiveTooltip,
|
||||
onComponentTooltipClick,
|
||||
onComponentTooltipTouchEnd,
|
||||
showHoverComponentTooltip,
|
||||
showHoverTooltip,
|
||||
useTapForTooltip,
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
import ProgressBar from "@/components/ProgressBar.vue";
|
||||
import { goToItem } from "@/utils/url";
|
||||
import { getIconClass } from "@/utils/material-symbols";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import IndexInfo from "@/components/files/IndexInfo.vue";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { showShareDownloadPrompt } from "@/utils/download.js";
|
||||
import ShareInfo from "@/components/files/ShareInfo.vue";
|
||||
import FileTree from '@/components/files/FileTree.vue';
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
export default {
|
||||
name: "SidebarLinks",
|
||||
components: {
|
||||
HelpTooltipIcon,
|
||||
ProgressBar,
|
||||
ShareInfo,
|
||||
FileTree,
|
||||
@@ -205,12 +233,12 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sourceTooltipDismissHandler: null,
|
||||
pressedSourceInfo: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
useTapForSourceTooltip() {
|
||||
return getters.isMobile();
|
||||
return useTapForTooltip();
|
||||
},
|
||||
editShareText() {
|
||||
return this.$t("general.shareManagement");
|
||||
@@ -325,7 +353,7 @@ export default {
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.unregisterSourceTooltipDismiss();
|
||||
hideInteractiveTooltip(true);
|
||||
},
|
||||
methods: {
|
||||
isSourceCategory(category) {
|
||||
@@ -485,33 +513,13 @@ export default {
|
||||
mutations.closeTopPrompt();
|
||||
},
|
||||
goToDownload() {
|
||||
// Check if we're in a directory with multiple items
|
||||
const hasMultipleItems = state.req.items && state.req.items.length > 1;
|
||||
if (hasMultipleItems) {
|
||||
// Show format selector for directories with multiple items
|
||||
mutations.showPrompt({
|
||||
name: "download",
|
||||
confirm: (format) => {
|
||||
mutations.closeTopPrompt();
|
||||
const downloadLink = resourcesApi.getDownloadURLPublic({
|
||||
path: "/",
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
inline: false,
|
||||
}, [state.req.path]);
|
||||
window.open(`${downloadLink}&format=${format}`, "_blank");
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Direct download for single files or directories
|
||||
const downloadLink = resourcesApi.getDownloadURLPublic({
|
||||
path: "/",
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
inline: false,
|
||||
}, [state.req.path]);
|
||||
window.open(downloadLink, "_blank");
|
||||
}
|
||||
showShareDownloadPrompt([{
|
||||
path: state.req?.path || "/",
|
||||
name: state.req?.name,
|
||||
type: state.req?.type,
|
||||
size: state.req?.size,
|
||||
isDir: state.req?.type === "directory",
|
||||
}]);
|
||||
},
|
||||
navigateTo(path, hash) {
|
||||
mutations.setPreviousHistoryItem({
|
||||
@@ -558,95 +566,55 @@ export default {
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
if (text) {
|
||||
mutations.showTooltip({
|
||||
content: text,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
showHoverTooltip(text, event);
|
||||
}
|
||||
},
|
||||
hideTooltip() {
|
||||
this.unregisterSourceTooltipDismiss();
|
||||
mutations.hideTooltip();
|
||||
},
|
||||
isSourceTooltipVisible(info) {
|
||||
if (!state.tooltip.show || state.tooltip.component !== IndexInfo) {
|
||||
return false;
|
||||
}
|
||||
const shown = state.tooltip.componentProps?.info;
|
||||
if (!shown || !info) {
|
||||
return false;
|
||||
}
|
||||
return shown.name === info.name;
|
||||
hideInteractiveTooltip();
|
||||
},
|
||||
onSourceInfoClick(event, info) {
|
||||
if (!this.useTapForSourceTooltip) {
|
||||
return;
|
||||
onComponentTooltipClick(this.sourceInfoTooltipOptions(event, info));
|
||||
},
|
||||
onSourceInfoTouchStart(sourceName) {
|
||||
if (useTapForTooltip()) {
|
||||
this.pressedSourceInfo = sourceName;
|
||||
}
|
||||
this.toggleSourceTooltip(event, info);
|
||||
},
|
||||
onSourceInfoTouchEnd(event, info) {
|
||||
this.pressedSourceInfo = null;
|
||||
onComponentTooltipTouchEnd(this.sourceInfoTooltipOptions(event, info));
|
||||
},
|
||||
onSourceInfoTouchCancel() {
|
||||
this.pressedSourceInfo = null;
|
||||
},
|
||||
sourceInfoTooltipOptions(event, info) {
|
||||
return {
|
||||
event,
|
||||
component: IndexInfo,
|
||||
componentProps: { info },
|
||||
pointerEvents: this.useTapForSourceTooltip,
|
||||
propsMatcher: (props) => props.info?.name === info?.name,
|
||||
};
|
||||
},
|
||||
onSourceInfoMouseEnter(event, info) {
|
||||
if (this.useTapForSourceTooltip) {
|
||||
return;
|
||||
}
|
||||
this.showSourceTooltip(event, info);
|
||||
this.showSourceHoverTooltip(event, info);
|
||||
},
|
||||
onSourceInfoMouseLeave() {
|
||||
this.pressedSourceInfo = null;
|
||||
if (this.useTapForSourceTooltip) {
|
||||
return;
|
||||
}
|
||||
this.hideTooltip();
|
||||
},
|
||||
toggleSourceTooltip(event, info) {
|
||||
if (this.isSourceTooltipVisible(info)) {
|
||||
this.hideTooltip();
|
||||
return;
|
||||
}
|
||||
this.showSourceTooltip(event, info);
|
||||
},
|
||||
showSourceTooltip(event, info) {
|
||||
showSourceHoverTooltip(event, info) {
|
||||
if (!info?.name) {
|
||||
return;
|
||||
}
|
||||
mutations.showTooltip({
|
||||
showHoverComponentTooltip({
|
||||
component: IndexInfo,
|
||||
componentProps: { info },
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
pointerEvents: this.useTapForSourceTooltip,
|
||||
event,
|
||||
});
|
||||
if (this.useTapForSourceTooltip) {
|
||||
this.registerSourceTooltipDismiss();
|
||||
}
|
||||
},
|
||||
registerSourceTooltipDismiss() {
|
||||
this.unregisterSourceTooltipDismiss();
|
||||
this.sourceTooltipDismissHandler = (e) => {
|
||||
const target = e.target;
|
||||
if (target instanceof Element && (
|
||||
target.closest(".tooltip-info-icon") ||
|
||||
target.closest(".floating-tooltip")
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
this.hideTooltip();
|
||||
};
|
||||
// Defer so the same tap does not immediately dismiss the tooltip.
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.sourceTooltipDismissHandler) {
|
||||
return;
|
||||
}
|
||||
document.addEventListener("click", this.sourceTooltipDismissHandler, true);
|
||||
document.addEventListener("touchstart", this.sourceTooltipDismissHandler, true);
|
||||
});
|
||||
},
|
||||
unregisterSourceTooltipDismiss() {
|
||||
if (!this.sourceTooltipDismissHandler) {
|
||||
return;
|
||||
}
|
||||
document.removeEventListener("click", this.sourceTooltipDismissHandler, true);
|
||||
document.removeEventListener("touchstart", this.sourceTooltipDismissHandler, true);
|
||||
this.sourceTooltipDismissHandler = null;
|
||||
},
|
||||
async showEditShareHover() {
|
||||
if (!this.canEdit) {
|
||||
|
||||
@@ -204,7 +204,7 @@ export default {
|
||||
transition: 0.4s ease;
|
||||
top: 4em;
|
||||
padding-bottom: 4em;
|
||||
background-color: rgb(37 49 55 / 5%) !important;
|
||||
background-color: color-mix(in srgb, var(--alt-background) 20%, transparent);
|
||||
will-change: left;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
@@ -215,9 +215,6 @@ export default {
|
||||
backdrop-filter: blur(8px) invert(0.1);
|
||||
isolation: isolate;
|
||||
}
|
||||
#sidebar.dark-mode {
|
||||
background-color: rgb(37 49 55 / 33%) !important;
|
||||
}
|
||||
#sidebar:not(.active) {
|
||||
backdrop-filter: blur(0) invert(0);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
<div v-else class="table-value-cell">
|
||||
<span
|
||||
:class="badgeClasses"
|
||||
@mouseenter="showTooltip"
|
||||
@click.stop="onBadgeClick"
|
||||
@touchend.stop="onBadgeTouchEnd"
|
||||
@mouseenter="onBadgeMouseEnter"
|
||||
@mouseleave="hideTooltip"
|
||||
>{{ displayText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mutations } from "@/store";
|
||||
import ActivityTableValueInfo from "@/components/tools/ActivityTableValueInfo.vue";
|
||||
import {
|
||||
hideInteractiveTooltip,
|
||||
onComponentTooltipClick,
|
||||
onComponentTooltipTouchEnd,
|
||||
showHoverComponentTooltip,
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
|
||||
export default {
|
||||
name: "ActivityTableValueCell",
|
||||
@@ -72,24 +79,55 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
showTooltip(event) {
|
||||
componentPropsMatcher(props) {
|
||||
return props.label === this.label && props.value === this.tooltipText;
|
||||
},
|
||||
showComponentTooltip(event) {
|
||||
const text = this.tooltipText;
|
||||
if (!text || !this.label) {
|
||||
return;
|
||||
}
|
||||
mutations.showTooltip({
|
||||
showHoverComponentTooltip({
|
||||
component: ActivityTableValueInfo,
|
||||
componentProps: {
|
||||
label: this.label,
|
||||
value: text,
|
||||
},
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
event,
|
||||
width: "22rem",
|
||||
});
|
||||
},
|
||||
onBadgeClick(event) {
|
||||
const text = this.tooltipText;
|
||||
if (!text || !this.label) {
|
||||
return;
|
||||
}
|
||||
onComponentTooltipClick(this.badgeTooltipOptions(event, text));
|
||||
},
|
||||
onBadgeTouchEnd(event) {
|
||||
const text = this.tooltipText;
|
||||
if (!text || !this.label) {
|
||||
return;
|
||||
}
|
||||
onComponentTooltipTouchEnd(this.badgeTooltipOptions(event, text));
|
||||
},
|
||||
badgeTooltipOptions(event, text) {
|
||||
return {
|
||||
event,
|
||||
component: ActivityTableValueInfo,
|
||||
componentProps: {
|
||||
label: this.label,
|
||||
value: text,
|
||||
},
|
||||
width: "22rem",
|
||||
propsMatcher: (props) => this.componentPropsMatcher(props),
|
||||
};
|
||||
},
|
||||
onBadgeMouseEnter(event) {
|
||||
this.showComponentTooltip(event);
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
hideInteractiveTooltip();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,6 +89,10 @@ input.sizeInput:disabled {
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
|
||||
.sizeInputWrapper:focus-within {
|
||||
border-color: var(--primaryColor);
|
||||
}
|
||||
|
||||
.sizeInputWrapper .sizeInput {
|
||||
text-align: center;
|
||||
border: none;
|
||||
|
||||
@@ -92,7 +92,8 @@ i.spin {
|
||||
}
|
||||
|
||||
.main-padding {
|
||||
padding: 4em .5em .5em;
|
||||
padding-top: 4em;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Files */
|
||||
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user