Compare commits

...
27 Commits
Author SHA1 Message Date
Graham Steffaniak f1a7e19fdc fix onlyoffice csp regression 2026-08-30 11:26:16 -05:00
Graham Steffaniak 33a6fd3915 fix: serve subtitle content on public video shares (#2864) 2026-08-29 09:12:19 -05:00
Graham Steffaniak 044d08f2f1 fix: restore upload chunk size 0 to disable chunking (#2734) (#2863) 2026-08-29 09:02:18 -05:00
Graham Steffaniak e071c8df8a updated ffmpeg and dependencies (#2855) 2026-08-28 13:34:13 -05:00
Graham Steffaniak 83393afb31 fix GHSA-vvm6-jwrf-hgmg (#2852) 2026-08-28 13:13:54 -05:00
Graham Steffaniak 47b2c44886 fixed install app bug (#2849) 2026-08-26 15:27:02 -05:00
Graham Steffaniak f7e51f11b8 Merge branch 'staging/v1.5.3' of github.com:gtsteffaniak/filebrowser into staging/v1.5.3 2026-08-21 20:29:22 -05:00
Graham Steffaniak ec4947e634 Merge final v1.5.3 (#2833) 2026-08-21 20:09:39 -05:00
Graham Steffaniak 88ee6424bf Merge branch 'staging/v1.5.3' of github.com:gtsteffaniak/filebrowser into staging/v1.5.3 2026-08-21 20:07:02 -05:00
Graham Steffaniak 67c2908413 fix windows slash regression (#2831) 2026-08-21 20:01:02 -05:00
Graham Steffaniak 78084d5b5a fix windows slash regression 2026-08-21 19:46:05 -05:00
Graham Steffaniak 053571efe2 fix onlyoffice share with passwords (#2830) 2026-08-21 19:18:23 -05:00
Graham Steffaniak ec2b6df30e Fix single file share download (#2829) 2026-08-21 18:57:09 -05:00
Graham Steffaniak 45161017cc Merge branch 'staging/v1.5.2' of github.com:gtsteffaniak/filebrowser into staging/v1.5.2 2026-08-14 19:19:48 -05:00
Graham Steffaniak fcc30ab42d finalize changelog for v1.5.2-stable (#2798) 2026-08-14 19:17:28 -05:00
Graham Steffaniak bbb56b54af updated changelog 2026-08-14 19:07:24 -05:00
Graham Steffaniak 8bc1def989 fix for GHSA-x79q-5hqm-x839 (#2797) 2026-08-14 18:57:06 -05:00
Graham Steffaniak 3e9c4325d5 v1.5.2-stable GHSA-cw65-p35p-633w fix (#2791) 2026-08-14 18:52:24 -05:00
Graham Steffaniak acc3008472 fix GHSA-8m35-wcjh-95q7 (#2792) 2026-08-14 18:51:40 -05:00
Graham Steffaniak 8ed3efbfc9 backport security fix (#2790) 2026-08-14 18:50:13 -05:00
Graham Steffaniak 4655f8985c updated with share bug fix for stable (#2789) 2026-08-14 18:48:13 -05:00
Graham Steffaniak 0f0531cbd7 fix GHSA-rqqq-wv83-rp74 (#2793) 2026-08-14 18:44:51 -05:00
Graham Steffaniak 4126edeb07 fix GHSA-mgqf-5mf5-prfj (#2795) 2026-08-14 18:38:32 -05:00
Graham Steffaniak ca2e1689e0 fix GHSA-9wm6-jcjh-3m8c (#2796) 2026-08-14 18:28:22 -05:00
Graham Steffaniak 6a5ed61751 fix GHSA-4wmj-rq3c-m65v (#2794) 2026-08-14 18:26:36 -05:00
Graham Steffaniak 6a36684892 updated gitignore 2026-08-14 17:54:42 -05:00
Graham Steffaniak 30f21afded fixed webdav api token creation bug (#2740) 2026-08-07 10:40:49 -05:00
64 changed files with 3256 additions and 933 deletions

No files matched your search

-12
View File
@@ -15,18 +15,6 @@ jobs:
go-version: 'stable'
- working-directory: backend
run: go test -race -v ./... -timeout 30s
lint-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- uses: golangci/golangci-lint-action@v7
with:
version: 'v2.1.6'
working-directory: backend
install-mode: goinstall
format-backend:
runs-on: ubuntu-latest
steps:
+2 -1
View File
@@ -1,6 +1,6 @@
*.db
*.db.old
*.sqlite
*.sqlite**
*.bak
*.log
*.mjs
@@ -8,6 +8,7 @@ _old
rice-box.go
.idea/
/backend/backend
/backend/internal
/backend/filebrowser
/backend/filebrowser.exe
frontend/tsconfig.tsbuildinfo
+46
View File
@@ -2,6 +2,52 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.5.5
**BugFixes**:
- OnlyOffice editor failed to load after the CSP security fix; allow configured `integrations.office.url` and `integrations.office.internalUrl` origins in `script-src` ([#2874](https://github.com/gtsteffaniak/filebrowser/issues/2874)).
## v1.5.4-stable
**Security**:
- [High] Stored XSS via HTML preview: `<script>` is no longer preserved in srcdoc, the preview iframe is opaque-origin (no `allow-same-origin`), the session cookie is HttpOnly, and the SPA shell sends a `script-src` CSP inherited by srcdoc frames (GHSA-vvm6-jwrf-hgmg) -- thanks @qrn12580
**New Features**:
- Added option to globally disable the "Install App" message via `frontend.disablePWAInstall`
**Notes**:
- [docker] upgraded ffmpeg version 8.1.2 to 9.0.1
**BugFixes**:
- "install app" message reappears after being cleared on device.
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
- Restored upload chunk size `0` to disable chunking as documented ([#2202](https://github.com/gtsteffaniak/filebrowser/issues/2202)); workaround for iOS 26 multi-chunk upload stalls ([#2734](https://github.com/gtsteffaniak/filebrowser/issues/2734)).
## v1.5.3-stable
**BugFixes**:
- windows backslash inserted into directory URLs causing malformed paths and path escapes from parent (#2815) (#2816)
- root download of a shared directory returns HTTP 500 (#2807) (#2810) (#2821) (#2818)
- OnlyOffice is inaccessible if share has optional password (#2811)
## v1.5.2-stable
**Security**:
- [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds
- [High] Share create/update could set delete/modify/create flags beyond the owner's permissions; POST /api/share with hash could update another user's share (GHSA-x79q-5hqm-x839) -- thanks @pant0m
- [High] PATCH /api/share could re-point a share outside the owner's scope (GHSA-wfjp-qhvc-69wp) -- thanks @maximeborges
- [High] Public upload ACL check used scope-stripped path, bypassing per-folder DENY rules (GHSA-qv53-4557-m65h) -- thanks @hypnguyen1209
- [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo
- [Moderate] Authenticated upload and pause endpoints checked access rules on scope-relative paths, bypassing per-folder DENY rules (GHSA-cw65-p35p-633w) -- thanks @5ud0er
- [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu)
- [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec
- [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209
## v1.5.5
**BugFixes**:
- fix uncustomized (minimal) API tokens creation needed by webdav clients (#2503)
## v1.5.4
**Notes**:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:8.1.2-decode AS ffmpeg
FROM gtstef/ffmpeg:9.0.1-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
+23 -7
View File
@@ -143,7 +143,7 @@ func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *
}
// Combine scope + sanitized path
indexPath := utils.JoinPathAsUnix(userScope, safePath)
indexPath := utils.JoinScopedIndexPath(userScope, safePath)
// Layer 1: USER ACCESS CONTROL
// Quick check: Does THIS user have permission?
if !access.Permitted(idx.Path, indexPath, user.Username) {
@@ -173,7 +173,7 @@ func filterFilesByExt(files []iteminfo.ExtendedItemInfo, hideFileExt string) []i
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
items := Items{}
indexPath, _, topLevelErr := CheckPermissions(opts, access, user)
indexPath, userScope, topLevelErr := CheckPermissions(opts, access, user)
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
if topLevelErr != nil && !accessRulesErr {
return items, topLevelErr
@@ -183,14 +183,18 @@ func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.Use
if idx == nil {
return items, fmt.Errorf("could not get index: %v ", opts.Source)
}
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
infoReq := indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
HideFileExt: opts.HideFileExt,
Expand: true,
SkipExtendedAttrs: true,
})
}
if opts.FollowSymlinks {
infoReq.BoundIndexPath = userScope
}
info, err := idx.GetFileInfo(infoReq)
if err != nil {
return items, err // Path excluded by index rules OR doesn't exist
}
@@ -242,14 +246,18 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
}
// Layer 2: INDEX RULES (global)
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
fileInfoReq := indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
HideFileExt: opts.HideFileExt,
Expand: opts.Expand,
SkipExtendedAttrs: opts.SkipExtendedAttrs,
})
}
if opts.FollowSymlinks {
fileInfoReq.BoundIndexPath = userScope
}
info, err := idx.GetFileInfo(fileInfoReq)
if err != nil {
return response, err // Path excluded by index rules OR doesn't exist
}
@@ -266,7 +274,15 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
// Build response
response.FileInfo = *info
response.RealPath = filepath.Join(idx.Path, indexPath)
if opts.FollowSymlinks && info.Type != "directory" {
realPath, _, err := idx.GetRealPathScoped(userScope, indexPath)
if err != nil {
return response, err
}
response.RealPath = realPath
} else {
response.RealPath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
}
response.Source = opts.Source
if shareStore != nil && user.Permissions.Share && opts.ShowSharedAttr {
for i := range response.Files {
@@ -0,0 +1,57 @@
package files
import (
"errors"
"os"
"path/filepath"
"testing"
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
func setupSymlinkDirEscapeFixture(t *testing.T) (sourceRoot, userScope string) {
t.Helper()
root := t.TempDir()
sourceRoot = filepath.Join(root, "srv")
userDir := filepath.Join(sourceRoot, "user1")
if err := os.MkdirAll(userDir, 0o755); err != nil {
t.Fatal(err)
}
otherDir := filepath.Join(sourceRoot, "otheruser")
if err := os.MkdirAll(otherDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(otherDir, "leaked.txt"), []byte("leaked"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(otherDir, filepath.Join(userDir, "escape_dir")); err != nil {
t.Fatal(err)
}
return sourceRoot, "/user1"
}
func TestGetDirItems_RejectsDirectorySymlinkOutsideUserScope(t *testing.T) {
sourceRoot, userScope := setupSymlinkDirEscapeFixture(t)
sourceName := "test_getdiritems_scope"
indexing.SetTestIndex(sourceName, sourceRoot)
t.Cleanup(indexing.ClearTestIndices)
origCheck := CheckPermissionsFunc
t.Cleanup(func() { CheckPermissionsFunc = origCheck })
CheckPermissionsFunc = func(opts utils.FileOptions, _ *access.Storage, _ *users.User) (string, string, error) {
return utils.JoinPathAsUnix(userScope, opts.Path), userScope, nil
}
_, err := GetDirItems(utils.FileOptions{
Source: sourceName,
Path: "/escape_dir",
FollowSymlinks: true,
}, nil, &users.User{})
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
}
+7 -1
View File
@@ -55,7 +55,13 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
claim.Permissions = perms
claim.BelongsTo = user.ID
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
signedClaims := jwt.Claims(claim)
if minimal {
// Sign only standard JWT claims. The full AuthToken struct always JSON-marshals
// a zero Permissions object, which bloats the token past WebDAV client limits.
signedClaims = claim.MinimalAuthToken
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, signedClaims)
tokenString, err := token.SignedString([]byte(settings.Config.Auth.Key))
if err != nil {
return "", users.AuthToken{}, err
+124
View File
@@ -0,0 +1,124 @@
package auth
import (
"bytes"
"encoding/base64"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
const base64URLAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
func decodeJWTBase64URL(s string) ([]byte, error) {
switch len(s) % 4 {
case 2:
s += "=="
case 3:
s += "="
}
return base64.URLEncoding.DecodeString(s)
}
// equivalentSpelling returns a different compact JWT string with the same decoded signature bytes.
func equivalentSpelling(token string) string {
parts := strings.Split(token, ".")
if len(parts) != 3 {
panic("equivalentSpelling: invalid JWT")
}
header, payload, signature := parts[0], parts[1], parts[2]
orig, err := decodeJWTBase64URL(signature)
if err != nil {
panic("equivalentSpelling: decode canonical signature: " + err.Error())
}
last := signature[len(signature)-1]
for _, c := range base64URLAlphabet {
if byte(c) == last {
continue
}
trial := signature[:len(signature)-1] + string(c)
dec, err := decodeJWTBase64URL(trial)
if err != nil || !bytes.Equal(dec, orig) {
continue
}
respelled := header + "." + payload + "." + trial
if respelled != token {
return respelled
}
}
panic("equivalentSpelling: no alternate spelling found")
}
func invalidSignatureSpelling(token string) string {
parts := strings.Split(token, ".")
header, payload, signature := parts[0], parts[1], parts[2]
idx := strings.IndexByte(base64URLAlphabet, signature[len(signature)-1])
replacement := base64URLAlphabet[((idx&0b110000)+16)%64]
return header + "." + payload + "." + signature[:len(signature)-1] + string(replacement)
}
func parseSessionToken(tokenString string) error {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(settings.Config.Auth.Key), nil
}
var tk users.AuthToken
_, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
return err
}
func TestDecodeStrictRejectsEquivalentSpelling(t *testing.T) {
origKey := settings.Config.Auth.Key
origStrict := jwt.DecodeStrict
settings.Config.Auth.Key = "test-signing-key-strict-decode"
jwt.DecodeStrict = true
t.Cleanup(func() {
settings.Config.Auth.Key = origKey
jwt.DecodeStrict = origStrict
})
user := &users.User{ID: 1, Username: "alice"}
canonical, _, err := MakeSignedTokenAPI(user, "WEB_TOKEN_test", time.Hour, user.Permissions, false)
if err != nil {
t.Fatalf("MakeSignedTokenAPI: %v", err)
}
if err := parseSessionToken(canonical); err != nil {
t.Fatalf("canonical token should parse under strict mode: %v", err)
}
respelled := equivalentSpelling(canonical)
if err := parseSessionToken(respelled); err == nil {
t.Fatal("equivalent spelling should be rejected when jwt.DecodeStrict is true")
}
tampered := invalidSignatureSpelling(canonical)
if err := parseSessionToken(tampered); err == nil {
t.Fatal("tampered signature should be rejected")
}
}
func TestEquivalentSpellingParsesWithoutStrictMode(t *testing.T) {
origKey := settings.Config.Auth.Key
origStrict := jwt.DecodeStrict
settings.Config.Auth.Key = "test-signing-key-strict-decode"
jwt.DecodeStrict = false
t.Cleanup(func() {
settings.Config.Auth.Key = origKey
jwt.DecodeStrict = origStrict
})
user := &users.User{ID: 1, Username: "alice"}
canonical, _, err := MakeSignedTokenAPI(user, "WEB_TOKEN_test", time.Hour, user.Permissions, false)
if err != nil {
t.Fatalf("MakeSignedTokenAPI: %v", err)
}
respelled := equivalentSpelling(canonical)
if err := parseSessionToken(respelled); err != nil {
t.Fatalf("equivalent spelling should parse without strict mode (documents pre-fix behavior): %v", err)
}
}
+1
View File
@@ -23,6 +23,7 @@ var (
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
ErrNotViewable = errors.New("directory or item is not viewable")
ErrPathEscapesScope = errors.New("resolved path escapes scope")
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
+1
View File
@@ -291,6 +291,7 @@ type Frontend struct {
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
LoginButtonText string `json:"loginButtonText"` // text to display on the login button
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
DisablePWAInstall bool `json:"disablePWAInstall"` // disable the PWA install button in the sidebar
}
type StylingConfig struct {
+20
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"golang.org/x/crypto/bcrypt"
)
@@ -72,3 +73,22 @@ func CSPNonce() (string, error) {
}
return base64.StdEncoding.EncodeToString(b), nil
}
// CSPNonceFromData returns an existing cspNonce from map template data, or generates
// and stores one when data is a map[string]interface{}. For other data types it
// generates a fresh nonce without modifying data.
func CSPNonceFromData(data interface{}) (string, error) {
m, ok := data.(map[string]interface{})
if !ok {
return CSPNonce()
}
if nonce, ok := m["cspNonce"].(string); ok && nonce != "" {
return nonce, nil
}
nonce, err := CSPNonce()
if err != nil {
return "", fmt.Errorf("csp nonce: %w", err)
}
m["cspNonce"] = nonce
return nonce, nil
}
+53
View File
@@ -0,0 +1,53 @@
package utils
import "testing"
func TestCSPNonceFromData_ReusesExistingNonce(t *testing.T) {
data := map[string]interface{}{
"cspNonce": "existing-nonce",
}
nonce, err := CSPNonceFromData(data)
if err != nil {
t.Fatalf("CSPNonceFromData: %v", err)
}
if nonce != "existing-nonce" {
t.Fatalf("nonce = %q, want existing-nonce", nonce)
}
}
func TestCSPNonceFromData_GeneratesAndStoresMissingNonce(t *testing.T) {
data := map[string]interface{}{}
nonce, err := CSPNonceFromData(data)
if err != nil {
t.Fatalf("CSPNonceFromData: %v", err)
}
if nonce == "" {
t.Fatal("expected generated nonce")
}
stored, ok := data["cspNonce"].(string)
if !ok || stored != nonce {
t.Fatalf("stored nonce = %q, want %q", stored, nonce)
}
reused, err := CSPNonceFromData(data)
if err != nil {
t.Fatalf("CSPNonceFromData reuse: %v", err)
}
if reused != nonce {
t.Fatalf("reused nonce = %q, want %q", reused, nonce)
}
}
func TestCSPNonceFromData_NonMapGeneratesWithoutMutation(t *testing.T) {
type pageData struct {
Title string
}
data := pageData{Title: "test"}
nonce, err := CSPNonceFromData(data)
if err != nil {
t.Fatalf("CSPNonceFromData: %v", err)
}
if nonce == "" {
t.Fatal("expected generated nonce for non-map data")
}
}
+11 -10
View File
@@ -10,7 +10,7 @@ import (
"hash"
"io"
"os"
"path/filepath"
"path"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
@@ -57,8 +57,8 @@ type FileOptions struct {
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
ShowHidden bool // whether to show hidden files (true = show, false = hide)
ShowPinnedItems bool // whether to show pinned items
ShareHash string // when set, pinned items are loaded from this share link
ShowPinnedItems bool // whether to show pinned items
ShareHash string // when set, pinned items are loaded from this share link
HideFileExt string // Hide files based on extensions (eg: '.lrc' or '.srt')
FollowSymlinks bool // whether to follow symlinks
Only string // whether to only get files or folders
@@ -69,22 +69,23 @@ type FileOptions struct {
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
// Rule 1: Do Not Use User Input in File Paths (without validation)
func SanitizeUserPath(userPath string) (string, error) {
clean := filepath.Clean(userPath)
userPath = strings.ReplaceAll(userPath, `\`, `/`)
clean := path.Clean(userPath)
// Split the path into segments to check for path traversal attempts
// We check if ".." appears as a complete path segment (not just in a filename)
segments := strings.Split(clean, string(filepath.Separator))
// Split the path into segments to check for path traversal attempts.
// We check if ".." appears as a complete path segment (not just in a filename).
segments := strings.Split(clean, "/")
for _, segment := range segments {
// Check if any segment is exactly ".." (path traversal attempt)
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
// Check if any segment is exactly ".." (path traversal attempt).
// This catches any ".." that path.Clean couldn't resolve (i.e., escape attempts).
if segment == ".." {
return "", fmt.Errorf("invalid path: path traversal detected")
}
}
if clean == "." {
return "", fmt.Errorf("invalid path: path must standard index path")
return "", fmt.Errorf("invalid path: path must be a standard index path")
}
return clean, nil
+45
View File
@@ -0,0 +1,45 @@
package utils
import (
"strings"
"testing"
)
func TestSanitizeUserPath_indexPaths(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{"root slash", "/", "/", false},
{"nested", "/foo/bar", "/foo/bar", false},
{"traversal segment", "..", "", true},
{"resolved traversal", "/../secret", "/secret", false},
{"empty", "", "", true},
{"backslash traversal", `..\secret`, "", true},
{"backslash double traversal", `..\..\secret`, "", true},
{"backslash path normalized", `\foo\bar`, "/foo/bar", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SanitizeUserPath(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("SanitizeUserPath(%q) expected error", tt.input)
}
return
}
if err != nil {
t.Fatalf("SanitizeUserPath(%q) unexpected error: %v", tt.input, err)
}
if strings.Contains(got, `\`) {
t.Errorf("SanitizeUserPath(%q) = %q contains backslash", tt.input, got)
}
if got != tt.want {
t.Errorf("SanitizeUserPath(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
+63
View File
@@ -0,0 +1,63 @@
package utils
import (
"path"
"path/filepath"
"strings"
)
// CleanIndexPathSegment normalizes a path segment as if it were rooted, so relative
// ".." components cannot escape via path.Clean on an unrooted input.
func CleanIndexPathSegment(segment string) string {
return strings.TrimPrefix(path.Clean("/"+segment), "/")
}
// JoinIndexPathComponents combines index path segments into a single rooted Unix path.
func JoinIndexPathComponents(relativePath ...string) string {
if len(relativePath) == 0 {
return "/"
}
cleaned := CleanIndexPathSegment(relativePath[0])
for _, p := range relativePath[1:] {
segment := CleanIndexPathSegment(p)
if segment == "" {
continue
}
if cleaned == "" {
cleaned = segment
continue
}
cleaned = cleaned + "/" + segment
}
if cleaned == "" {
return "/"
}
return "/" + cleaned
}
// JoinScopedIndexPath joins a user's index scope with a sanitized path segment.
// Unlike filepath.Join, a leading slash on rel does not discard scope (avoids absolute-path scope bypass).
func JoinScopedIndexPath(scope, rel string) string {
rel = CleanIndexPathSegment(rel)
scope = strings.TrimRight(scope, "/")
if rel == "" {
if scope == "" || scope == "/" {
return "/"
}
return scope
}
if scope == "" || scope == "/" {
return "/" + rel
}
return scope + "/" + rel
}
// JoinUnderSourceRoot maps an index path onto a source filesystem root without treating
// indexPath as a host-absolute path (filepath.Join would discard sourceRoot when indexPath starts with "/").
func JoinUnderSourceRoot(sourceRoot, indexPath string) string {
rel := CleanIndexPathSegment(indexPath)
if rel == "" {
return sourceRoot
}
return filepath.Join(sourceRoot, rel)
}
+76
View File
@@ -0,0 +1,76 @@
package utils
import (
"path/filepath"
"testing"
)
func TestJoinScopedIndexPath_absoluteRelDoesNotDropScope(t *testing.T) {
const scope = "/home/alice"
got := JoinScopedIndexPath(scope, "/etc/passwd")
want := "/home/alice/etc/passwd"
if got != want {
t.Fatalf("JoinScopedIndexPath(%q, /etc/passwd) = %q, want %q", scope, got, want)
}
}
func TestJoinScopedIndexPath_traversalResolvedUnderScope(t *testing.T) {
const scope = "/home/alice"
got := JoinScopedIndexPath(scope, "/../../../etc/passwd")
want := "/home/alice/etc/passwd"
if got != want {
t.Fatalf("JoinScopedIndexPath(%q, /../../../etc/passwd) = %q, want %q", scope, got, want)
}
}
func TestJoinScopedIndexPath_unrootedTraversalResolvedUnderScope(t *testing.T) {
const scope = "/home/alice"
got := JoinScopedIndexPath(scope, "../../etc/passwd")
want := "/home/alice/etc/passwd"
if got != want {
t.Fatalf("JoinScopedIndexPath(%q, ../../etc/passwd) = %q, want %q", scope, got, want)
}
}
func TestJoinScopedIndexPath_rootScope(t *testing.T) {
got := JoinScopedIndexPath("/", "projects/acme/file.txt")
if got != "/projects/acme/file.txt" {
t.Fatalf("got %q want /projects/acme/file.txt", got)
}
}
func TestJoinUnderSourceRoot_absoluteIndexPathStaysUnderSource(t *testing.T) {
const sourceRoot = "/srv/mount"
got := JoinUnderSourceRoot(sourceRoot, "/etc/passwd")
want := filepath.Join(sourceRoot, "etc/passwd")
if got != want {
t.Fatalf("JoinUnderSourceRoot = %q, want %q", got, want)
}
}
func TestJoinUnderSourceRoot_relativeIndexPath(t *testing.T) {
const sourceRoot = "/srv/mount"
got := JoinUnderSourceRoot(sourceRoot, "/home/alice/projects/foo")
want := filepath.Join(sourceRoot, "home/alice/projects/foo")
if got != want {
t.Fatalf("JoinUnderSourceRoot = %q, want %q", got, want)
}
}
func TestJoinUnderSourceRoot_unrootedTraversalStaysUnderSource(t *testing.T) {
const sourceRoot = "/srv/mount"
got := JoinUnderSourceRoot(sourceRoot, "../../etc/passwd")
want := filepath.Join(sourceRoot, "etc/passwd")
if got != want {
t.Fatalf("JoinUnderSourceRoot(%q, ../../etc/passwd) = %q, want %q", sourceRoot, got, want)
}
}
func TestJoinScopedIndexPath_authorizationMustUseScopedPath(t *testing.T) {
const userScope = "/home/alice"
requestPath := "/etc/passwd"
scoped := JoinScopedIndexPath(userScope, requestPath)
if scoped == requestPath {
t.Fatalf("access checks must use scoped path %q, not raw request path %q", scoped, requestPath)
}
}
+22
View File
@@ -0,0 +1,22 @@
package share
import "github.com/gtsteffaniak/filebrowser/backend/database/users"
// ClampShareEditable limits client-requested share flags to what the owner is allowed.
func ClampShareEditable(ownerPerms users.Permissions, cs *CommonShare) {
if cs == nil {
return
}
if !ownerPerms.Modify {
cs.AllowModify = false
}
if !ownerPerms.Create {
cs.AllowCreate = false
}
if !ownerPerms.Delete {
cs.AllowDelete = false
}
if !ownerPerms.Download {
cs.DisableDownload = true
}
}
@@ -0,0 +1,39 @@
package share
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
func TestClampShareEditable(t *testing.T) {
t.Parallel()
ownerPerms := users.Permissions{
Modify: false,
Create: true,
Delete: false,
Download: false,
}
cs := &CommonShare{
AllowModify: true,
AllowCreate: true,
AllowDelete: true,
DisableDownload: false,
}
ClampShareEditable(ownerPerms, cs)
if cs.AllowModify {
t.Fatal("expected AllowModify clamped to false")
}
if !cs.AllowCreate {
t.Fatal("expected AllowCreate to remain true")
}
if cs.AllowDelete {
t.Fatal("expected AllowDelete clamped to false")
}
if !cs.DisableDownload {
t.Fatal("expected DisableDownload forced true when owner lacks download")
}
}
+73 -73
View File
@@ -1,6 +1,6 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.25.0
go 1.26.0
require (
github.com/asdine/storm/v3 v3.2.1
@@ -11,57 +11,58 @@ require (
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-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
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.49
github.com/mattn/go-sqlite3 v1.14.50
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
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.54.0
golang.org/x/image v0.44.0
golang.org/x/mod v0.38.0
golang.org/x/net v0.57.0
golang.org/x/crypto v0.55.0
golang.org/x/image v0.45.0
golang.org/x/mod v0.40.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.48.0
golang.org/x/tools v0.49.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.55.0
modernc.org/sqlite v1.57.0
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
charm.land/lipgloss/v2 v2.0.3 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/golib v0.6.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/Abirdcfly/dupword v0.1.7 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
github.com/Antonboom/errname v1.1.2 // indirect
github.com/Antonboom/nilnil v1.1.2 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.65.3 // indirect
github.com/alecthomas/chroma/v2 v2.24.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/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
@@ -74,22 +75,23 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bits-and-blooms/bitset v1.24.6 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.8.0 // indirect
github.com/bombsimon/wsl/v5 v5.9.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/butuzov/mirror v1.3.3 // indirect
github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
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-20251205161215-1948445e3318 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // 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
github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -100,9 +102,8 @@ require (
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
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
@@ -110,26 +111,26 @@ require (
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/firefart/nonamedreturns v1.0.8 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
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.20 // indirect
github.com/ghostiam/protogetter v0.3.21 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-critic/go-critic v0.14.4 // 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-openapi/swag/conv v0.28.0 // indirect
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
github.com/go-openapi/swag/loading v0.28.0 // indirect
github.com/go-openapi/swag/pools v0.28.0 // indirect
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
github.com/go-openapi/jsonreference v1.0.1 // indirect
github.com/go-openapi/spec v0.22.11 // 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
github.com/go-openapi/swag/pools v0.29.1 // indirect
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -140,20 +141,22 @@ require (
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
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/hugo v0.149.1 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/gohugoio/hugo v0.164.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260713102120-857a528af641 // indirect
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
github.com/golang/protobuf v1.5.3 // 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-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.12.2 // indirect
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // 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
@@ -174,38 +177,36 @@ require (
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.10.0 // indirect
github.com/jgautheron/goconst v1.11.0 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kisielk/errcheck v1.20.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
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.8.0 // indirect
github.com/ldez/gomoddirectives v0.9.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.7.2 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.6.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // 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.23 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
@@ -216,11 +217,10 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
@@ -230,35 +230,35 @@ require (
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.2.0 // indirect
github.com/raeperd/recvcheck v0.3.0 // 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.14.1 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
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.26.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/securego/gosec/v2 v2.28.0 // indirect
github.com/sirupsen/logrus v1.10.1 // 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
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
@@ -269,7 +269,7 @@ require (
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.4.2 // 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 v0.0.0-20220910002029-abceb7e1c41e // indirect
@@ -280,24 +280,24 @@ require (
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.augendre.info/fatcontext v0.10.0 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.74.4 // indirect
honnef.co/go/tools v0.8.1 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
modernc.org/memory v1.12.1 // indirect
mvdan.cc/gofumpt v0.11.0 // indirect
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
+223 -224
View File
@@ -1,9 +1,9 @@
4d63.com/gocheckcompilerdirectives v1.3.0 h1:Ew5y5CtcAAQeTVKUVFrE7EwHMrTO6BggtEj8BZSjZ3A=
4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY=
4d63.com/gocheckcompilerdirectives v1.4.0 h1:ZLq62rbGWVmQhiZ8kuNVIT/M09xCSTdJz9K3xOdT/CY=
4d63.com/gocheckcompilerdirectives v1.4.0/go.mod h1:9ZOAiMOjqC/nRwci2fcUXVHUNLG/cH6r6rhUh+jTFtQ=
4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU=
4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -44,21 +44,23 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y=
dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI=
dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo=
dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE=
dev.gaijin.team/go/exhaustruct/v5 v5.0.3 h1:yOeA7DNjlT8y4yfmN6nWWYYggA13N523YAj9/TXbuTM=
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=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8=
github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
github.com/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ=
github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4=
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=
github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY=
github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY=
github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc=
github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q=
github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ=
github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ=
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
github.com/AlwxSin/noinlineerr v1.0.6 h1:KAvuxunTe9QxvqrFB7nZTdb/7Wzas4AvifslTnG0Ld8=
github.com/AlwxSin/noinlineerr v1.0.6/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc=
github.com/Antonboom/errname v1.1.2 h1:dxwONZJua3VB8Xh/VaCjqAcqF645sWWv7xj26zy7tdQ=
github.com/Antonboom/errname v1.1.2/go.mod h1:YeZIpgLMxT+SNkruGgYkLhzq/9vs3fsolTZegKaKDZI=
github.com/Antonboom/nilnil v1.1.2 h1:aNlFuJhaEseXe4fHO3xbjXlSeEiQVYa2lEkWD2s2hAY=
github.com/Antonboom/nilnil v1.1.2/go.mod h1:0ynwvphOLmAuMwTNDyBnDZmSwZoDpcFXmUHmzoHH2WA=
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
@@ -69,12 +71,16 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/ClickHouse/clickhouse-go-linter v1.2.0 h1:zbm174up3hTKjp0wKZVnTzRiG7tSF5XZF0FJG/MuCBI=
github.com/ClickHouse/clickhouse-go-linter v1.2.0/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4=
github.com/ClickHouse/clickhouse-go-linter v1.2.1 h1:zGEKIyd5YL08ieWG/LOUmlau2DxbxPVOfAeo+4Jz3ck=
github.com/ClickHouse/clickhouse-go-linter v1.2.1/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4=
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g=
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
@@ -85,12 +91,12 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsu
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.65.3 h1:7NUAqofZuSf5vZbVoZpdTr/4LuKkOCj1JTiMrOdUs7A=
github.com/air-verse/air v1.65.3/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.67.4 h1:pkWBQues0094VMgUZ9MboNfS1vyCBEXeU6QxuD6/PwU=
github.com/air-verse/air v1.67.4/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
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/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
@@ -114,8 +120,6 @@ github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEW
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI=
@@ -140,30 +144,34 @@ github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7
github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU=
github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI=
github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5kujw=
github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q=
github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc=
github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw=
github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044=
github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k=
github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8=
github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8=
github.com/bep/lazycache v0.8.0/go.mod h1:BQ5WZepss7Ko91CGdWz8GQZi/fFnCcyWupv8gyTeKwk=
github.com/bep/golocales v0.2.0 h1:4H1H5UPw3ainpj5zykeEfiMRQngyaIC/t+I4Dvn+fvE=
github.com/bep/golocales v0.2.0/go.mod h1:Hl78nje8mNL3LzLeJvYN9NsIZgyFJGrGfvgO9r1+mwE=
github.com/bep/goportabletext v0.2.0 h1:CZ9f8jADBWqHwBymQiJJPCTSV/tHSA+PYzlUf86Yze0=
github.com/bep/goportabletext v0.2.0/go.mod h1:xDeA5+qcgKzJq6Q6XjAiBKtxLD3Yn7f6XP4joD3J3qU=
github.com/bep/helpers v0.12.0 h1:tD6V2DQW0B+FUynF2etR/106S/TO9akm+vA/Hk24GxY=
github.com/bep/helpers v0.12.0/go.mod h1:PfE7MGdA8sSQ19nyDh4tYbs5rAlStlJaDI21f/fnNps=
github.com/bep/imagemeta v0.17.2 h1:fDyXM1eAqCfBeqGLqS6UsN4OfuLM0cdu70KuLCehjOg=
github.com/bep/imagemeta v0.17.2/go.mod h1:+Hlp195TfZpzsqCxtDKTG6eWdyz2+F2V/oCYfr3CZKA=
github.com/bep/lazycache v0.8.1 h1:ko6ASLjkPxyV5DMWoNNZ8B2M0weyjqXX8IZkjBoBtvg=
github.com/bep/lazycache v0.8.1/go.mod h1:pbEiFsZoq7cLXvrTll0AHOPEurB1aGGxx4jKjOtlx9w=
github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE=
github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro=
github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
github.com/bep/overlayfs v0.11.0 h1:aymHDGC0CHpvn0XvTfgpK6skCp16oMi+tdUF32l6pPs=
github.com/bep/overlayfs v0.11.0/go.mod h1:L+ggdoKm+Y7Xb4a1osd+/LOPG4qsY62snqRqJH5Mspc=
github.com/bep/textandbinarywriter v0.1.0 h1:KXmXsRN2Uhwhm1G3e/snM8+5SPQBJrCEpIosdIBR3po=
github.com/bep/textandbinarywriter v0.1.0/go.mod h1:dAcHveajlWWU7PXhp6Dn4PIAYDg2H13Huif9xMS2w8w=
github.com/bep/tmc v0.6.0 h1:5zWy4L+3gS+Kk8czzLC4g7ETaC3wkX9ZtTRdAdL8V4s=
github.com/bep/tmc v0.6.0/go.mod h1:SNHxc3o2WSNMAYqJcAO0rxFY+pbhZzMwjIHe5xaAue0=
github.com/bits-and-blooms/bitset v1.24.6 h1:qcrftZUVBIwfs+m+nhoCBAPT+ZPZZjti8SbHbDQQkZ4=
github.com/bits-and-blooms/bitset v1.24.6/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w=
github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs=
github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o=
github.com/bombsimon/wsl/v5 v5.9.0 h1:WCrgZ7RQnZO5oEwbVTlYgBdU3wL294kR1BSWV8vTfsU=
github.com/bombsimon/wsl/v5 v5.9.0/go.mod h1:kjo4HiAV5FDkHC8/uzJq9mBffEEd6WT/nvN7DoMovDM=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -173,8 +181,8 @@ github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDw
github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I=
github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4=
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
github.com/butuzov/mirror v1.3.3 h1:v0RsWBhfFc1RQqE/f3sHpSttKDtodFn0gFmtYyD4/hA=
github.com/butuzov/mirror v1.3.3/go.mod h1:h9BzzwYnTiHO0GzgvaTqIg7VSsOUhdIv51cHFFBmX1w=
github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
@@ -188,10 +196,10 @@ 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-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/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=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -227,16 +235,17 @@ github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEy
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
@@ -264,37 +273,35 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
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/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/evanw/esbuild v0.28.1 h1:ds+yuRyUaZGx++GR56CrCeuXh8PVhVM4xq8v7PNELFc=
github.com/evanw/esbuild v0.28.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo=
github.com/firefart/nonamedreturns v1.0.8 h1:iB32Dl17zJl1zlVEj/WlUWgx0HiRyQ85OUw1WHa4/II=
github.com/firefart/nonamedreturns v1.0.8/go.mod h1:vxFNvm5AfP/8rgAKFzYmnqx0yp1HjrYsErZ9pHPTznA=
github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJtW1M=
github.com/gen2brain/go-fitz v1.28.2/go.mod h1:pY2hqAjp9Zy7qfPI2gwbJMHBFAdZpVXOLrRxD82l3Bs=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=
github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
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/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.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
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-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=
@@ -318,31 +325,31 @@ 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-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/jsonreference v1.0.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38=
github.com/go-openapi/jsonreference v1.0.1/go.mod h1:dYplQXa6p5lXprLcJ8LE2iU7vNpXsAHDQ5ZAgL+Qx3A=
github.com/go-openapi/spec v0.22.11 h1:/ZW28n4PCghbBpwjmvoMeyCeDwd6pIe715Ov8T3kAaQ=
github.com/go-openapi/spec v0.22.11/go.mod h1:Ypu+u1KTejey7/QCr3j1ChdjwhGgXuklbH7l8+YP1J4=
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=
github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c=
github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg=
github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc=
github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw=
github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg=
github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc=
github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0=
github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc=
github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M=
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-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=
@@ -351,8 +358,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
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-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
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-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
@@ -377,10 +384,10 @@ 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/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
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=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@@ -394,22 +401,22 @@ github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs=
github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
github.com/gohugoio/hugo v0.149.1 h1:uWOc8Ve4h4e48FyYhBquRoHCJviyxA5yGrFJLT48yio=
github.com/gohugoio/hugo v0.149.1/go.mod h1:HS6BP6e8FGxungP4CHC3zeLDvhBLnTJIjHJZWTZjs7o=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0 h1:dco+7YiOryRoPOMXwwaf+kktZSCtlFtreNdiJbETvYE=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0/go.mod h1:CRrxQTKeM3imw+UoS4EHKyrqB7Zp6sAJiqHit+aMGTE=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
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 v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
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.164.0 h1:oDElnVJNvDMpEOeoxBjzW8JTFk7u+gJj2kG74BsfXlM=
github.com/gohugoio/hugo v0.164.0/go.mod h1:QPBCN6Gntmm6YrYJEnTasaZjnMLRkv8YoyCOw7mo9JE=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -417,8 +424,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20260713102120-857a528af641 h1:cpoobkgVGCE6bC5kJkOfwfM6hAtZuvoBjnCLECxG4AU=
github.com/golang/geo v0.0.0-20260713102120-857a528af641/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 h1:m+G0ip1+N4CF0ex34SeojAon6htIIBwvzsyXNx1fGWg=
github.com/golang/geo v0.0.0-20260818125358-b200a1149890/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -452,14 +459,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-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint/v2 v2.12.2 h1:7+d1uY0bq1MU2UV3R5pW5Q7QWdcoq4naMRXM+gsJKrs=
github.com/golangci/golangci-lint/v2 v2.12.2/go.mod h1:opqHHuIcTG2R+4akzWMd4o1BnD9/1LcjICWOujr91U8=
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.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=
@@ -503,8 +512,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -566,14 +575,12 @@ github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/jgautheron/goconst v1.10.0 h1:Ptt+OoE4NaEWKhLrWrrN3IpZdGLiqaf7WLnEX/iv4Jw=
github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc=
github.com/jgautheron/goconst v1.11.0 h1:KgN90z5qXt5f0Uzf3cWXev3hfMMFUyNeKdpkSBRvLDk=
github.com/jgautheron/goconst v1.11.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc=
github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -587,8 +594,8 @@ github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/errcheck v1.20.0 h1:9rwHBNKzd4wkDWcROy3DvFGNqEPlkxBg305rvk7HabI=
github.com/kisielk/errcheck v1.20.0/go.mod h1:O+f80MKNwX8Oor2jwgpeQ9An7uJm+hRSgT+h22knRJU=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
@@ -614,12 +621,10 @@ 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.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
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.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk=
github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q=
github.com/ldez/gomoddirectives v0.9.0 h1:2YV/EX7nVlWL4jySusYTzBKHuE3D2fgcRsQuMa3yIoo=
github.com/ldez/gomoddirectives v0.9.0/go.mod h1:DdZzfm9MdXCjn2/UGYXCFfo+tzrp2Ib4iD2Q0kIJkwE=
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk=
@@ -632,14 +637,12 @@ github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE=
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww=
@@ -656,14 +659,14 @@ github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
@@ -679,8 +682,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -699,22 +700,24 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8=
github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps=
github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8=
github.com/nunnatsa/ginkgolinter v0.24.0/go.mod h1:2ZMRuzX6+3XXyY6UZOwb6n+MCocVGbkIsDBC4vuWz5c=
github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg=
github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0=
github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg=
github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
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/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=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
@@ -726,17 +729,14 @@ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2D
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
@@ -772,15 +772,15 @@ 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.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI=
github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=
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/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=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -794,22 +794,22 @@ github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7
github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0=
github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw=
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.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4=
github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE=
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/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
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=
@@ -818,8 +818,8 @@ github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQyt
github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
@@ -836,13 +836,13 @@ github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP
github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
@@ -851,20 +851,21 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tdewolff/minify/v2 v2.24.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo=
github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0=
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
@@ -883,12 +884,10 @@ github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
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.4.2 h1:06Vq5RKVYThBsj0Bnw4oasMjD1r+7CE/bcKOA8dVSvg=
github.com/uudashr/iface v1.4.2/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/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
@@ -909,8 +908,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
@@ -923,8 +922,8 @@ go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4=
go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I=
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
go.augendre.info/fatcontext v0.10.0 h1:HhFopmivh8U1+AU7f0kuwUeg2eiIns7YsGQOMHwSJ90=
go.augendre.info/fatcontext v0.10.0/go.mod h1:pqpGvA9GlrXy+aXkp8L2dKz12Zp4g2FhzcAtwToU+2w=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
@@ -951,8 +950,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -967,12 +966,12 @@ golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDME
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-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f h1:+lI8cDJ4uceLipg2f1ODay7fEuLkk0BIHXd6PB8icxo=
golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
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/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -995,8 +994,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1035,8 +1034,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1119,8 +1118,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1173,8 +1172,8 @@ golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0t
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1260,8 +1259,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -1289,40 +1288,40 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
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.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
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.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
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.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI=
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU=
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-20260823230713-2fa3d841b0c8 h1:Re1NRyLpiAt9kB+ImaaoapwWiQXrKwER4tY8fLOkDew=
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8/go.mod h1:MrS/+zJ1M2xvGXhKktiHbNQeQPyg3Qel+KkzT+f7/i4=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+6 -2
View File
@@ -45,8 +45,8 @@ func createApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusBadRequest, fmt.Errorf("api token duration must be valid")
}
// For full tokens (minimal=false), permissions are required in the claim
// For minimal tokens (minimal=true), permissions are not in the token
// For full tokens (minimal=false), permissions are embedded in the JWT claim.
// For minimal tokens (minimal=true), only standard JWT claims are included.
var permissions users.Permissions
if !minimal {
// Parse permissions from the query parameter
@@ -60,6 +60,10 @@ func createApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestCon
Realtime: strings.Contains(permissionsStr, "realtime") && d.user.Permissions.Realtime,
Download: strings.Contains(permissionsStr, "download") && d.user.Permissions.Download,
}
if !permissions.Api && !permissions.Admin && !permissions.Modify && !permissions.Delete &&
!permissions.Create && !permissions.Share && !permissions.Realtime && !permissions.Download {
minimal = true
}
}
// Convert the duration string to an int64
+18 -24
View File
@@ -252,22 +252,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
logger.Errorf("Failed to revoke token on logout: %v", err)
}
// Clear the authentication cookie by setting it to expire in the past
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: "",
Domain: strings.Split(host, ":")[0],
Path: "/",
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0), // Expire immediately
MaxAge: -1, // Delete cookie
}
http.SetCookie(w, cookie)
http.SetCookie(w, sessionCookie(r, "", time.Unix(0, 0), -1, http.SameSiteStrictMode))
logoutUrl := fmt.Sprintf("%vlogin", config.Server.BaseURL) // Default fallback
if d.user != nil && d.user.LoginMethod == users.LoginMethodProxy {
@@ -435,20 +420,29 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
return 200, nil
}
// setSessionCookie - sets the authentication token as an HTTP cookie
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
const sessionCookieName = "filebrowser_quantum_jwt"
// sessionCookie builds the JWT cookie. HttpOnly prevents srcdoc XSS from reading
// document.cookie; Secure is set when the request is HTTPS (including via proxy).
func sessionCookie(r *http.Request, token string, expiresTime time.Time, maxAge int, sameSite http.SameSite) *http.Cookie {
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
return &http.Cookie{
Name: sessionCookieName,
Value: token,
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
Domain: strings.Split(host, ":")[0],
Path: "/",
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
SameSite: sameSite,
HttpOnly: true,
Secure: getScheme(r) == "https",
Expires: expiresTime,
MaxAge: maxAge,
}
http.SetCookie(w, cookie)
}
// setSessionCookie - sets the authentication token as an HTTP cookie
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
http.SetCookie(w, sessionCookie(r, token, expiresTime, 0, http.SameSiteStrictMode))
}
+39 -45
View File
@@ -2,14 +2,15 @@ package http
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -173,13 +174,13 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
// modify all filepaths for user scope
if d.share == nil {
userscope, err = d.user.GetScopeForSourceName(source)
if err != nil {
if err != nil || userscope == "" {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
// Try to get document ID for error logging
idx := indexing.GetIndex(source)
if idx != nil {
tempPath := utils.JoinPathAsUnix(userscope, firstFilePath)
tempPath := utils.JoinScopedIndexPath(userscope, firstFilePath)
if realPath, _, realErr := idx.GetRealPath(tempPath); realErr == nil {
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
@@ -190,10 +191,10 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
}
}
return http.StatusForbidden, err
return http.StatusForbidden, fmt.Errorf("user has no access to source: %s", source)
}
for i, filePath := range fileList {
fileList[i] = utils.JoinPathAsUnix(userscope, filePath)
fileList[i] = utils.JoinScopedIndexPath(userscope, filePath)
}
}
firstFilePath = fileList[0]
@@ -207,22 +208,42 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
realPath, isDir, err := idx.GetRealPath(firstFilePath)
if err != nil {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not resolve path: %s - %v", firstFilePath, err))
}
}
var bound string
if d.share != nil {
if d.share.Path == "" {
return http.StatusForbidden, fmt.Errorf("share has no path scope")
}
return http.StatusInternalServerError, err
bound = d.share.Path
} else {
bound = userscope
}
// ** Single file download with Content-Length **
if len(fileList) == 1 && !isDir {
if len(fileList) == 1 {
fd, fileInfo, openErr := idx.OpenScopedPath(bound, firstFilePath)
if openErr != nil {
if errors.Is(openErr, liberrors.ErrPathEscapesScope) {
return http.StatusForbidden, openErr
}
if isOnlyOffice {
if docId, _ := getOnlyOfficeId(filepath.Join(idx.Path, firstFilePath)); docId != "" {
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not open file: %s - %v", firstFilePath, openErr))
}
}
}
return http.StatusInternalServerError, openErr
}
defer fd.Close()
realPath := fd.Name()
if fileInfo.IsDir() {
return BuildAndStreamArchive(w, r, d, source, fileList)
}
// Get document ID and log context for OnlyOffice downloads
if isOnlyOffice {
documentId, _ = getOnlyOfficeId(realPath)
@@ -231,11 +252,10 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
}
// Verify access control before opening the file (direct rule check)
// Verify access control before serving the file (direct rule check)
if d.share == nil && store.Access != nil {
if !store.Access.Permitted(idx.Path, firstFilePath, d.user.Username) {
logger.Debugf("user %s denied access to path %s", d.user.Username, firstFilePath)
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - access denied by rule: %s", firstFilePath))
@@ -244,28 +264,6 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
}
fd, err2 := os.Open(realPath)
if err2 != nil {
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not open file: %s - %v", firstFilePath, err2))
}
return http.StatusInternalServerError, err2
}
defer fd.Close()
// Get file size
fileInfo, err2 := fd.Stat()
if err2 != nil {
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not get file info: %s - %v", firstFilePath, err2))
}
return http.StatusInternalServerError, err2
}
// Send success log for OnlyOffice downloads
if isOnlyOffice && logContext != nil {
logger.Infof("OnlyOffice Server is downloading file: %s (documentId: %s)",
@@ -279,13 +277,9 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
setContentDisposition(w, r, fileName)
w.Header().Set("Cache-Control", "private")
w.Header().Set("X-Content-Type-Options", "nosniff")
// video scrubbing, etc.
// Note: http.ServeContent will respect our already-set Content-Disposition header
var reader io.ReadSeeker = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
+1
View File
@@ -191,6 +191,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("GET /media/metadata", withUser(metadataHandler))
api.HandleFunc("GET /media/lyrics", withUser(lyricsHandler))
publicApi.HandleFunc("GET /media/metadata", withHashFile(publicMetadataHandler))
publicApi.HandleFunc("GET /media/subtitles", withHashFile(publicSubtitlesHandler))
publicApi.HandleFunc("GET /media/lyrics", withHashFile(publicLyricsHandler))
// ========================================
+76
View File
@@ -262,3 +262,79 @@ func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
return renderJSON(w, r, map[string]any{"lyrics": lyrics})
}
// publicSubtitlesHandler is the share-link variant of subtitlesHandler.
// @Summary Get subtitle content (public share)
// @Tags Shares
// @Produce text/plain
// @Param hash query string true "Share hash"
// @Param path query string false "Path within the share"
// @Param name query string true "Subtitle track name"
// @Param embedded query bool false "Whether this is an embedded stream"
// @Success 200 {string} string "Raw subtitle content"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Not found"
// @Router /public/api/media/subtitles [get]
func publicSubtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
}
name := r.URL.Query().Get("name")
embedded := r.URL.Query().Get("embedded") == "true"
if name == "" {
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
}
sourceCfg, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: d.IndexPath,
Source: sourceCfg.Name,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
ShowHidden: d.share.ShowHidden,
HideFileExt: d.user.HideFileExt,
FollowSymlinks: false,
SkipExtendedAttrs: false,
}, store.Access, d.shareUser, store.Share)
if err != nil {
return errToStatus(err), err
}
if !strings.HasPrefix(fileInfo.Type, "video") {
return http.StatusNotFound, fmt.Errorf("file is not a video")
}
track := findSubtitleTrack(fileInfo.Subtitles, name, embedded)
if track == nil {
return http.StatusNotFound, fmt.Errorf("subtitle track '%s' not found", name)
}
var content string
if !embedded {
subtitlePath := filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
content, err = utils.GetSubtitleSidecarContent(subtitlePath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
}
} else {
if track.Index == nil {
return http.StatusNotFound, fmt.Errorf("embedded subtitle track '%s' not found", name)
}
content, err = ffmpeg.ExtractSubtitleContent(fileInfo.RealPath, *track.Index)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("Cache-Control", "no-store")
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
return http.StatusOK, nil
}
+18 -6
View File
@@ -8,7 +8,6 @@ import (
"net"
"net/http"
"net/url"
"path/filepath"
"runtime"
"slices"
"strings"
@@ -58,7 +57,6 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if err != nil && inputPath != "" {
return http.StatusBadRequest, err
}
path = filepath.ToSlash(path)
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
@@ -123,7 +121,8 @@ func withHashFileHelper(fn handleFunc) handleFunc {
// skip file fetch for certain apis
if (r.Method == "POST" && strings.Contains(r.URL.Path, "/resources")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) {
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/subtitles")) {
return fn(w, r, data)
}
file, err := FileInfoFasterFunc(utils.FileOptions{
@@ -201,7 +200,10 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
data.token = tokenString
var tk users.AuthToken
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
// Validate signature without enforcing exp/iat so expired session JWTs can still yield
// user context on public-share routes; revocation is checked explicitly below.
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
token, err := parser.ParseWithClaims(tokenString, &tk, keyFunc)
if err != nil {
return nil
}
@@ -210,8 +212,18 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
return nil
}
// Token is valid (but might be expired or revoked)
// Try to get the user regardless of expiration status
if auth.IsRevokedApiToken(store.Access, tokenString) {
return nil
}
if tk.BelongsTo == 0 {
userID, found := store.Access.GetUserIDFromToken(tokenString)
if !found {
return nil
}
tk.BelongsTo = userID
}
user, err := store.Users.Get(tk.BelongsTo)
if err != nil {
logger.Errorf("Failed to get user with ID %v: %v", tk.BelongsTo, err)
+219 -3
View File
@@ -1,6 +1,7 @@
package http
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -80,10 +81,10 @@ func TestWithAdminHelper(t *testing.T) {
Permissions: users.Permissions{Admin: false}, // Non-admin user
}
// Save the users to the mock database
if err := store.Users.Save(adminUser, true, true); err != nil {
if err := store.Users.Save(adminUser, false, false); err != nil {
t.Fatal("failed to save admin user:", err)
}
if err := store.Users.Save(nonAdminUser, true, true); err != nil {
if err := store.Users.Save(nonAdminUser, false, false); err != nil {
t.Fatal("failed to save non-admin user:", err)
}
// Test cases for different scenarios
@@ -144,6 +145,162 @@ func TestWithAdminHelper(t *testing.T) {
}
}
func TestPublicShare_RejectsRevokedJWT(t *testing.T) {
setupTestEnv(t)
victim := &users.User{
Username: "victim",
Permissions: users.Permissions{Api: true},
Scopes: []users.SourceScope{
{Name: "srv", Scope: "/"},
},
}
if err := store.Users.Save(victim, false, false); err != nil {
t.Fatal("failed to create victim user:", err)
}
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
if err := store.Settings.Save(&settings.Settings{Auth: settings.Auth{Key: "key"}}); err != nil {
t.Fatal(err)
}
tokenString, _, err := auth.MakeSignedTokenAPI(victim, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, victim.Permissions, false)
if err != nil {
t.Fatalf("failed to issue token: %v", err)
}
shareLink := &share.Link{
Hash: "revoked_jwt_hash",
UserID: victim.ID,
CommonShare: share.CommonShare{
Source: "/srv",
Path: "/",
AllowedUsernames: []string{"victim"},
},
}
if err := store.Share.Save(shareLink); err != nil {
t.Fatal("failed to create share:", err)
}
handler := withHashFile(publicGetResourceHandler)
makeRequest := func(jwt string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=revoked_jwt_hash&path=/", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: jwt,
})
handler.ServeHTTP(recorder, req)
return recorder
}
rec := makeRequest(tokenString)
if rec.Code != http.StatusOK {
t.Fatalf("valid token: expected status %d, got %d", http.StatusOK, rec.Code)
}
if err := auth.RevokeApiToken(store.Access, tokenString); err != nil {
t.Fatalf("failed to revoke token: %v", err)
}
rec = makeRequest(tokenString)
if rec.Code == http.StatusOK {
t.Fatalf("revoked token: expected non-200 status, got %d (JWT resurrection bypass)", rec.Code)
}
}
func TestExtractUserFromExpiredToken_RejectsRevokedJWT(t *testing.T) {
_, tokenString := issueExtractUserTestToken(t, time.Hour*2)
if err := auth.RevokeApiToken(store.Access, tokenString); err != nil {
t.Fatalf("failed to revoke token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
})
data := &requestContext{}
if got := extractUserFromExpiredToken(req, data); got != nil {
t.Fatalf("extractUserFromExpiredToken() = user %q, want nil for revoked token", got.Username)
}
}
func TestExtractUserFromExpiredToken_AcceptsExpiredNonRevoked(t *testing.T) {
user, tokenString := issueExtractUserTestToken(t, -time.Hour)
req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
})
data := &requestContext{}
got := extractUserFromExpiredToken(req, data)
if got == nil {
t.Fatal("extractUserFromExpiredToken() = nil, want user for expired non-revoked token")
}
if got.Username != user.Username {
t.Fatalf("extractUserFromExpiredToken() username = %q, want %q", got.Username, user.Username)
}
}
func TestExtractUserFromExpiredToken_RejectsRevokedExpired(t *testing.T) {
_, tokenString := issueExtractUserTestToken(t, -time.Hour)
if err := auth.RevokeApiToken(store.Access, tokenString); err != nil {
t.Fatalf("failed to revoke token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
})
data := &requestContext{}
if got := extractUserFromExpiredToken(req, data); got != nil {
t.Fatalf("extractUserFromExpiredToken() = user %q, want nil for revoked expired token", got.Username)
}
}
func issueExtractUserTestToken(t *testing.T, duration time.Duration) (*users.User, string) {
t.Helper()
setupTestEnv(t)
user := &users.User{
Username: "victim",
Permissions: users.Permissions{Api: true},
}
if err := store.Users.Save(user, false, false); err != nil {
t.Fatal("failed to create user:", err)
}
got, err := store.Users.Get(user.ID)
if err != nil {
t.Fatal("failed to load user:", err)
}
got.Permissions = users.Permissions{Api: true}
if err = store.Users.Save(got, false, false); err != nil {
t.Fatal("failed to set user permissions:", err)
}
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
if err = store.Settings.Save(&settings.Settings{Auth: settings.Auth{Key: "key"}}); err != nil {
t.Fatal(err)
}
tokenString, _, err := auth.MakeSignedTokenAPI(got, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), duration, got.Permissions, false)
if err != nil {
t.Fatalf("failed to issue token: %v", err)
}
return got, tokenString
}
func TestPublicShareHandlerAuthentication(t *testing.T) {
setupTestEnv(t)
@@ -158,7 +315,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
{Name: "srv", Scope: "/"}, // Root scope on srv source
},
}
if err := store.Users.Save(dummyUser, true, true); err != nil {
if err := store.Users.Save(dummyUser, false, false); err != nil {
t.Fatal("failed to save dummy user:", err)
}
@@ -270,6 +427,65 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
}
}
func TestWithHashFileHelper_SkipsPrefetchForMediaSubtitles(t *testing.T) {
setupTestEnv(t)
dummyUser := &users.User{
ID: 1,
Username: "testuser",
Scopes: []users.SourceScope{
{Name: "srv", Scope: "/"},
},
}
if err := store.Users.Save(dummyUser, false, false); err != nil {
t.Fatal("failed to save dummy user:", err)
}
shareLink := &share.Link{
Hash: "subtitle_share_hash",
UserID: 1,
CommonShare: share.CommonShare{
Source: "/srv",
Path: "/",
},
}
if err := store.Share.Save(shareLink); err != nil {
t.Fatal("failed to save share:", err)
}
fileInfoCalled := false
originalFileInfoFaster := FileInfoFasterFunc
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
fileInfoCalled = true
return nil, fmt.Errorf("FileInfoFasterFunc should not be called for /media/subtitles")
}
handlerCalled := false
handler := withHashFileHelper(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
handlerCalled = true
return http.StatusOK, nil
})
if err := store.Settings.Save(&settings.Settings{Auth: settings.Auth{Key: "key"}}); err != nil {
t.Fatal("failed to save settings:", err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=subtitle_share_hash&path=/video.mkv&name=sub.srt", http.NoBody)
status, err := handler(recorder, req, &requestContext{})
if status != http.StatusOK {
t.Fatalf("expected status %d, got %d (err=%v)", http.StatusOK, status, err)
}
if !handlerCalled {
t.Fatal("expected wrapped handler to run")
}
if fileInfoCalled {
t.Fatal("FileInfoFasterFunc should not be called for GET /media/subtitles")
}
}
// Helper function to create a new HTTP request with optional parameters
func newTestRequest(t *testing.T, hash, token, password string, headers map[string]string) *http.Request {
req := newHTTPRequest(t, hash, func(r *http.Request) {
+1 -17
View File
@@ -408,23 +408,7 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
// This allows backend to identify expired sessions and provide better user feedback
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
// Set the authentication token as an HTTP cookie
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
Path: "/",
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
Expires: expiresTime,
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
// Secure: true, // Enable this in production with HTTPS
}
http.SetCookie(w, cookie)
http.SetCookie(w, sessionCookie(r, tokenString, expiresTime, 0, http.SameSiteLaxMode))
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
+24 -9
View File
@@ -205,11 +205,16 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
// Send initial log event with detailed path information
sendOnlyOfficeLogEvent(logContext, "INFO", "config", fmt.Sprintf("OnlyOffice session started for document: %s ", path))
shareToken := ""
if d.share != nil {
shareToken = d.share.Token
}
// Build download URL that OnlyOffice server will use
downloadURL := buildOnlyOfficeDownloadURL(r, source, providedPath, d.fileInfo.Hash, d.token)
downloadURL := buildOnlyOfficeDownloadURL(r, source, providedPath, d.fileInfo.Hash, d.token, shareToken)
// Build callback URL for OnlyOffice to notify us of changes
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.fileInfo.Hash, d.token)
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.fileInfo.Hash, d.token, shareToken)
// Build OnlyOffice client configuration
clientConfig := map[string]interface{}{
@@ -255,7 +260,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
}
// buildOnlyOfficeDownloadURL constructs the download URL that OnlyOffice server will use to fetch the file
func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token string) string {
func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, authToken, shareToken string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
var baseURL string
if config.Server.InternalUrl != "" {
@@ -290,16 +295,23 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
escapedPath := url.QueryEscape(path)
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s&source=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token, url.QueryEscape(source))
strings.TrimSuffix(baseURL, "/"), escapedPath, authToken, url.QueryEscape(source))
if hash != "" {
downloadURL = fmt.Sprintf("%s/public/api/resources/download?file=%s&auth=%s&hash=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token, hash)
params := url.Values{}
params.Set("file", path)
params.Set("auth", authToken)
params.Set("hash", hash)
if shareToken != "" {
params.Set("token", shareToken)
}
downloadURL = fmt.Sprintf("%s/public/api/resources/download?%s",
strings.TrimSuffix(baseURL, "/"), params.Encode())
}
return downloadURL
}
// buildOnlyOfficeCallbackURL constructs the callback URL that OnlyOffice server will use to notify us of changes
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token string) string {
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, authToken, shareToken string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
var baseURL string
if config.Server.InternalUrl != "" {
@@ -338,7 +350,10 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
params := url.Values{}
params.Set("hash", hash)
params.Set("path", path) // This should be the path relative to the share, not the full filesystem path
params.Set("auth", token)
params.Set("auth", authToken)
if shareToken != "" {
params.Set("token", shareToken)
}
callbackURL = fmt.Sprintf("%s/public/api/office/callback?%s",
strings.TrimSuffix(baseURL, "/"), params.Encode())
@@ -347,7 +362,7 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
params := url.Values{}
params.Set("source", source)
params.Set("path", path)
params.Set("auth", token)
params.Set("auth", authToken)
callbackURL = fmt.Sprintf("%s/api/office/callback?%s",
strings.TrimSuffix(baseURL, "/"), params.Encode())
+82
View File
@@ -2,7 +2,9 @@ package http
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
@@ -14,6 +16,86 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func TestBuildOnlyOfficeShareURLs(t *testing.T) {
setupTestEnv(t)
origBaseURL := config.Server.BaseURL
origInternalURL := config.Server.InternalUrl
t.Cleanup(func() {
config.Server.BaseURL = origBaseURL
config.Server.InternalUrl = origInternalURL
})
config.Server.BaseURL = "/files/"
config.Server.InternalUrl = ""
req := httptestNewRequest(t, "http://example.com/files/public/api/office/config?hash=abc")
t.Run("share download URL includes share token", func(t *testing.T) {
got := buildOnlyOfficeDownloadURL(req, "source", "/doc.docx", "shareHash", "jwt-token", "share-download-token")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse download URL: %v", err)
}
q := parsed.Query()
if q.Get("hash") != "shareHash" {
t.Errorf("hash = %q, want shareHash", q.Get("hash"))
}
if q.Get("auth") != "jwt-token" {
t.Errorf("auth = %q, want jwt-token", q.Get("auth"))
}
if q.Get("token") != "share-download-token" {
t.Errorf("token = %q, want share-download-token", q.Get("token"))
}
if q.Get("file") != "/doc.docx" {
t.Errorf("file = %q, want /doc.docx", q.Get("file"))
}
})
t.Run("share download URL omits token when empty", func(t *testing.T) {
got := buildOnlyOfficeDownloadURL(req, "source", "/doc.docx", "shareHash", "jwt-token", "")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse download URL: %v", err)
}
if parsed.Query().Get("token") != "" {
t.Errorf("expected no token param, got %q", parsed.Query().Get("token"))
}
})
t.Run("non-share download URL unchanged", func(t *testing.T) {
got := buildOnlyOfficeDownloadURL(req, "my-source", "/doc.docx", "", "jwt-token", "share-download-token")
if strings.Contains(got, "token=") {
t.Errorf("non-share URL should not include share token: %q", got)
}
if !strings.Contains(got, "source=my-source") {
t.Errorf("expected source in URL, got %q", got)
}
})
t.Run("share callback URL includes share token", func(t *testing.T) {
got := buildOnlyOfficeCallbackURL(req, "source", "/doc.docx", "shareHash", "jwt-token", "share-download-token")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse callback URL: %v", err)
}
q := parsed.Query()
if q.Get("token") != "share-download-token" {
t.Errorf("token = %q, want share-download-token", q.Get("token"))
}
if q.Get("auth") != "jwt-token" {
t.Errorf("auth = %q, want jwt-token", q.Get("auth"))
}
})
}
func httptestNewRequest(t *testing.T, rawURL string) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodGet, rawURL, http.NoBody)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
return req
}
func TestResolveOnlyOfficeDownloadURL(t *testing.T) {
orig := config.Integrations.OnlyOffice
t.Cleanup(func() {
+4 -4
View File
@@ -95,7 +95,7 @@ func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestCon
}
// Join the share path with the requested path
filePath := utils.JoinPathAsUnix(d.share.Path, cleanFile)
filePath := utils.JoinScopedIndexPath(d.share.Path, cleanFile)
fileList = append(fileList, filePath)
}
@@ -259,7 +259,7 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, err
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
resolvedPath := utils.JoinScopedIndexPath(d.share.Path, cleanPath)
err = files.WriteFile(sourceName, resolvedPath, r.Body)
// hide the error
if err != nil {
@@ -373,13 +373,13 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusBadRequest, fmt.Errorf("invalid from path: %w", err)
}
req.Items[i].FromSource = sourceName
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, sanitizedPath)
req.Items[i].FromPath = utils.JoinScopedIndexPath(d.share.Path, sanitizedPath)
sanitizedPath, err = utils.SanitizeUserPath(req.Items[i].ToPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid to path: %w", err)
}
req.Items[i].ToSource = sourceName
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, sanitizedPath)
req.Items[i].ToPath = utils.JoinScopedIndexPath(d.share.Path, sanitizedPath)
}
d.Data = req
+16 -16
View File
@@ -333,7 +333,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
continue
}
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
indexPath := utils.JoinPathAsUnix(withoutUserScope, sanitizedPath)
indexPath := utils.JoinScopedIndexPath(withoutUserScope, sanitizedPath)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
@@ -471,13 +471,15 @@ func resourcePauseHandler(w http.ResponseWriter, r *http.Request, d *requestCont
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
if _, err := d.user.GetScopeForSourceName(source); err != nil {
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
fullIndexPath := utils.JoinScopedIndexPath(userscope, path)
if !store.Access.Permitted(idx.Path, fullIndexPath, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", fullIndexPath)
}
pauseCache.Set(pauseUploadCacheKey(source, path), "1")
pauseCache.Set(pauseUploadCacheKey(source, fullIndexPath), "1")
return http.StatusOK, nil
}
@@ -566,14 +568,14 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
userscope = strings.TrimRight(userscope, "/")
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
fullIndexPath := utils.JoinScopedIndexPath(userscope, path)
// get scoped path
realPath, _, _ := idx.GetRealPath(fullIndexPath)
// Check access control for the target path
if !store.Access.Permitted(idx.Path, path, filePermUser.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
if !store.Access.Permitted(idx.Path, fullIndexPath, filePermUser.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", fullIndexPath)
}
// Check for file/folder conflicts before creation
@@ -690,7 +692,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
publicPauseCache.Delete(k)
}
} else {
k := pauseUploadCacheKey(source, path)
k := pauseUploadCacheKey(source, fullIndexPath)
if _, ok := pauseCache.Get(k); ok {
gracefulPause = true
pauseCache.Delete(k)
@@ -765,7 +767,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
fullIndexPath := utils.JoinScopedIndexPath(userScope, path)
// Check access control for the target path
idx := indexing.GetIndex(source)
if idx == nil {
@@ -930,8 +932,8 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
// Build full index paths for access control
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
fullSrcIndexPath := utils.JoinScopedIndexPath(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinScopedIndexPath(userscopeDst, item.ToPath)
if fullDstIndexPath == "/" || fullSrcIndexPath == "/" {
item.Message = "source or destination is the root or unautharized directory"
response.Failed = append(response.Failed, item)
@@ -963,9 +965,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
// Get real paths
// Combine user scope with item paths BEFORE calling GetRealPath to avoid double scope application
fullSrcPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcPath)
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcIndexPath)
if err != nil {
logger.Errorf("could not resolve source path: %v, item.FromPath: %v", err, item.FromPath)
item.Message = "could not resolve source path"
@@ -981,7 +981,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// Check destination parent directory exists
dstParentPath := filepath.Dir(item.ToPath)
fullDstParentPath := utils.JoinPathAsUnix(userscopeDst, dstParentPath)
fullDstParentPath := utils.JoinScopedIndexPath(userscopeDst, dstParentPath)
parentDir, _, err := dstIdx.GetRealPath(fullDstParentPath)
if err != nil {
item.Message = "destination directory does not exist"
+125
View File
@@ -0,0 +1,125 @@
package http
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
func setupResourceACLTestSource(t *testing.T) string {
t.Helper()
srvRoot := t.TempDir()
settings.Config.Server.SourceMap = map[string]*settings.Source{
srvRoot: {Path: srvRoot, Name: "srv"},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"srv": {Path: srvRoot, Name: "srv"},
}
indexing.SetTestIndex("srv", srvRoot)
t.Cleanup(indexing.ClearTestIndices)
return srvRoot
}
func TestResourcePostACLUsesFullIndexPathKey(t *testing.T) {
setupTestEnv(t)
const (
sourcePath = "/srv"
userScope = "/home/alice"
deniedFolder = "/home/alice/projects/acme/private"
relPath = "/projects/acme/private/poison.docx"
)
fullIndexPath := utils.JoinScopedIndexPath(userScope, relPath)
alice := &users.User{
ID: 1,
Username: "alice",
}
if err := store.Users.Save(alice, false, false); err != nil {
t.Fatal(err)
}
if err := store.Access.DenyUser(sourcePath, deniedFolder, "alice"); err != nil {
t.Fatalf("DenyUser: %v", err)
}
if !store.Access.Permitted(sourcePath, relPath, "alice") {
t.Fatal("scope-stripped path does not match deny rule keyed at full index path (documents pre-fix ACL miss)")
}
if store.Access.Permitted(sourcePath, fullIndexPath, "alice") {
t.Fatalf("full index path %q should be denied for alice", fullIndexPath)
}
}
func TestResourcePostHandler_DeniesAuthenticatedUploadToDeniedPath(t *testing.T) {
setupTestEnv(t)
sourcePath := setupResourceACLTestSource(t)
alice := &users.User{
ID: 1,
Username: "alice",
Permissions: users.Permissions{Create: true},
Scopes: []users.SourceScope{
{Name: sourcePath, Scope: "/home/alice"},
},
}
if err := store.Users.Save(alice, false, false); err != nil {
t.Fatal(err)
}
deniedFolder := "/home/alice/projects/acme/private"
if err := store.Access.DenyUser(sourcePath, deniedFolder, "alice"); err != nil {
t.Fatalf("DenyUser: %v", err)
}
req := httptest.NewRequest(
http.MethodPost,
"/api/resources?source=srv&path=/projects/acme/private/evil.txt",
strings.NewReader("payload"),
)
rec := httptest.NewRecorder()
status, err := resourcePostHandler(rec, req, &requestContext{user: alice})
if status != http.StatusForbidden {
t.Fatalf("expected 403 for denied upload path, got status=%d err=%v", status, err)
}
}
func TestResourcePauseHandler_DeniesAuthenticatedPauseOnDeniedPath(t *testing.T) {
setupTestEnv(t)
sourcePath := setupResourceACLTestSource(t)
alice := &users.User{
ID: 1,
Username: "alice",
Permissions: users.Permissions{Create: true},
Scopes: []users.SourceScope{
{Name: sourcePath, Scope: "/home/alice"},
},
}
if err := store.Users.Save(alice, false, false); err != nil {
t.Fatal(err)
}
if err := store.Access.DenyUser(sourcePath, "/home/alice/projects/acme/private", "alice"); err != nil {
t.Fatalf("DenyUser: %v", err)
}
req := httptest.NewRequest(
http.MethodPost,
"/api/resources/pause?source=srv&path=/projects/acme/private/evil.txt",
http.NoBody,
)
rec := httptest.NewRecorder()
status, err := resourcePauseHandler(rec, req, &requestContext{user: alice})
if status != http.StatusForbidden {
t.Fatalf("expected 403 for denied pause path, got status=%d err=%v", status, err)
}
}
+118
View File
@@ -0,0 +1,118 @@
package http
import (
"net/http"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
func TestSessionCookie_HttpOnlyAndSecureOnHTTPS(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Forwarded-Proto", "https")
req.Host = "example.com"
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
if !c.HttpOnly {
t.Fatal("expected HttpOnly session cookie")
}
if !c.Secure {
t.Fatal("expected Secure session cookie over HTTPS")
}
if c.Name != sessionCookieName {
t.Fatalf("cookie name: got %q", c.Name)
}
}
func TestSessionCookie_NotSecureOnHTTP(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "http://localhost/", nil)
if err != nil {
t.Fatal(err)
}
req.Host = "localhost"
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
if !c.HttpOnly {
t.Fatal("expected HttpOnly session cookie")
}
if c.Secure {
t.Fatal("did not expect Secure cookie on plain HTTP")
}
}
func TestSpaContentSecurityPolicy_BlocksUntrustedScripts(t *testing.T) {
prev := settings.Config.Integrations.OnlyOffice
settings.Config.Integrations.OnlyOffice = settings.OnlyOffice{}
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
csp := spaContentSecurityPolicy("testnonce")
if csp != "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com" {
t.Fatalf("unexpected CSP: %s", csp)
}
if strings.Contains(csp, "'unsafe-inline'") {
t.Fatal("script-src must not allow unsafe-inline (srcdoc XSS)")
}
if strings.Contains(csp, "default-src") {
t.Fatal("CSP should only restrict script-src to avoid breaking frames, blobs, and external assets")
}
}
func TestSpaContentSecurityPolicy_IncludesOnlyOfficeOrigins(t *testing.T) {
prev := settings.Config.Integrations.OnlyOffice
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
tests := []struct {
name string
oo settings.OnlyOffice
want string
}{
{
name: "url only",
oo: settings.OnlyOffice{Url: "http://localhost:9052"},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
},
{
name: "internal url only",
oo: settings.OnlyOffice{InternalUrl: "http://onlyoffice-internal:80"},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://onlyoffice-internal:80",
},
{
name: "both urls",
oo: settings.OnlyOffice{
Url: "https://office.example.com",
InternalUrl: "http://onlyoffice-internal",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com https://office.example.com http://onlyoffice-internal",
},
{
name: "duplicate origins",
oo: settings.OnlyOffice{
Url: "http://localhost:9052",
InternalUrl: "http://localhost:9052",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
},
{
name: "invalid urls ignored",
oo: settings.OnlyOffice{
Url: "not-a-url",
InternalUrl: "://bad",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings.Config.Integrations.OnlyOffice = tt.oo
if got := spaContentSecurityPolicy("testnonce"); got != tt.want {
t.Fatalf("unexpected CSP:\ngot: %s\nwant: %s", got, tt.want)
}
})
}
}
+33 -3
View File
@@ -220,8 +220,32 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
if thisShare.UserID != d.user.ID && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
}
// Update the share path
err = store.Share.UpdateSharePath(body.Hash, sanitizedPath)
sourceName := thisShare.GetSourceName()
if sourceName == "" {
return http.StatusBadRequest, fmt.Errorf("source not available for share")
}
shareOwner, err := store.Users.Get(thisShare.UserID)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("share owner not found")
}
userscope, err := shareOwner.GetScopeForSourceName(sourceName)
if err != nil {
return http.StatusForbidden, err
}
storedPath := utils.JoinPathAsUnix(userscope, body.Path)
storedPath = utils.AddTrailingSlashIfNotExists(storedPath)
idx := indexing.GetIndex(sourceName)
if idx == nil {
return http.StatusBadRequest, fmt.Errorf("index not found for source: %s", sourceName)
}
_, _, err = idx.GetRealPath(storedPath)
if err != nil {
return http.StatusForbidden, fmt.Errorf("path not found: %s", body.Path)
}
err = store.Share.UpdateSharePath(body.Hash, storedPath)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -320,6 +344,11 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
stringHash = string(hash)
}
if s != nil {
if !s.UserCanEdit(d.user) {
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
}
share.ClampShareEditable(d.user.Permissions, &body.CommonShare)
// Check if downloads limit or per-user limit changed - reset counts if so
shouldResetCounts := s.DownloadsLimit != body.DownloadsLimit || s.PerUserDownloadLimit != body.PerUserDownloadLimit
@@ -389,7 +418,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, err
}
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
body.Path = utils.JoinScopedIndexPath(userscope, cleanPath)
body.Path = utils.AddTrailingSlashIfNotExists(body.Path)
// validate path exists as file or folder
_, _, err = idx.GetRealPath(body.Path)
@@ -400,6 +429,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if body.ShareType == "upload" && !body.AllowCreate {
body.AllowCreate = true
}
share.ClampShareEditable(d.user.Permissions, &body.CommonShare)
body.Source = source.Path // backend source is path
s = &share.Link{
Expire: expire,
+184
View File
@@ -0,0 +1,184 @@
package http
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
const victimShareHashX79q = "victim_share_x79q_01"
func setupShareAuthUsers(t *testing.T) (owner, attacker, admin *users.User) {
t.Helper()
setupTestEnv(t)
owner = &users.User{
ID: 1,
Username: "share_owner",
Permissions: users.Permissions{Share: true, Delete: true, Create: true, Modify: true},
Scopes: []users.SourceScope{{Name: "/srv", Scope: "/"}},
}
attacker = &users.User{
ID: 2,
Username: "share_attacker",
Permissions: users.Permissions{Share: true, Delete: true, Create: true, Modify: true},
Scopes: []users.SourceScope{{Name: "/srv", Scope: "/"}},
}
noDelete := &users.User{
ID: 3,
Username: "share_no_delete",
Permissions: users.Permissions{Share: true, Delete: false, Create: true, Modify: true},
Scopes: []users.SourceScope{{Name: "/srv", Scope: "/"}},
}
admin = &users.User{
ID: 4,
Username: "share_admin",
Permissions: users.Permissions{Admin: true, Share: true, Delete: true},
Scopes: []users.SourceScope{{Name: "/srv", Scope: "/"}},
}
for _, u := range []*users.User{owner, attacker, noDelete, admin} {
if err := store.Users.Save(u, false, false); err != nil {
t.Fatalf("Save(%s): %v", u.Username, err)
}
}
gotOwner, err := store.Users.Get(owner.ID)
if err != nil {
t.Fatal(err)
}
gotAttacker, err := store.Users.Get(attacker.ID)
if err != nil {
t.Fatal(err)
}
gotAdmin, err := store.Users.Get(admin.ID)
if err != nil {
t.Fatal(err)
}
return gotOwner, gotAttacker, gotAdmin
}
func createVictimShareLink(t *testing.T, ownerID uint) {
t.Helper()
link := &share.Link{
Hash: victimShareHashX79q,
UserID: ownerID,
Version: 1,
CommonShare: share.CommonShare{
Path: "/",
Source: "/srv",
ShareType: "normal",
AllowDelete: false,
},
}
if err := store.Share.Save(link); err != nil {
t.Fatal(err)
}
}
func postShareUpdate(t *testing.T, user *users.User, hash string, allowDelete bool) (int, error) {
t.Helper()
raw, err := json.Marshal(map[string]interface{}{
"hash": hash,
"allowDelete": allowDelete,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/share", bytes.NewReader(raw))
rec := httptest.NewRecorder()
return sharePostHandler(rec, req, &requestContext{user: user})
}
func postShareCreate(t *testing.T, user *users.User, allowDelete bool) (int, error) {
t.Helper()
raw, err := json.Marshal(map[string]interface{}{
"path": "/",
"source": "srv",
"allowDelete": allowDelete,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/share", bytes.NewReader(raw))
rec := httptest.NewRecorder()
return sharePostHandler(rec, req, &requestContext{user: user})
}
func TestSharePostUpdate_RejectNonOwner(t *testing.T) {
owner, attacker, _ := setupShareAuthUsers(t)
createVictimShareLink(t, owner.ID)
status, err := postShareUpdate(t, attacker, victimShareHashX79q, true)
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
got, err := store.Share.GetByHash(victimShareHashX79q)
if err != nil {
t.Fatal(err)
}
if got.AllowDelete {
t.Fatal("non-owner update should not enable allowDelete")
}
if got.UserID != owner.ID {
t.Fatalf("UserID changed to %d, want owner %d", got.UserID, owner.ID)
}
}
func TestSharePostUpdate_AllowsOwner(t *testing.T) {
owner, _, _ := setupShareAuthUsers(t)
createVictimShareLink(t, owner.ID)
status, err := postShareUpdate(t, owner, victimShareHashX79q, true)
if status != http.StatusOK {
t.Fatalf("expected 200, got status=%d err=%v", status, err)
}
got, err := store.Share.GetByHash(victimShareHashX79q)
if err != nil {
t.Fatal(err)
}
if !got.AllowDelete {
t.Fatal("owner update should enable allowDelete")
}
}
func TestSharePostCreate_ClampsAllowDeleteWithoutUserDeletePerm(t *testing.T) {
setupTestEnv(t)
indexing.SetTestIndex("srv", t.TempDir())
t.Cleanup(indexing.ClearTestIndices)
user := &users.User{
ID: 5,
Username: "share_no_delete_create",
Permissions: users.Permissions{Share: true, Delete: false, Create: true},
Scopes: []users.SourceScope{{Name: "/srv", Scope: "/"}},
}
if err := store.Users.Save(user, false, false); err != nil {
t.Fatal(err)
}
got, err := store.Users.Get(user.ID)
if err != nil {
t.Fatal(err)
}
status, handlerErr := postShareCreate(t, got, true)
if status != http.StatusOK {
t.Fatalf("expected 200, got status=%d err=%v", status, handlerErr)
}
links, err := store.Share.GetBySourcePath("/", "/srv")
if err != nil || len(links) == 0 {
t.Fatalf("expected created share, err=%v len=%d", err, len(links))
}
created := links[len(links)-1]
if created.AllowDelete {
t.Fatal("user without delete permission should not persist allowDelete=true")
}
}
+183
View File
@@ -0,0 +1,183 @@
package http
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
func TestSharePatchHandler_AnchorsPathToOwnerScope(t *testing.T) {
setupTestEnv(t)
srvRoot := t.TempDir()
publicDir := filepath.Join(srvRoot, "public")
if err := os.MkdirAll(publicDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(publicDir, "marker.txt"), []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/srv": {Path: "/srv", Name: "srv"},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"srv": {Path: "/srv", Name: "srv"},
}
indexing.SetTestIndex("srv", srvRoot)
t.Cleanup(indexing.ClearTestIndices)
guest := &users.User{
ID: 2,
Username: "guest",
Permissions: users.Permissions{
Share: true,
},
Scopes: []users.SourceScope{
{Name: "/srv", Scope: "/public"},
},
}
if err := store.Users.Save(guest, false, false); err != nil {
t.Fatal(err)
}
initialPath := "/public/"
link := &share.Link{
Hash: "patch_scope_hash",
UserID: guest.ID,
Version: 1,
CommonShare: share.CommonShare{
Path: initialPath,
Source: "/srv",
},
}
if err := store.Share.Save(link); err != nil {
t.Fatal(err)
}
body, err := json.Marshal(map[string]string{
"hash": link.Hash,
"path": "/",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPatch, "/api/share", bytes.NewReader(body))
rec := httptest.NewRecorder()
status, handlerErr := sharePatchHandler(rec, req, &requestContext{user: guest})
if status != http.StatusOK {
t.Fatalf("sharePatchHandler status=%d err=%v", status, handlerErr)
}
updated, err := store.Share.GetByHash(link.Hash)
if err != nil {
t.Fatal(err)
}
if updated.Path == "/" {
t.Fatalf("share path escaped to source root: %q", updated.Path)
}
if updated.Path != initialPath {
t.Fatalf("share path = %q, want %q", updated.Path, initialPath)
}
}
func TestSharePatchHandler_AdminUpdateUsesOwnerScope(t *testing.T) {
setupTestEnv(t)
srvRoot := t.TempDir()
publicDir := filepath.Join(srvRoot, "public")
if err := os.MkdirAll(publicDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(publicDir, "marker.txt"), []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/srv": {Path: "/srv", Name: "srv"},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"srv": {Path: "/srv", Name: "srv"},
}
indexing.SetTestIndex("srv", srvRoot)
t.Cleanup(indexing.ClearTestIndices)
owner := &users.User{
ID: 2,
Username: "guest",
Permissions: users.Permissions{
Share: true,
},
Scopes: []users.SourceScope{
{Name: "/srv", Scope: "/public"},
},
}
if err := store.Users.Save(owner, false, false); err != nil {
t.Fatal(err)
}
admin := &users.User{
ID: 1,
Username: "admin",
Permissions: users.Permissions{
Admin: true,
Share: true,
},
Scopes: []users.SourceScope{
{Name: "/srv", Scope: "/"},
},
}
if err := store.Users.Save(admin, false, false); err != nil {
t.Fatal(err)
}
initialPath := "/public/"
link := &share.Link{
Hash: "patch_admin_scope_hash",
UserID: owner.ID,
Version: 1,
CommonShare: share.CommonShare{
Path: initialPath,
Source: "/srv",
},
}
if err := store.Share.Save(link); err != nil {
t.Fatal(err)
}
body, err := json.Marshal(map[string]string{
"hash": link.Hash,
"path": "/",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPatch, "/api/share", bytes.NewReader(body))
rec := httptest.NewRecorder()
status, handlerErr := sharePatchHandler(rec, req, &requestContext{user: admin})
if status != http.StatusOK {
t.Fatalf("sharePatchHandler status=%d err=%v", status, handlerErr)
}
updated, err := store.Share.GetByHash(link.Hash)
if err != nil {
t.Fatal(err)
}
if updated.Path == "/" {
t.Fatalf("admin update escaped to source root using admin scope: %q", updated.Path)
}
if updated.Path != initialPath {
t.Fatalf("share path = %q, want %q", updated.Path, initialPath)
}
}
+65 -1
View File
@@ -2,6 +2,7 @@ package http
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
@@ -42,7 +43,13 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return fmt.Errorf("error reloading template: %w", err)
}
}
// Set headers
nonce, err := utils.CSPNonceFromData(data)
if err != nil {
return err
}
// Inherited by srcdoc preview frames: nonce'd SPA scripts run; untrusted preview scripts do not.
w.Header().Set("Content-Security-Policy", spaContentSecurityPolicy(nonce))
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("X-Accel-Expires", "0")
@@ -51,6 +58,51 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return templates.ExecuteTemplate(w, name, data)
}
// spaContentSecurityPolicy restricts only scripts. srcdoc preview frames inherit
// this header, blocking inline scripts without limiting frames, images, or API calls.
func spaContentSecurityPolicy(nonce string) string {
policy := fmt.Sprintf(
"script-src 'self' 'nonce-%s' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
nonce,
)
for _, origin := range onlyOfficeScriptSrcOrigins() {
policy += " " + origin
}
return policy
}
func onlyOfficeScriptSrcOrigins() []string {
oo := settings.Config.Integrations.OnlyOffice
candidates := []string{
cspOriginFromURL(oo.Url),
cspOriginFromURL(oo.InternalUrl),
}
var origins []string
seen := make(map[string]struct{}, len(candidates))
for _, origin := range candidates {
if origin == "" {
continue
}
if _, ok := seen[origin]; ok {
continue
}
seen[origin] = struct{}{}
origins = append(origins, origin)
}
return origins
}
func cspOriginFromURL(raw string) string {
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
w.Header().Set("Content-Type", contentType)
userSelectedTheme := ""
@@ -245,8 +297,20 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"loginButtonText": config.Frontend.LoginButtonText,
"passkeyAvailable": config.Auth.Methods.PasskeyAuth.Enabled,
"passkeyLoginButtonText": config.Auth.Methods.PasskeyAuth.LoginButtonText,
"disablePWAInstall": config.Frontend.DisablePWAInstall,
}
cspNonce, err := utils.CSPNonce()
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("csp nonce: %w", err)
}
globalVars, ok := data["globalVars"].(map[string]interface{})
if !ok {
return http.StatusInternalServerError, errors.New("unable to load global variables")
}
globalVars["cspNonce"] = cspNonce
data["cspNonce"] = cspNonce
// Marshal each variable to JSON strings for direct template usage
globalVarsJSON, err := json.Marshal(data["globalVars"])
if err != nil {
+9 -9
View File
@@ -322,13 +322,13 @@ func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
}
// Save users
if err := store.Users.Save(adminUser, true, true); err != nil {
if err := store.Users.Save(adminUser, false, false); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(scopedUser, true, true); err != nil {
if err := store.Users.Save(scopedUser, false, false); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(restrictedUser, true, true); err != nil {
if err := store.Users.Save(restrictedUser, false, false); err != nil {
t.Fatal(err)
}
@@ -464,13 +464,13 @@ func TestWebDAV_WriteOperations(t *testing.T) {
}
// Save users
if err := store.Users.Save(fullAccessUser, true, true); err != nil {
if err := store.Users.Save(fullAccessUser, false, false); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(readOnlyUser, true, true); err != nil {
if err := store.Users.Save(readOnlyUser, false, false); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(scopedUser, true, true); err != nil {
if err := store.Users.Save(scopedUser, false, false); err != nil {
t.Fatal(err)
}
@@ -602,10 +602,10 @@ func TestWebDAV_AccessControl(t *testing.T) {
}
// Save users
if err := store.Users.Save(user1, true, true); err != nil {
if err := store.Users.Save(user1, false, false); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(user2, true, true); err != nil {
if err := store.Users.Save(user2, false, false); err != nil {
t.Fatal(err)
}
@@ -708,7 +708,7 @@ func TestWebDAV_IndexingStates(t *testing.T) {
},
}
if err := store.Users.Save(user, true, true); err != nil {
if err := store.Users.Save(user, false, false); err != nil {
t.Fatal(err)
}
+108 -16
View File
@@ -432,6 +432,7 @@ type PathContext struct {
type FileInfoRequest struct {
IndexPath string
FollowSymlinks bool
BoundIndexPath string // index prefix for symlink containment (e.g. user scope)
ShowHidden bool
HideFileExt string // Hide files based on their extension
Expand bool // get child items for directories
@@ -439,10 +440,61 @@ type FileInfoRequest struct {
SkipExtendedAttrs bool // whether to skip extended attributes
}
// filesystemBound resolves the filesystem path for an index prefix under this source.
func (idx *Index) filesystemBound(boundIndexPath string) (string, error) {
bound := boundIndexPath
if bound == "" {
bound = "/"
}
fsPath := utils.JoinUnderSourceRoot(idx.Path, bound)
abs, err := filepath.Abs(fsPath)
if err != nil {
return "", err
}
return filepath.EvalSymlinks(abs)
}
// ensureResolvedContained rejects resolved filesystem paths outside the source or scope prefix.
func (idx *Index) ensureResolvedContained(realPath, boundIndexPath string) error {
sourceRoot, err := idx.filesystemBound("/")
if err != nil {
return err
}
if err := iteminfo.PathWithinRoot(sourceRoot, realPath); err != nil {
return errors.ErrPathEscapesScope
}
bound := boundIndexPath
if bound == "" {
bound = "/"
}
if bound != "/" {
scopeRoot, err := idx.filesystemBound(bound)
if err != nil {
return err
}
if err := iteminfo.PathWithinRoot(scopeRoot, realPath); err != nil {
return errors.ErrPathEscapesScope
}
}
return nil
}
// resolveSymlinksContained resolves symlinks and ensures the target stays within bounds.
func (idx *Index) resolveSymlinksContained(fsPath, boundIndexPath string) (string, error) {
resolved, err := filepath.EvalSymlinks(fsPath)
if err != nil {
return "", err
}
if err := idx.ensureResolvedContained(resolved, boundIndexPath); err != nil {
return "", err
}
return resolved, nil
}
// resolvePathContext resolves all path characteristics in a SINGLE stat call
// This eliminates duplicate stat/lstat calls and redundant isHidden/isSymlink checks
func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*PathContext, error) {
realPath := filepath.Join(idx.Path, indexPath)
func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool, boundIndexPath string) (*PathContext, error) {
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
// ONE filesystem stat call
fileInfo, err := os.Lstat(realPath)
@@ -454,7 +506,7 @@ func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*Pa
// Resolve symlink if requested
if isSymlink && followSymlinks {
realPath, err = filepath.EvalSymlinks(realPath)
realPath, err = idx.resolveSymlinksContained(realPath, boundIndexPath)
if err != nil {
return nil, err
}
@@ -510,7 +562,7 @@ func (idx *Index) evaluateIndexRules(ctx *PathContext, isRoutineScan bool) (isVi
// User permission checking happens in the API layer (FileInfoFaster)
func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
// 1. Resolve path context (single stat call)
ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks)
ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks, req.BoundIndexPath)
if err != nil {
return nil, err
}
@@ -598,7 +650,7 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
if !strings.HasSuffix(indexPath, "/") {
indexPath = indexPath + "/"
}
realPath := filepath.Join(idx.Path, indexPath)
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
// Open the directory
dir, err := os.Open(realPath)
@@ -636,10 +688,10 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
// GetFsInfoCore is the consolidated implementation for both GetFsInfo and GetFsInfoViewableOnly
func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileInfo, error) {
// Handle symlinks if not following them
realPath := filepath.Join(idx.Path, indexPath)
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
if opts.FollowSymlinks {
var err error
realPath, err = filepath.EvalSymlinks(realPath)
realPath, err = idx.resolveSymlinksContained(realPath, "/")
if err != nil {
return nil, err
}
@@ -995,10 +1047,24 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, indexPath strin
}
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
combined := append([]string{idx.Path}, relativePath...)
joinedPath := filepath.Join(combined...)
isDir, _ := IsDirCache.Get(joinedPath + ":isdir")
cached, ok := RealPathCache.Get(joinedPath)
return idx.getRealPathInternal("/", false, relativePath...)
}
// GetRealPathScoped resolves an index path and rejects symlink targets outside the source and bound prefix.
func (idx *Index) GetRealPathScoped(boundIndexPath string, relativePath ...string) (string, bool, error) {
return idx.getRealPathInternal(boundIndexPath, true, relativePath...)
}
func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool, relativePath ...string) (string, bool, error) {
indexPath := utils.JoinIndexPathComponents(relativePath...)
joinedPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
bound := boundIndexPath
if enforceScope && bound == "" {
bound = "/"
}
cacheKey := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", enforceScope, bound, joinedPath)
isDir, _ := IsDirCache.Get(cacheKey + ":isdir")
cached, ok := RealPathCache.Get(cacheKey)
if ok && cached != "" {
return cached, isDir, nil
}
@@ -1007,11 +1073,37 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
}
realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath)
if err == nil {
RealPathCache.Set(joinedPath, realPath)
IsDirCache.Set(joinedPath+":isdir", isDir)
if err != nil {
return realPath, isDir, err
}
return realPath, isDir, err
if enforceScope {
if err := idx.ensureResolvedContained(realPath, boundIndexPath); err != nil {
return "", false, err
}
} else if err := idx.ensureResolvedContained(realPath, "/"); err != nil {
return "", false, err
}
RealPathCache.Set(cacheKey, realPath)
IsDirCache.Set(cacheKey+":isdir", isDir)
return realPath, isDir, nil
}
// OpenScopedPath opens indexPath relative to boundIndexPath using traversal-resistant APIs.
func (idx *Index) OpenScopedPath(boundIndexPath, indexPath string) (*os.File, os.FileInfo, error) {
realPath, _, err := idx.GetRealPathScoped(boundIndexPath, indexPath)
if err != nil {
return nil, nil, err
}
file, err := os.Open(realPath)
if err != nil {
return nil, nil, err
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, nil, err
}
return file, info, nil
}
func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
@@ -1038,7 +1130,7 @@ func (idx *Index) RefreshDirectory(indexPath string, recursive bool) error {
indexPath = indexPath + "/"
}
realPath := filepath.Join(idx.Path, indexPath)
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
// Check if directory exists
dirInfo, err := os.Stat(realPath)
+45
View File
@@ -105,6 +105,51 @@ func (info *FileInfo) SortItems() {
})
}
// PathWithinRoot reports whether path is equal to or under root after resolving symlinks.
// It is intended for validation only; use OpenContained for atomic file opens within a root.
func PathWithinRoot(root, path string) error {
rootAbs, err := filepath.Abs(root)
if err != nil {
return fmt.Errorf("invalid root path: %w", err)
}
rootReal, err := filepath.EvalSymlinks(rootAbs)
if err != nil {
return fmt.Errorf("could not resolve root path: %w", err)
}
pathAbs, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
pathReal, err := filepath.EvalSymlinks(pathAbs)
if err != nil {
return fmt.Errorf("could not resolve path: %w", err)
}
rel, err := filepath.Rel(rootReal, pathReal)
if err != nil {
return fmt.Errorf("path outside root: %w", err)
}
sep := string(filepath.Separator)
if rel == ".." || strings.HasPrefix(rel, ".."+sep) {
return fmt.Errorf("path escapes root")
}
return nil
}
// OpenContained opens relPath relative to rootDir without following symlinks that escape rootDir.
// Prefer this over PathWithinRoot followed by os.Open to avoid TOCTOU races.
func OpenContained(rootDir, relPath string) (*os.File, error) {
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(relPath, "/")))
if rel == "." || rel == "" || strings.HasPrefix(rel, "..") {
return nil, fmt.Errorf("path escapes root")
}
root, err := os.OpenRoot(rootDir)
if err != nil {
return nil, err
}
defer root.Close()
return root.Open(rel)
}
// ResolveSymlinks resolves symlinks in the given path and returns
// Uses Go's filepath.EvalSymlinks which properly detects circular symlinks.
func ResolveSymlinks(path string) (string, bool, error) {
@@ -0,0 +1,41 @@
package iteminfo
import (
"os"
"path/filepath"
"testing"
)
func TestOpenContained_RejectsSymlinkEscape(t *testing.T) {
root := t.TempDir()
scopeDir := filepath.Join(root, "scope")
if err := os.MkdirAll(scopeDir, 0o755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(root, "outside.txt")
if err := os.WriteFile(outside, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(scopeDir, "escape")); err != nil {
t.Fatal(err)
}
_, err := OpenContained(scopeDir, "escape")
if err == nil {
t.Fatal("expected symlink escape to be rejected")
}
}
func TestOpenContained_OpensInScopeFile(t *testing.T) {
root := t.TempDir()
inside := filepath.Join(root, "allowed.txt")
if err := os.WriteFile(inside, []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
file, err := OpenContained(root, "allowed.txt")
if err != nil {
t.Fatalf("expected in-scope open to succeed: %v", err)
}
file.Close()
}
@@ -0,0 +1,226 @@
package indexing
import (
"errors"
"os"
"path/filepath"
"testing"
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func setupSymlinkEscapeFixture(t *testing.T) (sourceRoot, userScope string) {
t.Helper()
root := t.TempDir()
sourceRoot = filepath.Join(root, "srv")
userDir := filepath.Join(sourceRoot, "user1")
if err := os.MkdirAll(userDir, 0o755); err != nil {
t.Fatal(err)
}
outsideSecret := filepath.Join(root, "secret-outside.txt")
if err := os.WriteFile(outsideSecret, []byte("ROOT_DB_PASSWORD=supersecret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideSecret, filepath.Join(userDir, "secret_link")); err != nil {
t.Fatal(err)
}
sibling := filepath.Join(root, "outside-sibling.txt")
if err := os.WriteFile(sibling, []byte("outside sibling"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(sibling, filepath.Join(userDir, "sibling_link")); err != nil {
t.Fatal(err)
}
inside := filepath.Join(userDir, "allowed.txt")
if err := os.WriteFile(inside, []byte("allowed"), 0o644); err != nil {
t.Fatal(err)
}
return sourceRoot, "/user1"
}
func symlinkTestIndex(sourceRoot string) *Index {
return &Index{Source: settings.Source{Path: sourceRoot}}
}
func TestPathWithinRoot(t *testing.T) {
root := t.TempDir()
inside := filepath.Join(root, "inner", "file.txt")
if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(inside, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
outside := filepath.Join(filepath.Dir(root), "outside.txt")
if err := os.WriteFile(outside, []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
if err := iteminfo.PathWithinRoot(root, inside); err != nil {
t.Fatalf("expected inside path to be allowed: %v", err)
}
if err := iteminfo.PathWithinRoot(root, outside); err == nil {
t.Fatal("expected outside path to be rejected")
}
}
func TestGetRealPath_RejectsEscapeOutsideSource(t *testing.T) {
sourceRoot, _ := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
_, _, err := idx.GetRealPath("/user1/secret_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
_, _, err = idx.GetRealPath("/user1/sibling_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope for sibling link, got %v", err)
}
}
func TestGetRealPathScoped_RejectsEscapeOutsideUserScope(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
insideSource := filepath.Join(sourceRoot, "secret-in-source.txt")
if err := os.WriteFile(insideSource, []byte("in source"), 0o644); err != nil {
t.Fatal(err)
}
userDir := filepath.Join(sourceRoot, "user1")
if err := os.Symlink(insideSource, filepath.Join(userDir, "in_source_link")); err != nil {
t.Fatal(err)
}
_, _, err := idx.GetRealPathScoped(userScope, "/user1/in_source_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope for in-source escape, got %v", err)
}
}
func TestGetRealPathScoped_AllowsInScopeTarget(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
userDir := filepath.Join(sourceRoot, "user1")
if err := os.Symlink("allowed.txt", filepath.Join(userDir, "good_link")); err != nil {
t.Fatal(err)
}
realPath, _, err := idx.GetRealPathScoped(userScope, "/user1/good_link")
if err != nil {
t.Fatalf("expected in-scope symlink to resolve: %v", err)
}
want := filepath.Join(userDir, "allowed.txt")
realPath, err = filepath.EvalSymlinks(realPath)
if err != nil {
t.Fatal(err)
}
want, err = filepath.EvalSymlinks(want)
if err != nil {
t.Fatal(err)
}
if realPath != want {
t.Fatalf("got real path %q, want %q", realPath, want)
}
}
func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
insideSource := filepath.Join(sourceRoot, "secret-in-source.txt")
if err := os.WriteFile(insideSource, []byte("in source"), 0o644); err != nil {
t.Fatal(err)
}
userDir := filepath.Join(sourceRoot, "user1")
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("legitimate"), 0o644); err != nil {
t.Fatal(err)
}
legacyKey := filepath.Join(sourceRoot, "user1", "foo") + ":bound:" + "/user1"
RealPathCache.Set(legacyKey, insideSource)
IsDirCache.Set(legacyKey+":isdir", false)
realPath, _, err := idx.GetRealPathScoped(userScope, "/user1/foo")
if err != nil {
t.Fatalf("scoped in-scope file should resolve: %v", err)
}
want := filepath.Join(userDir, "foo")
want, err = filepath.EvalSymlinks(want)
if err != nil {
t.Fatal(err)
}
realPath, err = filepath.EvalSymlinks(realPath)
if err != nil {
t.Fatal(err)
}
if realPath != want {
t.Fatalf("scoped lookup returned %q, want in-scope file %q", realPath, want)
}
}
func TestGetFileInfo_RejectsSymlinkEscapeWithBound(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
_, err := idx.GetFileInfo(FileInfoRequest{
IndexPath: "/user1/secret_link",
FollowSymlinks: true,
BoundIndexPath: userScope,
})
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope from GetFileInfo, got %v", err)
}
}
func TestGetFileInfo_RejectsDirectorySymlinkOutsideUserScope(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
otherDir := filepath.Join(sourceRoot, "otheruser")
if err := os.MkdirAll(otherDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(otherDir, "leaked.txt"), []byte("leaked"), 0o644); err != nil {
t.Fatal(err)
}
userDir := filepath.Join(sourceRoot, "user1")
if err := os.Symlink(otherDir, filepath.Join(userDir, "escape_dir")); err != nil {
t.Fatal(err)
}
_, err := idx.GetFileInfo(FileInfoRequest{
IndexPath: "/user1/escape_dir/",
FollowSymlinks: true,
BoundIndexPath: userScope,
Expand: true,
})
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope for directory symlink outside scope, got %v", err)
}
}
func TestOpenScopedPath_RejectsEscapeOutsideUserScope(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
_, _, err := idx.OpenScopedPath(userScope, "/user1/secret_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope from OpenScopedPath, got %v", err)
}
}
func TestOpenScopedPath_OpensInScopeFile(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
file, info, err := idx.OpenScopedPath(userScope, "/user1/allowed.txt")
if err != nil {
t.Fatalf("expected in-scope open to succeed: %v", err)
}
defer file.Close()
if info.IsDir() {
t.Fatal("expected file, got directory")
}
}
+2 -2
View File
@@ -4,10 +4,10 @@ package indexing
import (
"os"
"path/filepath"
"strings"
"syscall"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -66,7 +66,7 @@ func (idx *Index) handleFile(file os.FileInfo, indexPath string, realFilePath st
// Use provided realFilePath if available, otherwise construct it
if realFilePath == "" {
realFilePath = filepath.Join(idx.Path, indexPath)
realFilePath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
}
sys := file.Sys()
+2
View File
@@ -1,9 +1,11 @@
package main
import (
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/cmd"
)
func main() {
jwt.DecodeStrict = true
cmd.StartFilebrowser()
}
+69
View File
@@ -3777,6 +3777,71 @@ const docTemplate = `{
}
}
},
"/public/api/media/subtitles": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"Shares"
],
"summary": "Get subtitle content (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
},
{
"type": "string",
"description": "Subtitle track name",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream",
"name": "embedded",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content",
"schema": {
"type": "string"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -5761,6 +5826,10 @@ const docTemplate = `{
"description": "disable the nav buttons in the sidebar",
"type": "boolean"
},
"disablePWAInstall": {
"description": "disable the PWA install button in the sidebar",
"type": "boolean"
},
"disableUsedPercentage": {
"description": "disable used percentage for the sources in the sidebar",
"type": "boolean"
+69
View File
@@ -3766,6 +3766,71 @@
}
}
},
"/public/api/media/subtitles": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"Shares"
],
"summary": "Get subtitle content (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
},
{
"type": "string",
"description": "Subtitle track name",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream",
"name": "embedded",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content",
"schema": {
"type": "string"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -5750,6 +5815,10 @@
"description": "disable the nav buttons in the sidebar",
"type": "boolean"
},
"disablePWAInstall": {
"description": "disable the PWA install button in the sidebar",
"type": "boolean"
},
"disableUsedPercentage": {
"description": "disable used percentage for the sources in the sidebar",
"type": "boolean"
+46
View File
@@ -767,6 +767,9 @@ definitions:
disableNavButtons:
description: disable the nav buttons in the sidebar
type: boolean
disablePWAInstall:
description: disable the PWA install button in the sidebar
type: boolean
disableUsedPercentage:
description: disable used percentage for the sources in the sidebar
type: boolean
@@ -5016,6 +5019,49 @@ paths:
summary: Directory with media metadata (public share)
tags:
- Shares
/public/api/media/subtitles:
get:
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share
in: query
name: path
type: string
- description: Subtitle track name
in: query
name: name
required: true
type: string
- description: Whether this is an embedded stream
in: query
name: embedded
type: boolean
produces:
- text/plain
responses:
"200":
description: Raw subtitle content
schema:
type: string
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
summary: Get subtitle content (public share)
tags:
- Shares
/public/api/resources:
get:
consumes:
+414 -379
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -159,6 +159,7 @@ frontend:
loginIcon: "" # path to an image file for the login page icon
loginButtonText: "" # text to display on the login button
oidcLoginButtonText: "" # text to display on the OIDC login button
disablePWAInstall: false # disable the PWA install button in the sidebar
userDefaults:
sidebar: # New organized structure
disableQuickToggles: false # disable the quick toggles in the sidebar
+1 -1
View File
@@ -50,7 +50,7 @@
<link rel="canonical" href="https://filebrowserquantum.com">
<!-- Injected variables come from backend server -->
<script>
<script nonce="{{ .cspNonce }}">
window.globalVars = {{ .globalVars }};
window.__pwaDeferredPrompt = null;
window.addEventListener("beforeinstallprompt", function (event) {
+26 -7
View File
@@ -3,16 +3,35 @@ import { state } from "@/store";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { adjustedData, fetchURL } from "./utils";
// GET /api/media/subtitles
// GET /api/media/subtitles or /public/api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const apiPath = getApiPath('media/subtitles', {
source: source,
path: path,
const hash = state.shareInfo?.hash || null
const baseParams = {
path,
name: subtitleName,
embedded: embedded.toString()
})
const res = await fetchURL(apiPath)
...(embedded && { embedded: 'true' }),
}
let res
if (hash) {
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 },
})
} else {
const apiPath = getApiPath('media/subtitles', {
source,
...baseParams,
})
res = await fetchURL(apiPath)
}
const content = await res.text()
return content
} catch (err) {
+12 -2
View File
@@ -1,4 +1,5 @@
import { notify } from '@/notify'
import { state } from '@/store'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { fetchURL } from './utils'
@@ -8,7 +9,16 @@ export async function getConfig(req) {
const params = {
path: req.path,
...(req.hash && { hash: req.hash }),
...(req.source && { source: req.source })
...(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
}
}
let apiPath
@@ -18,7 +28,7 @@ export async function getConfig(req) {
apiPath = getApiPath('office/config', params)
}
const res = await fetchURL(apiPath)
const res = await fetchURL(apiPath, { headers })
return await res.json()
} catch (err) {
notify.showError(err.message || 'Error fetching OnlyOffice configuration')
+7 -4
View File
@@ -57,7 +57,7 @@ export default {
resizeStartX: 0,
resizeStartWidth: 0,
previousSidebarSize: null, // Remember the previous width when switching from desktop to mobile.
pwaInstallDismissed: sessionStorage.getItem("pwaInstallDismissed") === "true",
pwaInstallDismissed: localStorage.getItem("pwaInstallDismissed") === "true",
};
},
mounted() {
@@ -121,7 +121,7 @@ export default {
);
},
showPwaInstall() {
return installAvailable.value && !this.pwaInstallDismissed && !this.isSettings;
return installAvailable.value && !this.pwaInstallDismissed && !this.isSettings && !globalVars.disablePWAInstall;
},
},
methods: {
@@ -178,11 +178,14 @@ export default {
mutations.setSeenUpdate(globalVars.updateAvailable);
},
async installPwa() {
await promptInstall();
const accepted = await promptInstall();
if (accepted) {
this.dismissPwaInstall();
}
},
dismissPwaInstall() {
this.pwaInstallDismissed = true;
sessionStorage.setItem("pwaInstallDismissed", "true");
localStorage.setItem("pwaInstallDismissed", "true");
},
},
};
+1 -1
View File
@@ -686,7 +686,7 @@
"maxConcurrentUpload": "Max concurrent uploads",
"maxConcurrentUploadHelp": "The maximum number of concurrent uploads. Higher values typically perform better, but this depends on many factors.",
"uploadChunkSizeMb": "Upload chunk size in MB",
"uploadChunkSizeMbHelp": "Typically 5 to 50 MB, smaller is better for unstable or restricted connections. Setting to 0 will disable upload chunking.",
"uploadChunkSizeMbHelp": "Typically 5 to 50 MB, smaller is better for unstable or restricted connections. Setting to 0 will disable upload chunking. On iOS 26+ devices, if multi-chunk uploads stall after the first chunk, set this to 0 or larger than your largest file.",
"downloadChunkSizeMb": "Download chunk size in MB",
"downloadChunkSizeMbHelp": "Chunk size for chunked downloads. Files larger than this size will be downloaded in chunks to prevent timeout issues (e.g., Cloudflare 524 errors). Setting to 0 disables chunked downloads.",
"clearAll": "Clear completed should remove error, pause, and conflict",
+9 -23
View File
@@ -14,19 +14,11 @@ export async function validateLogin(isPublicRoute = false) {
});
if (res.status !== 200) {
// A 401 from the non-public self check means our session cookie (JWT) is no
// longer valid — typically it expired. Clear it and redirect to login so the
// user can re-authenticate, instead of leaving the stale cookie in place
// (which otherwise leaves the app stuck on reload). Only do this for a real,
// non-public session: public routes legitimately 401 for anonymous share
// visitors, and we skip it when there is no session cookie to clear.
if (
res.status === 401 &&
!isPublicRoute &&
document.cookie
.split(";")
.some((c) => c.trim().startsWith("filebrowser_quantum_jwt="))
) {
// A 401 from the non-public self check means our session is no longer valid —
// typically the HttpOnly JWT cookie expired. Redirect to login when the app
// still considers the user logged in (public routes legitimately 401 for
// anonymous share visitors).
if (res.status === 401 && !isPublicRoute && getters.isLoggedIn()) {
sessionExpired();
}
throw new Error(`{"status":${res.status},"message":"${await res.text()}"}`);
@@ -88,8 +80,7 @@ export async function logout(redirectUrl) {
if (redirectUrl) {
destination = redirectUrl;
}
// Backend clears the cookie, but frontend does it as fail-safe cleanup
document.cookie = "filebrowser_quantum_jwt=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
// Backend clears the HttpOnly session cookie.
void mutations.setCurrentUser(null);
// No need to clear state.jwt - cookie is the source of truth
// Add a small delay to ensure cookie deletion completes before redirect
@@ -106,15 +97,10 @@ export async function logout(redirectUrl) {
}
}
// Handle an authenticated request that came back 401 because the session cookie
// (JWT) is no longer valid — typically it expired while the tab was idle. Unlike
// logout(), this does NOT call the server (which would itself 401 on an expired
// token and leave the stale cookie in place); it clears the cookie locally and
// redirects to the login page so the user can re-authenticate, instead of being
// stuck on a raw "token is expired" error.
// Handle an authenticated request that came back 401 because the session is no
// longer valid — typically it expired while the tab was idle. Unlike logout(),
// this does NOT call the server; it clears client state and redirects to login.
export function sessionExpired() {
document.cookie =
"filebrowser_quantum_jwt=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
void mutations.setCurrentUser(null);
// Avoid a redirect loop if we're already on the login page.
if (window.location.pathname.endsWith("/login")) {
+4 -10
View File
@@ -18,8 +18,6 @@ vi.mock('@/utils/url.js', () => ({
import { validateLogin } from './auth.js';
const COOKIE = 'filebrowser_quantum_jwt';
function respond(status, body = '') {
global.fetch = vi.fn().mockResolvedValue({
status,
@@ -31,7 +29,7 @@ function respond(status, body = '') {
describe('validateLogin session-expiry handling', () => {
beforeEach(() => {
storeMock.mutations.setCurrentUser.mockClear();
document.cookie = `${COOKIE}=stale; path=/`;
storeMock.getters.isLoggedIn.mockReturnValue(true);
// Stub navigation via Vitest's global-stub API (jsdom doesn't implement
// real navigation). pathname+search feed the redirect; href captures it.
vi.stubGlobal('location', { pathname: '/files/', search: '?a=1', href: '' });
@@ -41,12 +39,10 @@ describe('validateLogin session-expiry handling', () => {
vi.unstubAllGlobals();
});
it('clears the cookie + user and redirects (with encoded return path) on a non-public 401 with a session cookie', async () => {
it('clears user state and redirects (with encoded return path) on a non-public 401 while logged in', async () => {
respond(401, 'token is expired');
await expect(validateLogin(false)).rejects.toThrow();
expect(storeMock.mutations.setCurrentUser).toHaveBeenCalledWith(null);
expect(document.cookie.includes(`${COOKIE}=stale`)).toBe(false);
// Full redirect contract: login route + correctly encoded current path+query.
expect(window.location.href).toBe(
`/login?redirect=${encodeURIComponent('/files/?a=1')}`
);
@@ -56,12 +52,11 @@ describe('validateLogin session-expiry handling', () => {
respond(401, 'unauthorized');
await expect(validateLogin(true)).rejects.toThrow();
expect(storeMock.mutations.setCurrentUser).not.toHaveBeenCalledWith(null);
expect(document.cookie.includes(`${COOKIE}=stale`)).toBe(true);
expect(window.location.href).toBe('');
});
it('does NOT log out or redirect on a non-public 401 when there is NO session cookie', async () => {
document.cookie = `${COOKIE}=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/`;
it('does NOT log out or redirect on a non-public 401 when the user is NOT logged in', async () => {
storeMock.getters.isLoggedIn.mockReturnValue(false);
respond(401, 'unauthorized');
await expect(validateLogin(false)).rejects.toThrow();
expect(storeMock.mutations.setCurrentUser).not.toHaveBeenCalledWith(null);
@@ -72,7 +67,6 @@ describe('validateLogin session-expiry handling', () => {
respond(500, 'server error');
await expect(validateLogin(false)).rejects.toThrow();
expect(storeMock.mutations.setCurrentUser).not.toHaveBeenCalledWith(null);
expect(document.cookie.includes(`${COOKIE}=stale`)).toBe(true);
expect(window.location.href).toBe('');
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { sanitizeDocxHtml } from "./docxPreview";
describe("sanitizeDocxHtml", () => {
it("strips javascript: hyperlinks", () => {
const input =
'<p><a href="javascript:alert(document.cookie)">CLICK HERE</a></p>';
const output = sanitizeDocxHtml(input);
expect(output).not.toContain("javascript:");
expect(output).toContain("CLICK HERE");
});
it("preserves normal formatting", () => {
const input = "<p>Hello <strong>world</strong></p>";
expect(sanitizeDocxHtml(input)).toBe(input);
});
it("preserves embedded data-uri images from mammoth", () => {
const input = '<p><img src="data:image/png;base64,abc" alt="chart"></p>';
const output = sanitizeDocxHtml(input);
expect(output).toContain('src="data:image/png;base64,abc"');
expect(output).toContain('alt="chart"');
});
it("strips inline event handlers", () => {
const input = '<p onclick="alert(1)">text</p>';
const output = sanitizeDocxHtml(input);
expect(output).not.toContain("onclick");
expect(output).toContain("text");
});
it("preserves https hyperlinks", () => {
const input = '<p><a href="https://example.com/doc">link</a></p>';
expect(sanitizeDocxHtml(input)).toBe(input);
});
});
+6
View File
@@ -0,0 +1,6 @@
import DOMPurify from "dompurify";
/** Sanitize mammoth DOCX HTML before v-html rendering. */
export function sanitizeDocxHtml(html: string): string {
return DOMPurify.sanitize(html);
}
+11 -3
View File
@@ -94,7 +94,7 @@ describe("rewriteCssContent", () => {
});
describe("buildHtmlPreview", () => {
it("rewrites linked assets and preserves scripts in sandboxed srcdoc", () => {
it("rewrites linked assets and strips script tags from srcdoc", () => {
const html = `<!DOCTYPE html>
<html>
<head>
@@ -109,13 +109,21 @@ describe("buildHtmlPreview", () => {
const { srcdoc } = buildHtmlPreview(html, "/pages/index.html", "src");
expect(srcdoc).toContain(encodeURIComponent("/pages/theme.css"));
expect(srcdoc).toContain(encodeURIComponent("/pages/app.js"));
expect(srcdoc).not.toContain("<script");
expect(srcdoc).not.toContain("app.js");
expect(srcdoc).toContain(encodeURIComponent("/pages/photo.jpg"));
expect(srcdoc).toContain(encodeURIComponent("/pages/other.html"));
expect(srcdoc).toContain("<script");
expect(srcdoc).not.toContain("javascript:");
});
it("strips inline and external scripts from uploaded HTML", () => {
const html = `<!DOCTYPE html><html><head><script>alert(document.cookie)</script></head><body><script src="https://evil.example/x.js"></script><p>ok</p></body></html>`;
const { srcdoc } = buildHtmlPreview(html, "/index.html", "src");
expect(srcdoc).not.toContain("<script");
expect(srcdoc).not.toContain("evil.example");
expect(srcdoc).toContain("<p>ok</p>");
});
it("strips dangerous protocols from attributes", () => {
const html = `<html><body><a href="javascript:alert(1)">bad</a></body></html>`;
const { srcdoc } = buildHtmlPreview(html, "/index.html", "src");
+2 -3
View File
@@ -6,8 +6,8 @@ import { resolveRelativePath } from "@/utils/url";
export const HTML_SANITIZE_CONFIG = {
USE_PROFILES: { html: true, svg: true, svgFilters: true },
WHOLE_DOCUMENT: true,
ADD_TAGS: ["link", "script"],
FORBID_TAGS: ["iframe", "object", "embed", "form", "base", "meta"],
ADD_TAGS: ["link"],
FORBID_TAGS: ["iframe", "object", "embed", "form", "base", "meta", "script"],
FORBID_ATTR: ["target", "formaction"],
};
@@ -29,7 +29,6 @@ const REWRITABLE_LINK_RELS = new Set([
]);
const RESOURCE_ATTRIBUTES: Array<[string, string]> = [
["script", "src"],
["link", "href"],
["img", "src"],
["video", "src"],
+7 -12
View File
@@ -334,15 +334,12 @@ class UploadManager {
this.hadActiveUploads = true; // Mark that we've had active uploads
upload.status = "uploading";
// Get chunk size in MB, default to 5 if not set or if 0
let chunkSizeMb = state.user.fileLoading?.uploadChunkSizeMb ?? 5;
if (chunkSizeMb === 0) {
chunkSizeMb = 5;
}
// Default to 5 MB when unset; explicit 0 disables chunking (single-shot upload).
const chunkSizeMb = state.user.fileLoading?.uploadChunkSizeMb ?? 5;
const chunkSize = chunkSizeMb * 1024 * 1024;
// Use non-chunked upload if file size is less than chunk size
if (upload.size < chunkSize) {
// Single-shot upload when chunking is disabled or file is smaller than chunk size.
if (chunkSize === 0 || upload.size < chunkSize) {
const progress = (percent) => {
this.updateProgress(upload, percent);
};
@@ -465,10 +462,7 @@ class UploadManager {
}
chunkSizeBytes() {
let chunkSizeMb = state.user.fileLoading?.uploadChunkSizeMb ?? 5;
if (chunkSizeMb === 0) {
chunkSizeMb = 5;
}
const chunkSizeMb = state.user.fileLoading?.uploadChunkSizeMb ?? 5;
return chunkSizeMb * 1024 * 1024;
}
@@ -494,7 +488,8 @@ class UploadManager {
if (upload?.status !== "uploading" || !upload.xhr) {
return;
}
if (upload.type !== "directory" && upload.size >= this.chunkSizeBytes()) {
const chunkBytes = this.chunkSizeBytes();
if (upload.type !== "directory" && chunkBytes > 0 && upload.size >= chunkBytes) {
try {
if (getters.isShare()) {
await resourcesApi.signalUploadPause(null, upload.path, state.shareInfo.hash);
+2 -1
View File
@@ -12,6 +12,7 @@ import * as mammoth from "mammoth";
import { resourcesApi } from "@/api";
import { state, mutations, getters } from "@/store";
import { url } from "@/utils";
import { sanitizeDocxHtml } from "@/utils/docxPreview";
export default defineComponent({
name: "DocxViewer",
@@ -185,7 +186,7 @@ export default defineComponent({
}
const result = await mammoth.convertToHtml({ arrayBuffer });
this.docxHtml = result.value;
this.docxHtml = sanitizeDocxHtml(result.value);
} catch (e) {
this.error = e.message || "An unknown error occurred.";
} finally {
+1 -1
View File
@@ -6,7 +6,7 @@
:key="req.path"
class="html-content"
:srcdoc="htmlPreview.srcdoc"
sandbox="allow-scripts allow-popups allow-same-origin"
sandbox="allow-scripts allow-popups"
referrerpolicy="no-referrer"
title="HTML preview"
></iframe>