Compare commits

..
Author SHA1 Message Date
Graham SteffaniakandCursor 2c071d1e6e fix(cli): preserve admin credentials in setup-generated config (#3267)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 16:19:34 -05:00
Graham Steffaniak b9afea9368 adjusted symlink extraction behavior to correct edge case (#3266) 2026-10-23 15:45:47 -05:00
a91bff2638 backport preview and share fixes to stable (#3264)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-23 15:29:41 -05:00
e02284183a fix(auth): API token permission handling for customized tokens (#3256)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-22 15:19:49 -05:00
05140f0ae3 fix: resolve staging/v2.0.2 release blockers (access perf revert, prompt cancel, tooltip dark mode) (#3239)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 11:25:49 -05:00
735842fd5f fix(sse): lock client registry during source-update delivery (GHSA-p9wm-f5jj-75pj) [stable] (#3208)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-19 19:00:10 -05:00
f0761c6146 fix(shares): preserve share paths when a folder is moved (GHSA-26fh-r34c-qxh6) [stable] (#3210)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-19 18:52:48 -05:00
Graham Steffaniak 9c812913c6 fix(frontend): restore upload guards while uploads are paused (stable) (#3221) 2026-10-19 13:52:07 -05:00
Cursor AgentandGraham Steffaniak e5e4fc4ec4 Merge branch 'staging/v2.0.2' into cursor/fix-upload-prompt-guard-stable-b1a8
Resolve Prompts.vue conflict by keeping upload guards in store mutations
and uploadNavigationGuard (PR refactor). CHANGELOG retains all v2.0.2-stable
security backports and upload guard bug fix entry.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-19 18:47:08 +00:00
Graham Steffaniak f8b7ee1a92 fix(frontend): escape Size Analyzer tooltip content (GHSA-rmjp-pfrq-cc3v) [stable] (#3212) 2026-10-19 12:41:28 -05:00
Graham Steffaniak 9e973c3959 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-rmjp-7fd7 2026-10-19 12:40:56 -05:00
Graham Steffaniak 7e97200e0a fix(auth): require password for passkey lifecycle changes (GHSA-r744-4vm3-2px3) [stable] (#3213) 2026-10-19 12:00:56 -05:00
Graham Steffaniak 44757aae7b Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-r744-7fd7 2026-10-19 12:00:31 -05:00
Graham Steffaniak 2d9eceadd6 fix(access): resolve filesystem aliases for path rules (GHSA-4vrc-935w-xxq2) [stable] (#3214) 2026-10-19 11:39:35 -05:00
Graham Steffaniak d13f5fd39d Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-4vrc-7fd7 2026-10-19 11:39:15 -05:00
Cursor AgentandGraham Steffaniak 3e0c93a435 fix(frontend): use staging api import paths in auth.js
The backport imported @/api/utils.ts and other dev-only module paths.
On staging/v2.0.2 the API layer uses utils.js and barrel imports from
@/store and @/notify.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-19 16:38:18 +00:00
Graham Steffaniak 04ae7b4764 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-r744-7fd7 2026-10-19 11:36:01 -05:00
Graham Steffaniak 22d5441d85 fix(stream): reject cross-context view grants on authenticated view API (GHSA-wxj5-g3qp-g732) [stable] (#3207) 2026-10-19 11:27:08 -05:00
Graham Steffaniak c3f340c7b6 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-wxj5-7fd7 2026-10-19 11:26:59 -05:00
Graham Steffaniak f044bd4266 fix(tools): scope duplicate finder cache per user (GHSA-4fhx-8pf6-5gmr) [stable] (#3209) 2026-10-19 11:26:22 -05:00
Graham Steffaniak 6ad9b8ea47 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-4fhx-7fd7 2026-10-19 11:26:10 -05:00
Cursor AgentandGraham Steffaniak 8e749deff2 fix(access): drop FS path cache; order alias resolution safely
Read directory entries from disk on each Permitted check. Resolve segments
with exact name, then case-fold + SameFile, then identity with ambiguity denied.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 22:37:38 +00:00
Cursor AgentandGraham Steffaniak bf8c18cf89 fix(frontend): clear stale upload navigation approval when idle
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 22:25:09 +00:00
Graham Steffaniak bb05475d83 Merge branch 'staging/v2.0.2' into cursor/fix-upload-prompt-guard-stable-b1a8 2026-10-18 17:22:39 -05:00
Graham Steffaniak db41b621d1 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-rmjp-7fd7 2026-10-18 17:12:01 -05:00
Graham Steffaniak e323f02d9e fix(webdav): require Create for new PUT targets (GHSA-8655-2ph3-q7q7) [stable] (#3211) 2026-10-18 17:11:42 -05:00
Graham Steffaniak c120419016 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-8655-7fd7 2026-10-18 17:11:31 -05:00
Graham Steffaniak 3ae4c2841f fix(auth): trust rightmost X-Forwarded-For for client IP (GHSA-wjp8-g49g-xrhx) [stable] (#3205) 2026-10-18 17:10:49 -05:00
Graham Steffaniak 4c05a913b9 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-wjp8-7fd7 2026-10-18 17:10:38 -05:00
Graham Steffaniak 865a1103c7 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-rmjp-7fd7 2026-10-18 17:08:53 -05:00
Graham Steffaniak ae2cb9f22a fix(frontend): files route parsing and /files/ default source (stable backport) (#3216) 2026-10-18 17:01:00 -05:00
Cursor AgentandGraham Steffaniak 15e803079f fix(frontend): keep staging router next() API for upload nav guard
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 21:59:48 +00:00
Cursor AgentandGraham Steffaniak dbd845e2e2 fix(frontend): harden upload nav guard and isUploading on queue removal
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 21:58:55 +00:00
Cursor AgentandGraham Steffaniak 177f48e5d7 fix(frontend): harden upload nav guard and isUploading on queue removal
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 21:57:29 +00:00
Cursor AgentandGraham Steffaniak 9e4f391f93 fix(access): resolve FS aliases via ReadDir and SameFile (GHSA-4vrc)
Replace ineffective Lstat().Name() resolution with per-segment directory
listing, SameFile matching, and fail-closed errors; add tests and path cache.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 21:56:45 +00:00
Cursor AgentandGraham Steffaniak 5d483e7953 fix(frontend): use next(false) for upload nav guard on staging router API
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 19:57:40 +00:00
Cursor AgentandGraham Steffaniak 865ccb3ae6 fix(frontend): guard upload prompt while uploads are paused
Treat paused queue items as in-progress for isUploading, centralize
closeTopPrompt protection in the store, add SPA navigation guard, and
keep beforeunload tied to isUploading instead of the Upload component.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 19:07:01 +00:00
Cursor AgentandGraham Steffaniak 74157deb3b fix(frontend): reject passkey password prompt on dismiss
Wire showPrompt cancel when the user closes the password dialog (X/Escape)
via Prompts.vue. Export isPasskeyActorPasswordCancelled and skip error toasts
in passkey settings UI when the user backs out.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 19:03:32 +00:00
Cursor AgentandGraham Steffaniak f168ce087d fix(auth): gate passkey password checks on password login; send X-Password from UI
Mirror GHSA-r744 beta fix (#3198): password-login accounts only for
checkOtpActorPassword; frontend prompts and sends X-Password on register/delete.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:58:17 +00:00
Cursor AgentandGraham Steffaniak c524a7d752 docs(changelog): use v2.0.2-stable section for GHSA-4vrc
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:39 +00:00
Cursor AgentandGraham Steffaniak f79f514a29 docs(changelog): use v2.0.2-stable section for GHSA-r744
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:38 +00:00
Cursor AgentandGraham Steffaniak 93bfa4906d docs(changelog): use v2.0.2-stable section for GHSA-rmjp
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:37 +00:00
Cursor AgentandGraham Steffaniak 18b8ed624f docs(changelog): use v2.0.2-stable section for GHSA-8655
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:36 +00:00
Cursor AgentandGraham Steffaniak a470e65a24 docs(changelog): use v2.0.2-stable section for GHSA-4fhx
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:33 +00:00
Cursor AgentandGraham Steffaniak e18b8683f5 docs(changelog): use v2.0.2-stable section for GHSA-wxj5
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:31 +00:00
Cursor AgentandGraham Steffaniak d03ee259a2 docs(changelog): use v2.0.2-stable section for GHSA-wjp8
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:28 +00:00
Graham Steffaniak 8a2f4a25d4 Refactor Tooltip and generic to avoid v-html usage (GHSA-rmjp-pfrq-cc3v) [stable] (#3217) 2026-10-18 13:07:24 -05:00
Cursor AgentandGraham Steffaniak 0d708f3955 fix(frontend): minimal /files/ and source-named-files routing (stable)
Backport #3099 knownSources guard and #3178 single-source fallback at
/files/ without changing multi-source redirect URLs.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:04:52 +00:00
Kevin B. 55c659e332 Refactor Tooltip.vue and generic.vue 2026-10-18 12:38:22 -05:00
Cursor AgentandGraham Steffaniak f095bc64ee fix(changelog): v2.0.2 security entry for GHSA-4fhx backport
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:43:56 +00:00
Cursor AgentandGraham Steffaniak 96efc21e88 fix(webdav): require Create for new PUT targets (GHSA-8655-2ph3-q7q7)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:42:42 +00:00
Cursor AgentandGraham Steffaniak 7b85410b50 fix(access): resolve filesystem aliases for access rules (GHSA-4vrc-935w-xxq2)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:40:52 +00:00
Cursor AgentandGraham Steffaniak c14a1af81e fix(auth): require password to change passkeys (GHSA-r744-4vm3-2px3)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:40:46 +00:00
Cursor AgentandGraham Steffaniak e8c85e3f90 fix(frontend): escape Size Analyzer tooltip content (GHSA-rmjp-pfrq-cc3v)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:40:45 +00:00
Cursor AgentandGraham Steffaniak c1f0033c65 fix(view): reject share view tokens on authenticated view route (GHSA-wxj5-g3qp-g732)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:40:41 +00:00
Cursor AgentandGraham Steffaniak a2d48144ea fix(tools): scope duplicate finder cache per user (GHSA-4fhx-8pf6-5gmr)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:39:49 +00:00
Cursor AgentandGraham Steffaniak 024d383b14 fix(auth): trust rightmost X-Forwarded-For for client IP (GHSA-wjp8-g49g-xrhx)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:37:50 +00:00
Graham Steffaniak aa4dd3ae95 updated v2.0.1 changelog (#3141) 2026-10-13 09:53:11 -05:00
Graham Steffaniak 9f4b15442b fix: reject usernames that do not map to their own home directory (#3139) 2026-10-13 09:33:41 -05:00
Graham Steffaniak 759342bef3 updated changelog 2026-10-13 09:12:40 -05:00
dl-pt c4072608d6 fix(auth): prevent collisions between newly issued tokens (#3135) 2026-10-13 09:09:21 -05:00
66 changed files with 2141 additions and 677 deletions

No files matched your search

+33
View File
@@ -2,6 +2,39 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v2.0.2-stable
**Security**:
- [Low] SSE source-update and user-targeted delivery hold the client registry lock through nonblocking sends, preventing concurrent map mutation and send-on-closed-channel panics when Realtime clients connect, disconnect, or shut down (GHSA-p9wm-f5jj-75pj) reporter @tao0845
- [Moderate] Moving or renaming a folder updates only shares whose paths are under the moved prefix, preserving per-file share roots instead of retargeting unrelated substring matches (GHSA-26fh-r34c-qxh6) reporters @jayhemnani9910 @dylanzonix
- [High] File Size Analyzer hover tooltips render file paths as plain text instead of HTML, closing stored XSS via crafted file names (GHSA-rmjp-pfrq-cc3v) reporter @U3RhcnQ
- [Moderate] Passkey registration and deletion now require the account password when a passkey is already configured, and passkeys cannot be altered through generic user profile PATCH (GHSA-r744-4vm3-2px3) reporter @tonghuaroot
- [Moderate] Authenticated GET /api/resources/view no longer accepts view tokens minted for a public share when the caller lacks view permission on the requested source (GHSA-wxj5-g3qp-g732) reporter @BlueN0r
- [Low] Duplicate finder results are cached per requesting user so filtered file names from one account are not served to another (GHSA-4fhx-8pf6-5gmr) reporter @Nazarami
- [Moderate] WebDAV PUT now requires Create permission when the target path does not yet exist, so Modify-only users cannot create new files (GHSA-8655-2ph3-q7q7) reporter @levanduy181
- [Low] Failed-login lockout and auth rate limits now use the rightmost `X-Forwarded-For` hop when proxy headers are trusted, so a client cannot spoof the client IP with a leftmost forwarded value (GHSA-wjp8-g49g-xrhx) reporter @LECOQQ
**Bug Fixes**:
- fix(auth): apply stored permission caps for customized API tokens with no global flags enabled; minimal tokens still inherit the owner's permissions
- Preview generation for WebP images (encode as JPEG thumbnail) ([#3259](https://github.com/gtsteffaniak/filebrowser/pull/3259), [#3254](https://github.com/gtsteffaniak/filebrowser/issues/3254))
- Public share pages show configured banner when API returns `bannerUrl` ([#3250](https://github.com/gtsteffaniak/filebrowser/pull/3250), [#3246](https://github.com/gtsteffaniak/filebrowser/issues/3246))
- Tar extraction creates hard-linked files instead of skipping `TypeLink` entries ([#3234](https://github.com/gtsteffaniak/filebrowser/pull/3234))
- Embedded JPEG previews for additional RAW/camera formats (ARW, RW2, RAW, RWL) ([#3243](https://github.com/gtsteffaniak/filebrowser/pull/3243))
- fix(preview): generate WebP image previews by encoding thumbnails as JPEG ([#3259](https://github.com/gtsteffaniak/filebrowser/pull/3259)) ([#3254](https://github.com/gtsteffaniak/filebrowser/issues/3254))
- fix(frontend): show configured share banner on public share pages when API returns `bannerUrl` ([#3250](https://github.com/gtsteffaniak/filebrowser/pull/3250)) ([#3246](https://github.com/gtsteffaniak/filebrowser/issues/3246))
- fix(archive): extract tar hard-linked files instead of skipping `TypeLink` entries ([#3234](https://github.com/gtsteffaniak/filebrowser/pull/3234))
- fix(preview): embedded JPEG previews for additional RAW/camera formats (RTF, RW2, RAW, RWL) ([#3243](https://github.com/gtsteffaniak/filebrowser/pull/3243))
- fix(frontend): keep source named `files` in `extractSourceFromPath` ([#3143](https://github.com/gtsteffaniak/filebrowser/issues/3143), [#3099](https://github.com/gtsteffaniak/filebrowser/pull/3099))
- fix(frontend): load the default source when browsing `/files/` with a single configured source ([#3178](https://github.com/gtsteffaniak/filebrowser/issues/3178)) (related: [#3144](https://github.com/gtsteffaniak/filebrowser/issues/3144))
- fix(frontend): restore upload prompt close, tab-reload, and in-app navigation guards while uploads are active (including paused uploads)
## v2.0.1-stable
**Security**:
- [High] New accounts named with ".." breaks out of scoped folder on creation (GHSA-c84q-q5g6-r47f) -- reporter @dylanzonix
- [Medium] Fixed session token collision issue when multiple users login simultaneously. (GHSA-c9cr-w724-gqj3) (#3135) Thanks @dl-pt reporters: @Ishtiaque-Ahmed-Risat @AlexWhiplash @tonghuaroot
## v2.0.0-stable
This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary.
+2 -2
View File
@@ -303,10 +303,10 @@ func generateSetupConfigYAML(config *settings.Settings) (string, error) {
for _, path := range embeddedPaths {
embeddedYaml, err := os.ReadFile(path)
if err == nil && len(embeddedYaml) > 0 {
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, string(embeddedYaml))
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, string(embeddedYaml), nil)
}
}
return settings.GenerateConfigYaml(config, true, false, false)
return settings.GenerateConfigYaml(config, true, false, false, nil)
}
func generateYaml() {
+8 -1
View File
@@ -6,9 +6,10 @@ import (
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// Auther is the authentication interface.
@@ -39,18 +40,24 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
if _, ok := user.Tokens[name]; ok {
return "", users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
}
id, err := utils.RandomHex(16)
if err != nil {
return "", users.AuthToken{}, fmt.Errorf("generate token id: %w", err)
}
now := time.Now()
expires := now.Add(duration)
// Create minimal token with only JWT standard claims
claim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
ID: id,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: FB_ISSUER,
},
},
}
claim.Minimal = &minimal
if !minimal {
claim.Permissions = users.SanitizeTokenPermissions(perms)
}
@@ -0,0 +1,56 @@
package auth
import (
"testing"
"testing/synctest"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSignedTokensAreUniqueAtSameTime(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-signing-key-for-token-uniqueness"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
// Freeze the clock so every token has identical issue and expiry times.
synctest.Test(t, func(t *testing.T) {
now := time.Now()
tokens := make(map[string]users.AuthToken)
for _, userID := range []uint64{1, 2} {
user := &users.User{ID: userID}
for _, minimal := range []bool{true, false} {
for range 2 {
tokenString, claim, err := MakeSignedTokenAPI(user, "test-token", time.Hour, users.Permissions{Api: true}, minimal)
if err != nil {
t.Fatal(err)
}
if _, exists := tokens[tokenString]; exists {
t.Fatalf("token collision for user %d with minimal=%v", userID, minimal)
}
tokens[tokenString] = claim
}
}
}
ids := make(map[string]bool)
for tokenString, claim := range tokens {
var parsed users.MinimalAuthToken
if _, err := jwt.ParseWithClaims(tokenString, &parsed, JWTSigningKeyFunc()); err != nil {
t.Fatalf("parse signed token: %v", err)
}
if parsed.ID == "" || ids[parsed.ID] {
t.Fatal("each signed token must have a unique nonempty id")
}
ids[parsed.ID] = true
if parsed.ID != claim.ID {
t.Fatal("signed token id must match returned metadata")
}
if parsed.IssuedAt == nil || !parsed.IssuedAt.Equal(now) || parsed.ExpiresAt == nil || !parsed.ExpiresAt.Equal(now.Add(time.Hour)) {
t.Fatal("token uniqueness must not depend on changing timestamps")
}
assertMinimalJWTClaims(t, tokenString)
}
})
}
+5 -11
View File
@@ -4,13 +4,12 @@ import (
stderrors "errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/crud"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -137,23 +136,18 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
return 0, err
}
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
newPath = utils.AddTrailingSlashIfNotExists(newPath)
updated := 0
for _, l := range links {
if l == nil || l.SourcePath != oldSource {
continue
}
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
pos := strings.Index(l.Path, oldPath)
if pos < 0 {
rewrittenPath, matches := utils.RewritePathPrefix(l.Path, oldPath, newPath)
if !matches {
continue
}
l.SourcePath = newSource
l.Path = newPath
l.Path = rewrittenPath
if err := s.back.Save(l); err != nil {
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
@@ -24,7 +24,11 @@ func HasAnyGlobalPermission(p Permissions) bool {
}
// IsMinimalApiToken reports whether the token inherits the owner's globals at auth time
// rather than embedding its own global permission caps in the JWT.
// rather than applying stored global permission caps.
func IsMinimalApiToken(t AuthToken) bool {
if t.Minimal != nil {
return *t.Minimal
}
// Legacy tokens lack minimal; empty stored global caps implied minimal (WebDAV-style).
return !HasAnyGlobalPermission(t.Permissions)
}
@@ -45,6 +45,14 @@ func TestIsMinimalApiToken(t *testing.T) {
t.Fatal("custom token with global caps should not be minimal")
}
if !IsMinimalApiToken(AuthToken{Permissions: Permissions{Modify: true}}) {
t.Fatal("token without global caps should be minimal")
t.Fatal("legacy token without global caps should be minimal")
}
minimal := true
if !IsMinimalApiToken(AuthToken{Minimal: &minimal}) {
t.Fatal("explicit minimal token should be minimal")
}
customized := false
if IsMinimalApiToken(AuthToken{Minimal: &customized}) {
t.Fatal("explicit customized token with empty caps should not be minimal")
}
}
@@ -21,3 +21,16 @@ func TestValidateUsernameAcceptsNormalName(t *testing.T) {
t.Fatalf("expected valid username, got %v", err)
}
}
func TestValidateUsernameRejectsUnsafeNames(t *testing.T) {
for _, username := range []string{"..", "....", ".", "-", "al..ice", "bob/", "./bob", `bob\`, "alice/docs", " alice", "alice "} {
if err := ValidateUsername(username); err == nil {
t.Fatalf("expected %q to be rejected", username)
}
}
for _, username := range []string{"alice", "first.last", "j.r.r", "a-b_c", "user@example.com", "José", "李雷", ".alice", "users"} {
if err := ValidateUsername(username); err != nil {
t.Fatalf("expected %q to be accepted, got %v", username, err)
}
}
}
+10 -1
View File
@@ -47,8 +47,10 @@ var reservedUsernames = map[string]struct{}{
strings.ToLower(AnonymousUserName): {},
}
// ValidateUsername rejects empty and reserved login names.
// ValidateUsername rejects empty and reserved login names, and names that do not map to their
// own createUserDir home directory.
func ValidateUsername(username string) error {
raw := username
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("username is empty")
@@ -56,6 +58,11 @@ func ValidateUsername(username string) error {
if _, reserved := reservedUsernames[strings.ToLower(username)]; reserved {
return fmt.Errorf("username %q is reserved", username)
}
// The per-user home directory is derived from CleanUsername(username), so reject names
// that it would rewrite, the names "." and "-", and names containing path separators.
if CleanUsername(raw) != raw || raw == "." || raw == "-" || strings.ContainsAny(raw, `/\`) {
return fmt.Errorf("username %q contains characters that are not allowed", raw)
}
return nil
}
@@ -97,6 +104,8 @@ type AuthToken struct {
BelongsTo uint64 `json:"belongsTo,omitempty"` // legacy stored metadata only, never trusted for auth; identity is always the hashed_tokens lookup
IssuedAt int64 `json:"issuedAt,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
// Minimal distinguishes owner-inheriting tokens from customized caps. Nil means legacy (infer from stored permissions).
Minimal *bool `json:"minimal,omitempty"`
Permissions Permissions `json:"Permissions,omitempty"`
}
+4 -20
View File
@@ -41,15 +41,13 @@ func handleUserEvents() {
for ue := range userEventChan {
for _, user := range ue.users {
userClientsMu.RLock()
conns := userClients[user]
userClientsMu.RUnlock()
for _, ch := range conns {
for _, ch := range userClients[user] {
select {
case ch <- ue.event:
default:
}
}
userClientsMu.RUnlock()
}
}
}
@@ -135,28 +133,14 @@ func DebouncedBroadcast(eventType, message string) {
func handleSourceUpdates() {
for update := range sourceUpdateChan {
sourceClientsMu.RLock()
clients := sourceClients[update.source]
clientCount := len(clients)
sourceClientsMu.RUnlock()
if clientCount == 0 {
// No clients registered for this source - this is normal if no one is connected
continue
}
sentCount := 0
for ch := range clients {
for ch := range sourceClients[update.source] {
select {
case ch <- update.event:
sentCount++
default:
// Channel full, message dropped
}
}
// Log if we have clients but couldn't send to all
//if sentCount < clientCount {
// // Some messages were dropped due to full channels
//}
sourceClientsMu.RUnlock()
}
}
@@ -0,0 +1,74 @@
package events
import (
"sync"
"testing"
"time"
)
func TestConcurrentRegisterUnregisterSourceUpdates(t *testing.T) {
stop := make(chan struct{})
go func() {
for {
select {
case <-stop:
return
default:
ch := Register("stress-user", []string{"stress-source"})
Unregister("stress-user", ch)
}
}
}()
for i := 0; i < 200; i++ {
SendSourceUpdate("stress-source", "tick")
}
close(stop)
time.Sleep(50 * time.Millisecond)
}
func TestConcurrentSSEClientLifecycleRace(t *testing.T) {
stop := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
user := "race-user-" + string(rune('a'+id))
source := "race-source-" + string(rune('a'+id%2))
for {
select {
case <-stop:
return
default:
ch := Register(user, []string{source, "shared-source"})
SendSourceUpdate(source, "tick")
SendSourceUpdate("shared-source", "tick")
SendToUsers("ping", "msg", []string{user})
Unregister(user, ch)
}
}
}(i)
}
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
SendSourceUpdate("shared-source", "broadcast")
SendToUsers("broadcast", "all", []string{"race-user-a", "race-user-b"})
}
}
}()
time.Sleep(100 * time.Millisecond)
Shutdown()
close(stop)
wg.Wait()
time.Sleep(50 * time.Millisecond)
}
+6 -5
View File
@@ -78,12 +78,10 @@ func extractTIFFEmbeddedPreview(f *os.File) ([]byte, error) {
return nil, err
}
ex, err := extimagemeta.Decode(f)
if err != nil {
return nil, err
var candidates []previewCandidate
if ex, err := extimagemeta.Decode(f); err == nil {
candidates = previewCandidates(ex)
}
candidates := previewCandidates(ex)
if scanCandidates, scanErr := collectTIFFPreviewCandidates(f); scanErr == nil {
candidates = append(candidates, scanCandidates...)
}
@@ -115,6 +113,9 @@ func previewCandidates(ex exif.Exif) []previewCandidate {
out = append(out, previewCandidate{offset: ifd.ImageOffset, length: ifd.ImageLength})
}
}
for _, sub := range ex.SubIFDs {
appendIFD(sub)
}
appendIFD(ex.IFD1)
appendIFD(ex.IFD2)
+8 -7
View File
@@ -47,15 +47,16 @@ func GetOrientation(ctx context.Context, path string) string {
default:
ex, err = extimagemeta.Decode(f)
}
if err != nil {
return ""
}
if ctx.Err() != nil {
return ""
}
if ex.IFD0.Orientation == 0 {
return ""
if err == nil && ex.IFD0.Orientation != 0 {
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(ex.IFD0.Orientation))
}
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(ex.IFD0.Orientation))
if !isHEICExtension(ext) {
if orient := extractTIFFOrientation(f); orient != 0 {
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(orient))
}
}
return ""
}
+109 -9
View File
@@ -9,15 +9,23 @@ import (
)
const (
tiffTagStripOffsets = 0x0111
tiffTagOrientation = 0x0112
tiffTagStripByteCounts = 0x0117
tiffTagSubIFDs = 0x014a
tiffTagJpgFromRaw = 0x002e
tiffTagJPEGInterchange = 0x0201
tiffTagJPEGInterchangeLen = 0x0202
tiffIFDScanLimit = 4 * 1024 * 1024
tiffMagicStandard = 42
tiffMagicPanasonicRW2 = 0x0055
tiffMagicOlympusORF1 = 0x4f52
tiffMagicOlympusORF2 = 0x5352
tiffTypeByte = 1
tiffTypeASCII = 2
tiffTypeShort = 3
tiffTypeLong = 4
tiffTypeIFD = 13
tiffTypeUndefined = 7
maxTIFFIFDsVisited = 128
maxTIFFIFDQueueLen = 128
@@ -96,6 +104,7 @@ func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
for _, ifd := range ifds {
out = appendPreviewCandidates(out, jpgFromRawCandidates(ifd))
out = appendPreviewCandidates(out, jpegInterchangeCandidates(ifd))
out = appendPreviewCandidates(out, stripPreviewCandidates(ifd, order))
if len(out) >= maxTIFFPreviewCandidates {
break
}
@@ -103,6 +112,42 @@ func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
return out, nil
}
// extractTIFFOrientation reads standard TIFF IFD0 Orientation (0x0112) as a fallback
// when full EXIF decoding fails.
func extractTIFFOrientation(f *os.File) uint16 {
info, err := f.Stat()
if err != nil {
return 0
}
scanSize := info.Size()
if scanSize > tiffIFDScanLimit {
scanSize = tiffIFDScanLimit
}
if scanSize < 8 {
return 0
}
header := make([]byte, scanSize)
n, err := f.ReadAt(header, 0)
if err != nil && err != io.EOF {
return 0
}
header = header[:n]
order, ifd0, ok := tiffHeaderIFD0(header)
if !ok || ifd0 == 0 {
return 0
}
ifd, _, _, err := parseTIFFIFD(header, order, ifd0)
if err != nil {
return 0
}
tag, ok := ifd.tags[tiffTagOrientation]
if !ok || tag.count == 0 {
return 0
}
return tiffTagFirstUint16(tag, order)
}
func appendPreviewCandidates(dst, src []previewCandidate) []previewCandidate {
remaining := maxTIFFPreviewCandidates - len(dst)
if remaining <= 0 {
@@ -143,6 +188,48 @@ func jpegInterchangeCandidates(ifd tiffIFD) []previewCandidate {
return []previewCandidate{{offset: offset, length: length}}
}
func stripPreviewCandidates(ifd tiffIFD, order binary.ByteOrder) []previewCandidate {
offTag, okOff := ifd.tags[tiffTagStripOffsets]
lenTag, okLen := ifd.tags[tiffTagStripByteCounts]
if !okOff || !okLen || offTag.count != 1 || lenTag.count != 1 {
return nil
}
offset := tiffTagScalarUint32(offTag, order)
length := tiffTagScalarUint32(lenTag, order)
if offset == 0 || length <= 4 || length > maxPreviewReadSize {
return nil
}
return []previewCandidate{{offset: offset, length: length}}
}
func tiffTagScalarUint32(t tiffTag, order binary.ByteOrder) uint32 {
switch t.typ {
case tiffTypeLong, tiffTypeIFD:
return t.valueOffset
case tiffTypeShort:
return uint32(tiffTagFirstUint16(t, order))
default:
return 0
}
}
func tiffTagFirstUint16(t tiffTag, order binary.ByteOrder) uint16 {
switch t.typ {
case tiffTypeShort:
if order == binary.BigEndian {
return uint16(t.valueOffset >> 16)
}
return uint16(t.valueOffset & 0xffff)
case tiffTypeLong:
if t.valueOffset > 0xffff {
return 0
}
return uint16(t.valueOffset)
default:
return 0
}
}
func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
if len(data) < 8 {
return nil, 0, false
@@ -156,7 +243,9 @@ func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
default:
return nil, 0, false
}
if order.Uint16(data[2:4]) != 42 {
switch order.Uint16(data[2:4]) {
case tiffMagicStandard, tiffMagicPanasonicRW2, tiffMagicOlympusORF1, tiffMagicOlympusORF2:
default:
return nil, 0, false
}
return order, order.Uint32(data[4:8]), true
@@ -205,19 +294,23 @@ func tiffTagUint32s(data []byte, order binary.ByteOrder, typ uint16, count uint3
if count > maxTIFFSubIFDsPerEntry {
count = maxTIFFSubIFDsPerEntry
}
elemSize := tiffTagByteSize(typ)
if elemSize <= 0 {
return nil
}
var raw []byte
switch {
case typ == tiffTypeLong && count == 1:
var b [4]byte
order.PutUint32(b[:], valueOffset)
raw = b[:]
case tiffTagByteSize(typ)*int(count) <= 4:
case elemSize*int(count) <= 4:
var b [4]byte
order.PutUint32(b[:], valueOffset)
raw = b[:count*uint32(tiffTagByteSize(typ))]
raw = b[:count*uint32(elemSize)]
default:
off := int(valueOffset)
n := int(count) * tiffTagByteSize(typ)
n := int(count) * elemSize
if off < 0 || off+n > len(data) {
return nil
}
@@ -225,11 +318,18 @@ func tiffTagUint32s(data []byte, order binary.ByteOrder, typ uint16, count uint3
}
out := make([]uint32, 0, count)
for i := 0; i < int(count); i++ {
pos := i * 4
if pos+4 > len(raw) {
pos := i * elemSize
if pos+elemSize > len(raw) {
break
}
out = append(out, order.Uint32(raw[pos:pos+4]))
switch typ {
case tiffTypeShort:
out = append(out, uint32(order.Uint16(raw[pos:pos+2])))
case tiffTypeLong, tiffTypeIFD:
out = append(out, order.Uint32(raw[pos:pos+4]))
default:
return nil
}
}
return out
}
@@ -240,9 +340,9 @@ func tiffTagByteSize(typ uint16) int {
return 1
case tiffTypeShort:
return 2
case tiffTypeLong:
case tiffTypeLong, tiffTypeIFD:
return 4
default:
return 1
return 0
}
}
+161 -4
View File
@@ -62,7 +62,7 @@ func TestCollectTIFFPreviewCandidatesIgnoresMalformedOffsets(t *testing.T) {
func TestCollectTIFFPreviewCandidatesIgnoresMalformedSubIFDOffset(t *testing.T) {
data := buildSyntheticTIFF(tiffFixture{
thumbnailJPEG: smallJPEG,
subIFDJPEG: largeJPEG,
subIFDJPEG: largeJPEG,
badJpgFromRaw: true,
})
f := openBytesFile(t, data)
@@ -93,9 +93,163 @@ func TestCollectTIFFPreviewCandidatesBoundsIFDTraversal(t *testing.T) {
}
}
func TestTiffTagUint32sSupportsIFDType(t *testing.T) {
data := buildSyntheticTIFF(tiffFixture{
thumbnailJPEG: smallJPEG,
subIFDJPEG: largeJPEG,
subIFDType: tiffTypeIFD,
})
f := openBytesFile(t, data)
candidates, err := collectTIFFPreviewCandidates(f)
if err != nil {
t.Fatal(err)
}
preview, err := readLargestJPEGPreview(f, candidates)
if err != nil {
t.Fatal(err)
}
if len(preview) != len(largeJPEG) {
t.Fatalf("preview size = %d, want %d (SubIFD type IFD)", len(preview), len(largeJPEG))
}
}
func TestExtractEmbeddedPreviewFallbackWhenDecodeFails(t *testing.T) {
// Synthetic ARW where SubIFD offset is placed after IFD2, and a SubIFD tag
// points past EOF so extimagemeta.Decode fails with EOF, while IFD2 holds a
// valid JPEGInterchangeFormat preview.
order := binary.LittleEndian
buf := make([]byte, 32*1024)
copy(buf[0:2], []byte("II"))
order.PutUint16(buf[2:4], tiffMagicStandard)
ifd0Off := uint32(8)
order.PutUint32(buf[4:8], ifd0Off)
thumbOff := uint32(4096)
copy(buf[thumbOff:], smallJPEG)
largeOff := uint32(8192)
copy(buf[largeOff:], largeJPEG)
ifd1Off := uint32(256)
ifd2Off := uint32(512)
subIFDOff := uint32(768)
writeIFD := func(offset uint32, entries []tiffEntry, next uint32) {
off := int(offset)
order.PutUint16(buf[off:off+2], uint16(len(entries)))
entryBase := off + 2
for i, entry := range entries {
pos := entryBase + i*12
order.PutUint16(buf[pos:pos+2], entry.tag)
order.PutUint16(buf[pos+2:pos+4], entry.typ)
order.PutUint32(buf[pos+4:pos+8], entry.count)
order.PutUint32(buf[pos+8:pos+12], entry.value)
}
order.PutUint32(buf[entryBase+len(entries)*12:entryBase+len(entries)*12+4], next)
}
writeIFD(ifd0Off, []tiffEntry{
{tag: tiffTagOrientation, typ: tiffTypeShort, count: 1, value: 6},
{tag: tiffTagSubIFDs, typ: tiffTypeLong, count: 1, value: subIFDOff},
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(smallJPEG))},
}, ifd1Off)
writeIFD(ifd1Off, []tiffEntry{
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(smallJPEG))},
}, ifd2Off)
writeIFD(ifd2Off, []tiffEntry{
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: largeOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(largeJPEG))},
}, 0)
// SubIFD has a StripOffsets tag with count=2 pointing beyond file end so Decode fails with EOF.
writeIFD(subIFDOff, []tiffEntry{
{tag: tiffTagStripOffsets, typ: tiffTypeLong, count: 2, value: uint32(len(buf) + 4096)},
}, 0)
path := filepath.Join(t.TempDir(), "sample.arw")
if err := os.WriteFile(path, buf[:int(largeOff)+len(largeJPEG)], 0o600); err != nil {
t.Fatal(err)
}
preview, err := ExtractEmbeddedPreview(t.Context(), path)
if err != nil {
t.Fatalf("ExtractEmbeddedPreview() err = %v", err)
}
if len(preview) != len(largeJPEG) {
t.Fatalf("ExtractEmbeddedPreview() len = %d, want %d", len(preview), len(largeJPEG))
}
if got := GetOrientation(t.Context(), path); got != "Rotate 90 CW" {
t.Fatalf("GetOrientation() = %q, want %q", got, "Rotate 90 CW")
}
}
func TestExtractEmbeddedPreviewSupportsRW2AndStripPreview(t *testing.T) {
order := binary.LittleEndian
buf := make([]byte, 16*1024)
copy(buf[0:2], []byte("II"))
order.PutUint16(buf[2:4], tiffMagicPanasonicRW2)
ifd0Off := uint32(8)
order.PutUint32(buf[4:8], ifd0Off)
largeOff := uint32(4096)
copy(buf[largeOff:], largeJPEG)
entries := []tiffEntry{
{tag: tiffTagStripOffsets, typ: tiffTypeLong, count: 1, value: largeOff},
{tag: tiffTagStripByteCounts, typ: tiffTypeLong, count: 1, value: uint32(len(largeJPEG))},
}
order.PutUint16(buf[ifd0Off:ifd0Off+2], uint16(len(entries)))
for i, entry := range entries {
pos := int(ifd0Off) + 2 + i*12
order.PutUint16(buf[pos:pos+2], entry.tag)
order.PutUint16(buf[pos+2:pos+4], entry.typ)
order.PutUint32(buf[pos+4:pos+8], entry.count)
order.PutUint32(buf[pos+8:pos+12], entry.value)
}
path := filepath.Join(t.TempDir(), "sample.rw2")
if err := os.WriteFile(path, buf[:int(largeOff)+len(largeJPEG)], 0o600); err != nil {
t.Fatal(err)
}
preview, err := ExtractEmbeddedPreview(t.Context(), path)
if err != nil {
t.Fatalf("ExtractEmbeddedPreview() err = %v", err)
}
if len(preview) != len(largeJPEG) {
t.Fatalf("ExtractEmbeddedPreview() len = %d, want %d", len(preview), len(largeJPEG))
}
}
func TestExtractEmbeddedPreviewDSC02672(t *testing.T) {
path := "/Users/steffag/Downloads/DSC02672-first-4MB.ARW"
if _, err := os.Stat(path); err != nil {
t.Skip(path)
}
got, err := ExtractEmbeddedPreview(t.Context(), path)
if err != nil {
t.Fatal(err)
}
if !IsJPEG(got) {
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
}
if len(got) != 2954147 {
t.Fatalf("preview size = %d, want 2954147", len(got))
}
if orient := GetOrientation(t.Context(), path); orient != "Horizontal (normal)" {
t.Fatalf("GetOrientation() = %q, want Horizontal (normal)", orient)
}
}
type tiffFixture struct {
thumbnailJPEG []byte
subIFDJPEG []byte
subIFDType uint16
badJpgFromRaw bool
badJPEGInterchg bool
}
@@ -116,6 +270,9 @@ func openBytesFile(t *testing.T, data []byte) *os.File {
func buildSyntheticTIFF(fix tiffFixture) []byte {
order := binary.LittleEndian
if fix.subIFDType == 0 {
fix.subIFDType = tiffTypeLong
}
buf := make([]byte, 64*1024)
thumbOff := uint32(4096)
@@ -149,7 +306,7 @@ func buildSyntheticTIFF(fix tiffFixture) []byte {
writeIFD(ifd1Off, ifd1Entries, 0)
ifd0Entries := []tiffEntry{
{tag: tiffTagSubIFDs, typ: tiffTypeLong, count: 1, value: ifd1Off},
{tag: tiffTagSubIFDs, typ: fix.subIFDType, count: 1, value: ifd1Off},
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(fix.thumbnailJPEG))},
}
@@ -170,8 +327,8 @@ func buildSyntheticTIFF(fix tiffFixture) []byte {
}
type tiffEntry struct {
tag, typ uint16
count, value uint32
tag, typ uint16
count, value uint32
}
func buildChainedTIFF(chainLen int) []byte {
+15 -1
View File
@@ -76,6 +76,18 @@ func (x Format) toImaging() imaging.Format {
}
}
// encodable reports whether imaging can encode this format.
// imaging only encodes JPEG, PNG, GIF, TIFF and BMP; webp and the netpbm
// formats are decode-only, so previews for them must be written as JPEG.
func (x Format) encodable() bool {
switch x {
case FormatJpeg, FormatPng, FormatGif, FormatTiff, FormatBmp:
return true
default:
return false
}
}
/*
ENUM(
high
@@ -203,7 +215,9 @@ func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
var wrappedReader = in
var err error
if opts.Format == FormatHeic {
// HEIC has no encoder and webp/netpbm are decode-only in imaging; fall back
// to JPEG so preview generation does not fail with ErrUnsupportedFormat.
if !opts.Format.encodable() {
opts.Format = FormatJpeg
}
// Ensure JPEG quality is set based on quality setting
+8
View File
@@ -159,6 +159,14 @@ func TestService_Resize(t *testing.T) {
},
matcher: sizeMatcher(100, 100),
},
"resize webp falls back to jpeg": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Format: FormatWebp},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/gray-sample.webp")
},
matcher: formatMatcher(FormatJpeg),
},
"resize with high quality": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityHigh},
source: func(t *testing.T) afero.File {
-9
View File
@@ -272,15 +272,6 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
case previewTypeImage:
ext := strings.ToLower(filepath.Ext(file.Name))
if iteminfo.IsRawImage(ext) {
if s.ffmpegService != nil {
previewBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err == nil && len(previewBytes) >= minPreviewSize {
return previewBytes, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
return nil, ErrUnsupportedFormat
}
return s.generateImagePreview(ctx, file, previewSize)
+4 -7
View File
@@ -6,9 +6,9 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -312,23 +312,20 @@ func UpdateSharesForMovedResource(oldSource, oldPath, newSource, newPath string)
sharesMux.Lock()
defer sharesMux.Unlock()
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
newPath = utils.AddTrailingSlashIfNotExists(newPath)
var updatedHashes []string
for _, l := range sharesByHash {
if l == nil || l.SourcePath != oldSource {
continue
}
norm := utils.AddTrailingSlashIfNotExists(l.Path)
if !strings.Contains(norm, oldPath) {
rewrittenPath, matches := utils.RewritePathPrefix(l.Path, oldPath, newPath)
if !matches {
continue
}
removeShareFromPathIndexLocked(l.SourcePath, l.Path, l.Hash)
l.SourcePath = newSource
l.Path = newPath
l.Path = rewrittenPath
if err := sqlDb.SaveShare(l); err != nil {
logger.Error("failed to save updated share after resource move", "hash", l.Hash, "error", err)
+140
View File
@@ -0,0 +1,140 @@
package state
import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
)
// newShareMoveTestStore opens an isolated SQLite store, points the package-level
// sqlDb at it, and resets the share caches so shares can be seeded via CreateShare.
func newShareMoveTestStore(t *testing.T) *sqldb.SQLStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.sqlite")
store, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
t.Fatal(err)
}
prevDB := sqlDb
prevByHash := sharesByHash
prevByPath := sharesByPath
sqlDb = store
sharesByHash = make(map[string]*share.Share)
sharesByPath = make(map[string][]string)
t.Cleanup(func() {
_ = store.Close()
sqlDb = prevDB
sharesByHash = prevByHash
sharesByPath = prevByPath
})
return store
}
func TestRewritePathPrefix(t *testing.T) {
cases := []struct {
name string
sharePath string
want string
matches bool
}{
{"file under moved dir", "/photos/2024/one.jpg", "/archive/photos/2024/one.jpg", true},
{"directory keeps trailing slash", "/photos/2024/", "/archive/photos/2024/", true},
{"exact dir match", "/photos/", "/archive/photos/", true},
{"exact file-form match", "/photos", "/archive/photos", true},
{"substring sibling does not match", "/backup/photos/", "/backup/photos/", false},
}
for _, tc := range cases {
got, matches := utils.RewritePathPrefix(tc.sharePath, "/photos/", "/archive/photos/")
if matches != tc.matches || got != tc.want {
t.Errorf("%s: RewritePathPrefix(%q) = (%q, %v), want (%q, %v)",
tc.name, tc.sharePath, got, matches, tc.want, tc.matches)
}
}
}
// TestUpdateSharesForMovedResource exercises the production updater end to end:
// seeded shares are rewritten in the cache, the path index, and SQL.
func TestUpdateSharesForMovedResource(t *testing.T) {
store := newShareMoveTestStore(t)
const oldSource = "/srv/photos"
const newSource = "/srv/archive"
seed := []share.Share{
{ShareColumns: share.ShareColumns{Hash: "file", Path: "/photos/2024/one.jpg"}, SourcePath: oldSource},
{ShareColumns: share.ShareColumns{Hash: "dir", Path: "/photos/2024/"}, SourcePath: oldSource},
{ShareColumns: share.ShareColumns{Hash: "exact", Path: "/photos/"}, SourcePath: oldSource},
{ShareColumns: share.ShareColumns{Hash: "sibling", Path: "/backup/photos/"}, SourcePath: oldSource},
{ShareColumns: share.ShareColumns{Hash: "othersrc", Path: "/photos/2024/"}, SourcePath: "/srv/other"},
}
for i := range seed {
s := seed[i]
if err := CreateShare(&s); err != nil {
t.Fatalf("seed share %s: %v", s.Hash, err)
}
}
updated, err := UpdateSharesForMovedResource(oldSource, "/photos", newSource, "/archive/photos/")
if err != nil {
t.Fatal(err)
}
updatedSet := make(map[string]bool, len(updated))
for _, h := range updated {
updatedSet[h] = true
}
for _, want := range []string{"file", "dir", "exact"} {
if !updatedSet[want] {
t.Fatalf("expected share %s in updated hashes %v", want, updated)
}
}
if len(updated) != 3 {
t.Fatalf("expected 3 updated shares, got %v", updated)
}
check := func(hash, wantPath, wantSource string) {
t.Helper()
s, getErr := GetShare(hash)
if getErr != nil {
t.Fatalf("GetShare(%s): %v", hash, getErr)
}
if s.Path != wantPath || s.SourcePath != wantSource {
t.Fatalf("share %s = (path %q, source %q), want (path %q, source %q)",
hash, s.Path, s.SourcePath, wantPath, wantSource)
}
}
check("file", "/archive/photos/2024/one.jpg", newSource)
check("dir", "/archive/photos/2024/", newSource)
check("exact", "/archive/photos/", newSource)
check("sibling", "/backup/photos/", oldSource)
check("othersrc", "/photos/2024/", "/srv/other")
// Writes went through to SQL, not just the cache.
persisted, err := store.ListAllShares()
if err != nil {
t.Fatal(err)
}
persistedPaths := make(map[string]string, len(persisted))
for _, l := range persisted {
persistedPaths[l.Hash] = l.Path
}
for hash, want := range map[string]string{
"file": "/archive/photos/2024/one.jpg",
"dir": "/archive/photos/2024/",
"exact": "/archive/photos/",
} {
if persistedPaths[hash] != want {
t.Fatalf("persisted share %s path = %q, want %q", hash, persistedPaths[hash], want)
}
}
// The path index tracks the new location.
moved, err := GetSharesByPath(newSource+"/", "/archive/photos/2024/")
if err != nil {
t.Fatal(err)
}
if len(moved) != 1 || moved[0].Hash != "dir" {
t.Fatalf("GetSharesByPath(new dir) = %+v, want dir share", moved)
}
}
+20
View File
@@ -84,3 +84,23 @@ func AddTrailingSlashIfNotExists(indexPath string) string {
}
return indexPath
}
// RewritePathPrefix rewrites sharePath when it is exactly oldPrefix or beneath it, swapping the
// prefix for newPrefix. Comparison is slash-aware (trailing slashes normalized), so a sibling path
// that merely shares a leading substring ("/backup/photos/" vs oldPrefix "/photos") does not match.
// A trailing slash on sharePath (directory convention) is preserved. Returns matches=false with the
// input unchanged on no match.
func RewritePathPrefix(sharePath, oldPrefix, newPrefix string) (rewritten string, matches bool) {
oldPrefix = AddTrailingSlashIfNotExists(oldPrefix)
newPrefix = AddTrailingSlashIfNotExists(newPrefix)
norm := AddTrailingSlashIfNotExists(sharePath)
if !strings.HasPrefix(norm, oldPrefix) {
return sharePath, false
}
suffix := strings.TrimPrefix(norm, oldPrefix)
rewritten = strings.TrimSuffix(newPrefix+suffix, "/")
if strings.HasSuffix(sharePath, "/") {
rewritten = AddTrailingSlashIfNotExists(rewritten)
}
return rewritten, true
}
+68
View File
@@ -1073,6 +1073,22 @@ func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) er
return applyArchivedTimesAndPerm(destPath, mode, modTime)
}
// removeSymlinkDestination removes destPath when it is a symlink so a regular-file
// write creates the entry at destPath instead of following the link.
func removeSymlinkDestination(destPath string) error {
info, err := os.Lstat(destPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&fs.ModeSymlink == 0 {
return nil
}
return os.Remove(destPath)
}
// extractArchivedRegularFile writes one regular file from src (closed by this function) and reapplies mode and mtime.
func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.Time, src io.ReadCloser) error {
defer func() { _ = src.Close() }()
@@ -1080,6 +1096,9 @@ func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.
if err := os.MkdirAll(parent, fileutils.EffectiveDirPerm()); err != nil {
return fmt.Errorf("mkdir %q: %w", parent, err)
}
if err := removeSymlinkDestination(destPath); err != nil {
return err
}
perm := mode.Perm()
if perm == 0 {
perm = fileutils.EffectiveFilePerm()
@@ -1118,6 +1137,47 @@ func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
}
// extractArchivedHardLink writes a copy of a regular file extracted earlier from the same
// archive, which is what a tar hard link entry stands for.
func extractArchivedHardLink(destRoot, destPath string, h *tar.Header, extracted map[string]os.FileInfo) error {
targetPath, err := safeExtractPath(destRoot, h.Linkname)
if err != nil {
return err
}
notExtracted := fmt.Errorf("hard link %q points to %q, which is not a file earlier in the archive", h.Name, h.Linkname)
written, ok := extracted[targetPath]
if !ok {
return notExtracted
}
src, err := os.Open(targetPath)
if err != nil {
logger.Debugf("unarchive: open hard link target: %v", err)
return fmt.Errorf("failed to write file from archive entry")
}
// A later entry may have replaced the target, e.g. with a symlink to another file.
info, err := src.Stat()
if err != nil || !info.Mode().IsRegular() || !os.SameFile(info, written) {
_ = src.Close()
return notExtracted
}
// The entry may name its own target, directly (GNU tar does this for a file archived
// twice). Writing it would truncate the file it copies from. Symlinks at destPath
// are replaced by extractArchivedRegularFile instead of being left in place.
if dest, err := os.Lstat(destPath); err == nil && dest.Mode().IsRegular() && os.SameFile(dest, info) {
return src.Close()
}
return extractArchivedRegularFile(destPath, h.FileInfo().Mode(), h.ModTime, src)
}
// recordExtractedFile remembers the regular file written at destPath for later hard links.
func recordExtractedFile(extracted map[string]os.FileInfo, destPath string) {
if info, err := os.Lstat(destPath); err == nil && info.Mode().IsRegular() {
extracted[destPath] = info
} else {
delete(extracted, destPath)
}
}
func extractZip(archivePath, destDir string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
@@ -1221,6 +1281,8 @@ func extractTarGz(archivePath, destDir string) error {
defer gz.Close()
tr := tar.NewReader(gz)
// Regular files written so far, for hard links to point at.
extracted := make(map[string]os.FileInfo)
for {
h, err := tr.Next()
if err == io.EOF {
@@ -1246,6 +1308,12 @@ func extractTarGz(archivePath, destDir string) error {
if err = extractArchivedRegularFile(destPath, mode, h.ModTime, io.NopCloser(tr)); err != nil {
return err
}
recordExtractedFile(extracted, destPath)
case tar.TypeLink:
if err = extractArchivedHardLink(destDir, destPath, h, extracted); err != nil {
return err
}
recordExtractedFile(extracted, destPath)
case tar.TypeSymlink:
if err = extractArchivedSymlink(destDir, destPath, h.Linkname, h.ModTime); err != nil {
return err
+247
View File
@@ -1,13 +1,38 @@
package web
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTestTarGz(t *testing.T, path string, write func(tw *tar.Writer) error) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
if err := write(tw); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
}
func TestNormalizeArchiveEntryName(t *testing.T) {
t.Parallel()
@@ -279,6 +304,228 @@ func TestExtractZip_nestedBackslashes(t *testing.T) {
}
}
func TestExtractTarGz_hardLinks(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "links.tar.gz")
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "proj/a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
return err
}
if _, err := io.WriteString(tw, "same"); err != nil {
return err
}
return tw.WriteHeader(&tar.Header{Name: "proj/sub/b.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "proj/a.txt"})
})
destDir := t.TempDir()
if err := extractTarGz(archivePath, destDir); err != nil {
t.Fatalf("extract: %v", err)
}
got, err := os.ReadFile(filepath.Join(destDir, "proj", "sub", "b.txt"))
if err != nil || string(got) != "same" {
t.Fatalf("hard link entry: got %q, %v", got, err)
}
missing := filepath.Join(t.TempDir(), "missing.tar.gz")
writeTestTarGz(t, missing, func(tw *tar.Writer) error {
return tw.WriteHeader(&tar.Header{Name: "b.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
})
if err := extractTarGz(missing, t.TempDir()); err == nil {
t.Fatal("expected an error for a hard link to a file that is not in the archive")
}
// A symlink entry replaces the extracted file before the hard link names it.
replaced := filepath.Join(t.TempDir(), "replaced.tar.gz")
writeTestTarGz(t, replaced, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
return err
}
if _, err := io.WriteString(tw, "mine"); err != nil {
return err
}
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "private/secret.txt"}); err != nil {
return err
}
return tw.WriteHeader(&tar.Header{Name: "b.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
})
replacedDest := t.TempDir()
if err := os.MkdirAll(filepath.Join(replacedDest, "private"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(replacedDest, "private", "secret.txt"), []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
if err := extractTarGz(replaced, replacedDest); err == nil {
t.Fatal("expected an error for a hard link whose target was replaced by a symlink")
}
if got, err := os.ReadFile(filepath.Join(replacedDest, "b.txt")); err == nil {
t.Fatalf("hard link entry copied the symlink target: %q", got)
}
// A hard link names its own target, as GNU tar writes for a file archived twice,
// or names it through a symlink.
self := filepath.Join(t.TempDir(), "self.tar.gz")
writeTestTarGz(t, self, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
return err
}
if _, err := io.WriteString(tw, "same"); err != nil {
return err
}
if err := tw.WriteHeader(&tar.Header{Name: "c.txt", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "a.txt"}); err != nil {
return err
}
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"}); err != nil {
return err
}
return tw.WriteHeader(&tar.Header{Name: "c.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
})
selfDest := t.TempDir()
if err := extractTarGz(self, selfDest); err != nil {
t.Fatalf("extract: %v", err)
}
if got, err := os.ReadFile(filepath.Join(selfDest, "a.txt")); err != nil || string(got) != "same" {
t.Fatalf("hard link to itself: got %q, %v", got, err)
}
}
func TestExtractTarGz_symlinkDestination(t *testing.T) {
t.Run("pre-existing symlink at destination", func(t *testing.T) {
destDir := t.TempDir()
victim := filepath.Join(destDir, "victim.txt")
if err := os.WriteFile(victim, []byte("untouched"), 0o644); err != nil {
t.Fatal(err)
}
outPath := filepath.Join(destDir, "out.txt")
if err := os.Symlink("victim.txt", outPath); err != nil {
t.Fatal(err)
}
archivePath := filepath.Join(t.TempDir(), "out.tar.gz")
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "out.txt", Mode: 0o644, Size: 7, Typeflag: tar.TypeReg}); err != nil {
return err
}
_, err := io.WriteString(tw, "payload")
return err
})
if err := extractTarGz(archivePath, destDir); err != nil {
t.Fatalf("extract: %v", err)
}
got, err := os.ReadFile(outPath)
if err != nil || string(got) != "payload" {
t.Fatalf("out.txt: got %q, %v", got, err)
}
victimData, err := os.ReadFile(victim)
if err != nil || string(victimData) != "untouched" {
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
}
info, err := os.Lstat(outPath)
if err != nil || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("out.txt should be a regular file, mode=%v err=%v", info.Mode(), err)
}
})
t.Run("symlink entry then regular file at same path", func(t *testing.T) {
destDir := t.TempDir()
other := filepath.Join(destDir, "other.txt")
if err := os.WriteFile(other, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
archivePath := filepath.Join(t.TempDir(), "link_then_reg.tar.gz")
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "link", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "other.txt"}); err != nil {
return err
}
if err := tw.WriteHeader(&tar.Header{Name: "link", Mode: 0o644, Size: 7, Typeflag: tar.TypeReg}); err != nil {
return err
}
_, err := io.WriteString(tw, "written")
return err
})
if err := extractTarGz(archivePath, destDir); err != nil {
t.Fatalf("extract: %v", err)
}
linkPath := filepath.Join(destDir, "link")
got, err := os.ReadFile(linkPath)
if err != nil || string(got) != "written" {
t.Fatalf("link: got %q, %v", got, err)
}
otherData, err := os.ReadFile(other)
if err != nil || string(otherData) != "keep" {
t.Fatalf("other.txt should be unchanged: %q, %v", otherData, err)
}
})
t.Run("hard link onto symlink to source", func(t *testing.T) {
destDir := t.TempDir()
archivePath := filepath.Join(t.TempDir(), "hardlink_symlink_dest.tar.gz")
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
return err
}
if _, err := io.WriteString(tw, "same"); err != nil {
return err
}
if err := tw.WriteHeader(&tar.Header{Name: "b.txt", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "a.txt"}); err != nil {
return err
}
return tw.WriteHeader(&tar.Header{Name: "c.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
})
if err := extractTarGz(archivePath, destDir); err != nil {
t.Fatalf("extract: %v", err)
}
cPath := filepath.Join(destDir, "c.txt")
got, err := os.ReadFile(cPath)
if err != nil || string(got) != "same" {
t.Fatalf("c.txt: got %q, %v", got, err)
}
info, err := os.Lstat(cPath)
if err != nil || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("c.txt should be a regular file")
}
})
}
func TestExtractZip_symlinkDestination(t *testing.T) {
destDir := t.TempDir()
victim := filepath.Join(destDir, "victim.txt")
if err := os.WriteFile(victim, []byte("untouched"), 0o644); err != nil {
t.Fatal(err)
}
outPath := filepath.Join(destDir, "out.txt")
if err := os.Symlink("victim.txt", outPath); err != nil {
t.Fatal(err)
}
zipPath := filepath.Join(t.TempDir(), "out.zip")
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
w, err := zw.Create("out.txt")
if err != nil {
t.Fatal(err)
}
if _, err = w.Write([]byte("payload")); err != nil {
t.Fatal(err)
}
if err = zw.Close(); err != nil {
t.Fatal(err)
}
if err = f.Close(); err != nil {
t.Fatal(err)
}
if err = extractZip(zipPath, destDir); err != nil {
t.Fatalf("extract: %v", err)
}
got, err := os.ReadFile(outPath)
if err != nil || string(got) != "payload" {
t.Fatalf("out.txt: got %q, %v", got, err)
}
victimData, err := os.ReadFile(victim)
if err != nil || string(victimData) != "untouched" {
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
}
}
// Benchmarks (optional, keep small)
func BenchmarkNormalizeArchiveEntryName(b *testing.B) {
const name = `PRO1000\Winx64\NDIS65\e1c65x64.cat`
+1 -1
View File
@@ -587,7 +587,7 @@ func applyNamedApiTokenGlobalCaps(user *users.User, tokenName string) {
if !ok {
return
}
if !users.HasAnyGlobalPermission(stored.Permissions) {
if users.IsMinimalApiToken(stored) {
return
}
user.Permissions = users.IntersectGlobalPermissions(user.Permissions, stored.Permissions)
+18
View File
@@ -197,9 +197,11 @@ func TestApplyNamedApiTokenGlobalCaps(t *testing.T) {
t.Run("custom api token caps globals", func(t *testing.T) {
user := *owner
customized := false
user.Tokens = map[string]users.AuthToken{
"customized": {
Name: "customized",
Minimal: &customized,
Permissions: users.Permissions{
Admin: false,
Api: true,
@@ -216,4 +218,20 @@ func TestApplyNamedApiTokenGlobalCaps(t *testing.T) {
t.Fatalf("expected api and share, got %#v", user.Permissions)
}
})
t.Run("customized zero global caps", func(t *testing.T) {
user := *owner
customized := false
user.Tokens = map[string]users.AuthToken{
"locked-down": {
Name: "locked-down",
Minimal: &customized,
Permissions: users.Permissions{},
},
}
applyNamedApiTokenGlobalCaps(&user, "locked-down")
if user.Permissions.Admin || user.Permissions.Api || user.Permissions.Share {
t.Fatalf("customized empty caps should clear globals, got %#v", user.Permissions)
}
})
}
@@ -0,0 +1,40 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
func createTokenAuthUser(t *testing.T, username string, perms users.Permissions) *users.User {
t.Helper()
u := &users.User{FrontendUser: users.FrontendUser{Username: username}}
if err := state.CreateUser(u, ""); err != nil {
t.Fatal(err)
}
stored, err := state.GetUserByUsername(username)
if err != nil {
t.Fatal(err)
}
stored.Permissions = perms
if err = state.UpdateUser(&stored, "", "permissions"); err != nil {
t.Fatal(err)
}
reloaded, err := state.GetUserByUsername(username)
if err != nil {
t.Fatal(err)
}
return &reloaded
}
func cookieRequest(t *testing.T, handler http.HandlerFunc, token string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/users", http.NoBody)
req.AddCookie(&http.Cookie{Name: "filebrowser_quantum_jwt", Value: token})
handler(rec, req)
return rec
}
+1 -1
View File
@@ -203,7 +203,7 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
// Generate cache key from all input parameters that affect results
// Checksums are always enabled, so cache key doesn't need to include that flag
cacheKey := fmt.Sprintf("%s:%s:%d", index.Path, opts.combinedPath, opts.minSize)
cacheKey := fmt.Sprintf("%s:%s:%s:%d", index.Path, opts.combinedPath, opts.username, opts.minSize)
// Check cache first (before acquiring mutex)
if cachedResults, ok := duplicateResultsCache.Get(cacheKey); ok {
+5 -2
View File
@@ -242,8 +242,11 @@ func GetRemoteIP(r *http.Request) string {
xff := r.Header.Get("X-Forwarded-For")
if cfg.Http.TrustProxyHeaders && xff != "" {
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
// Use the rightmost hop: the trusted proxy appends the connecting client, so the
// leftmost value is attacker-controlled when clients send X-Forwarded-For.
if ip := lastForwardedValue(xff); ip != "" {
return ip
}
}
xri := r.Header.Get("X-Real-IP")
+11
View File
@@ -19,6 +19,17 @@ func normalizeHTTPScheme(proto string) string {
}
}
func lastForwardedValue(v string) string {
parts := strings.Split(v, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip != "" {
return ip
}
}
return ""
}
func firstForwardedValue(v string) string {
v = strings.TrimSpace(v)
if i := strings.Index(v, ","); i >= 0 {
+12
View File
@@ -8,6 +8,18 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
settings.Config.Http.TrustProxyHeaders = true
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
req := httptest.NewRequest("GET", "http://example.com/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.50, 198.51.100.7")
if got := GetRemoteIP(req); got != "198.51.100.7" {
t.Fatalf("GetRemoteIP = %q, want 198.51.100.7", got)
}
}
func TestGetScheme(t *testing.T) {
t.Run("trusted proto", func(t *testing.T) {
settings.Config.Http.TrustProxyHeaders = true
@@ -6,12 +6,87 @@ import (
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSessionTokensRemainIndependent(t *testing.T) {
setupTestEnv(t)
origKey := settings.Config.Auth.Key
origExp := settings.Config.Auth.TokenExpirationHours
settings.Config.Auth.Key = "test-signing-key-for-independent-sessions"
settings.Config.Auth.TokenExpirationHours = 2
t.Cleanup(func() {
settings.Config.Auth.Key = origKey
settings.Config.Auth.TokenExpirationHours = origExp
})
user := createTokenAuthUser(t, "session-user", users.Permissions{Api: true})
admin := createTokenAuthUser(t, "session-admin", users.Permissions{Admin: true, Api: true})
owners := []*users.User{user, user, admin}
tokens := make([]string, len(owners))
for i, owner := range owners {
var err error
tokens[i], err = mintAndRegisterSessionToken(owner)
if err != nil {
t.Fatal(err)
}
}
for i, token := range tokens {
if userID, isSession, ok := state.HashedTokenOwner(token); !ok || !isSession || userID != owners[i].ID {
t.Fatalf("session %d lost its owner: userID=%d isSession=%v ok=%v", i, userID, isSession, ok)
}
want := http.StatusForbidden
if owners[i].Permissions.Admin {
want = http.StatusOK
}
if rec := cookieRequest(t, withAdmin(mockHandler), token); rec.Code != want {
t.Fatalf("session %d: got status %d, want %d", i, rec.Code, want)
}
}
if err := state.RevokeToken(tokens[0]); err != nil {
t.Fatal(err)
}
for i, token := range tokens {
want := http.StatusOK
if i == 0 {
want = http.StatusUnauthorized
}
if rec := cookieRequest(t, withUser(mockHandler), token); rec.Code != want {
t.Fatalf("session %d after revocation: got status %d, want %d", i, rec.Code, want)
}
}
}
func TestSessionTokenWithoutIDRemainsValid(t *testing.T) {
setupTestEnv(t)
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-signing-key-for-legacy-session"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
user := createTokenAuthUser(t, "legacy-session-user", users.Permissions{Api: true})
now := time.Now()
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
Issuer: auth.FB_ISSUER,
}).SignedString([]byte(settings.Config.Auth.Key))
if err != nil {
t.Fatal(err)
}
if err := state.RegisterSessionToken(token, user.ID); err != nil {
t.Fatal(err)
}
if rec := cookieRequest(t, withUser(mockHandler), token); rec.Code != http.StatusOK {
t.Fatalf("legacy session: got status %d, want %d", rec.Code, http.StatusOK)
}
}
func TestReplaceSessionTokenKeepsOldTokenWhenMintFails(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "rotation-user", users.Permissions{Api: true})
+6 -1
View File
@@ -232,7 +232,12 @@ func ValidateViewGrant(token string, d *Context, sourceName string) error {
utils.ViewGrantsCache.Delete(token)
return fmt.Errorf("view token expired")
}
viewCtx := contextForViewGrantValidation(d, grant.Source)
viewCtx := d
if d.Share.Hash != "" {
viewCtx = contextForViewGrantValidation(d, grant.Source)
} else if sourceName == "" || grant.Source != sourceName {
return fmt.Errorf("view token scope mismatch")
}
if !viewGrantScopeMatches(viewCtx, grant.Source, sourceName) {
return fmt.Errorf("view token scope mismatch")
}
+33
View File
@@ -226,6 +226,39 @@ func TestValidateViewGrantShareBinding(t *testing.T) {
}
}
func TestValidateViewGrantRejectsShareTokenOnAuthenticatedRoute(t *testing.T) {
initStreamTestSources(t)
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/srv": {Path: "/srv", Name: "srv"},
}
t.Cleanup(func() { settings.Config.Server.SourceMap = nil })
shareCtx := &requestContext{
User: testUserWithView(1, "srv"),
Share: share.Share{ShareColumns: share.ShareColumns{Hash: "abc123"}, SourcePath: "/srv"},
}
token, err := mintViewGrant(shareCtx, "")
if err != nil {
t.Fatal(err)
}
denied := &requestContext{
User: &users.User{
FrontendUser: users.FrontendUser{Username: "bob"},
ID: 2,
BackendScopes: []users.BackendScope{
{Path: "/srv", Scope: "/", Permissions: users.DenyAllSourceFilePermissions()},
},
BackendSourcePermissions: map[string]users.SourceFilePermissions{
"/srv": users.DenyAllSourceFilePermissions(),
},
Version: users.SourcePermissionsMigrationVersion,
},
}
if err := ValidateViewGrant(token, denied, "srv"); err == nil {
t.Fatal("expected share view token to be rejected on authenticated view route")
}
}
func TestAttachViewTokenForAllFileTypes(t *testing.T) {
t.Parallel()
initStreamTestSources(t)
-271
View File
@@ -1,271 +0,0 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func createTokenAuthUser(t *testing.T, username string, perms users.Permissions) *users.User {
t.Helper()
u := &users.User{FrontendUser: users.FrontendUser{Username: username}}
if err := state.CreateUser(u, ""); err != nil {
t.Fatal(err)
}
stored, err := state.GetUserByUsername(username)
if err != nil {
t.Fatal(err)
}
stored.Permissions = perms
if err = state.UpdateUser(&stored, "", "permissions"); err != nil {
t.Fatal(err)
}
reloaded, err := state.GetUserByUsername(username)
if err != nil {
t.Fatal(err)
}
return &reloaded
}
func cookieRequest(t *testing.T, handler http.HandlerFunc, token string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/users", http.NoBody)
req.AddCookie(&http.Cookie{Name: "filebrowser_quantum_jwt", Value: token})
handler(rec, req)
return rec
}
// A non-session bearer token whose hash has no stored permission metadata must be
// rejected rather than granted the owner's uncapped permissions.
func TestApiTokenWithoutMetadataRejected(t *testing.T) {
setupTestEnv(t)
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
user := createTokenAuthUser(t, "metadata-less-admin", users.Permissions{Admin: true, Api: true})
tokenString, _, err := auth.MakeSignedTokenAPI(user, "orphan-key", time.Hour, users.Permissions{}, true)
if err != nil {
t.Fatal(err)
}
// Register the hash mapping without any user.Tokens metadata.
if err := state.AddApiToken(tokenString, user.ID); err != nil {
t.Fatal(err)
}
rec := cookieRequest(t, withAdmin(mockHandler), tokenString)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("metadata-less API token: got status %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
// A named API token with stored caps must have those caps applied at auth time.
func TestApiTokenCapsAppliedFromStoredMetadata(t *testing.T) {
setupTestEnv(t)
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
user := createTokenAuthUser(t, "capped-admin", users.Permissions{Admin: true, Api: true})
// The token cap excludes admin, so the resolved user must lose admin.
tokenString, tokenMeta, err := auth.MakeSignedTokenAPI(user, "capped-key", time.Hour, users.Permissions{Share: true}, false)
if err != nil {
t.Fatal(err)
}
tokenMeta.Name = "capped-key"
tokenMeta.Token = tokenString
if err := state.AddUserToken(user.Username, tokenMeta); err != nil {
t.Fatal(err)
}
if err := state.AddApiToken(tokenString, user.ID); err != nil {
t.Fatal(err)
}
rec := cookieRequest(t, withAdmin(mockHandler), tokenString)
if rec.Code != http.StatusForbidden {
t.Fatalf("capped API token: got status %d, want %d", rec.Code, http.StatusForbidden)
}
}
// JwtAuth must reuse the session cookie it already issued instead of minting
// and registering a new session hash on every request.
func TestJwtAuthReusesSessionCookie(t *testing.T) {
setupTestEnv(t)
origAuthKey := settings.Config.Auth.Key
origJwtAuth := settings.Config.Auth.Methods.JwtAuth
origExp := settings.Config.Auth.TokenExpirationHours
t.Cleanup(func() {
settings.Config.Auth.Key = origAuthKey
settings.Config.Auth.Methods.JwtAuth = origJwtAuth
settings.Config.Auth.TokenExpirationHours = origExp
})
settings.Config.Auth.Key = "key"
settings.Config.Auth.TokenExpirationHours = 2
settings.Config.Auth.Methods.JwtAuth.Secret = "jwt-secret"
settings.Config.Auth.Methods.JwtAuth.Algorithm = "HS256"
settings.Config.Auth.Methods.JwtAuth.UserIdentifier = "sub"
externalJWT, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "jwt-user",
}).SignedString([]byte("jwt-secret"))
if err != nil {
t.Fatal(err)
}
call := func(cookieToken, authHeader, authQuery string) (*requestContext, *httptest.ResponseRecorder) {
t.Helper()
url := "/api/resources"
if authQuery != "" {
url += "?auth=" + authQuery
}
req := httptest.NewRequest(http.MethodGet, url, nil)
if authHeader != "" {
req.Header.Set("Authorization", "Bearer "+authHeader)
}
if cookieToken != "" {
req.AddCookie(&http.Cookie{Name: "filebrowser_quantum_jwt", Value: cookieToken})
}
rec := httptest.NewRecorder()
d := &requestContext{Ctx: req.Context()}
status, err := getJwtUser(rec, req, d, mockHandler, externalJWT)
if err != nil || status != http.StatusOK {
t.Fatalf("getJwtUser: status=%d err=%v", status, err)
}
return d, rec
}
// First request without a cookie mints a session token and sets the cookie.
d1, rec1 := call("", "", "")
if d1.Token == "" {
t.Fatal("expected a session token on first JwtAuth request")
}
if rec1.Header().Get("Set-Cookie") == "" {
t.Fatal("expected session cookie on first JwtAuth request")
}
// Follow-up requests carrying the session cookie must reuse it instead of
// minting and registering a new session.
d2, _ := call(d1.Token, "", "")
if d2.Token != d1.Token {
t.Fatal("expected JwtAuth to reuse the existing session token")
}
d3, _ := call(d1.Token, "", "")
if d3.Token != d1.Token {
t.Fatal("expected repeated JwtAuth requests to reuse the session token")
}
// When the external JWT also travels in the Authorization header or the
// auth query param, ExtractToken would prefer it over the session cookie.
// Reuse must still pick the cookie so those deployments don't mint a new
// session on every request.
d4, _ := call(d1.Token, externalJWT, "")
if d4.Token != d1.Token {
t.Fatal("expected JwtAuth to reuse the session cookie when the external JWT is in the Authorization header")
}
d5, _ := call(d1.Token, "", externalJWT)
if d5.Token != d1.Token {
t.Fatal("expected JwtAuth to reuse the session cookie when the external JWT is in the auth query param")
}
}
// A session cookie belonging to a different user must not be adopted by the
// JwtAuth user; a fresh session is minted instead.
func TestJwtAuthDoesNotReuseOtherUsersSessionCookie(t *testing.T) {
setupTestEnv(t)
origAuthKey := settings.Config.Auth.Key
origJwtAuth := settings.Config.Auth.Methods.JwtAuth
origExp := settings.Config.Auth.TokenExpirationHours
t.Cleanup(func() {
settings.Config.Auth.Key = origAuthKey
settings.Config.Auth.Methods.JwtAuth = origJwtAuth
settings.Config.Auth.TokenExpirationHours = origExp
})
settings.Config.Auth.Key = "key"
settings.Config.Auth.TokenExpirationHours = 2
settings.Config.Auth.Methods.JwtAuth.Secret = "jwt-secret"
settings.Config.Auth.Methods.JwtAuth.Algorithm = "HS256"
settings.Config.Auth.Methods.JwtAuth.UserIdentifier = "sub"
other := createTokenAuthUser(t, "other-user", users.Permissions{})
otherToken := testSessionToken(t, other, time.Hour)
externalJWT, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "jwt-user",
}).SignedString([]byte("jwt-secret"))
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/resources", nil)
req.AddCookie(&http.Cookie{Name: "filebrowser_quantum_jwt", Value: otherToken})
rec := httptest.NewRecorder()
d := &requestContext{Ctx: req.Context()}
status, err := getJwtUser(rec, req, d, mockHandler, externalJWT)
if err != nil || status != http.StatusOK {
t.Fatalf("getJwtUser: status=%d err=%v", status, err)
}
if d.Token == "" || d.Token == otherToken {
t.Fatal("expected a new session token, not the other user's cookie token")
}
}
// WebDAV authenticates with Basic Auth where the password is the raw API JWT.
// A minimal named token with a hashed_tokens row and stored metadata must pass;
// the same JWT without a hash mapping must 401 (the 2.0.8-beta regression).
func TestApiTokenBasicAuthRoundTrip(t *testing.T) {
setupTestEnv(t)
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
user := createTokenAuthUser(t, "webdav-user", users.Permissions{Api: true})
tokenString, tokenMeta, err := auth.MakeSignedTokenAPI(user, "obsidian-sync", time.Hour, users.Permissions{}, true)
if err != nil {
t.Fatal(err)
}
tokenMeta.Name = "obsidian-sync"
tokenMeta.Token = tokenString
if err = state.AddUserToken(user.Username, tokenMeta); err != nil {
t.Fatal(err)
}
if err = state.AddApiToken(tokenString, user.ID); err != nil {
t.Fatal(err)
}
basicRequest := func(token string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest("PROPFIND", "/dav/source1/", nil)
req.SetBasicAuth("webdav-user", token)
withBasicAuth(mockHandler)(rec, req)
return rec
}
if rec := basicRequest(tokenString); rec.Code != http.StatusOK {
t.Fatalf("backfilled API token over basic auth: got status %d, want %d", rec.Code, http.StatusOK)
}
// Same JWT shape with no hashed_tokens row must be rejected, never resolved
// via legacy BelongsTo claims. A different duration guarantees a distinct
// raw JWT (minimal claims carry only iss/iat/exp, so same-second mints
// would otherwise be byte-identical).
orphanString, _, err := auth.MakeSignedTokenAPI(user, "orphan-sync", 2*time.Hour, users.Permissions{}, true)
if err != nil {
t.Fatal(err)
}
rec := basicRequest(orphanString)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unmapped API token over basic auth: got status %d, want %d", rec.Code, http.StatusUnauthorized)
}
if rec.Header().Get("WWW-Authenticate") == "" {
t.Fatal("expected WWW-Authenticate challenge on basic auth 401")
}
}
+4 -1
View File
@@ -282,6 +282,9 @@ func validateNonAdminSelfPatchWhich(which []string) error {
if f == "" {
return fmt.Errorf("which must not contain empty field names")
}
if strings.EqualFold(f, "PasskeyCredentials") {
return fmt.Errorf("passkeys can only be managed through the passkey registration endpoints")
}
if strings.EqualFold(f, "Password") || strings.EqualFold(f, "OtpEnabled") {
continue
}
@@ -304,7 +307,7 @@ func userPutOnlyNonAdminEditableFields(which []string) bool {
if f == "" {
return false
}
if strings.EqualFold(f, "Password") {
if strings.EqualFold(f, "Password") || strings.EqualFold(f, "PasskeyCredentials") {
return false
}
if _, ok := allowed[f]; !ok {
+16
View File
@@ -11,6 +11,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
@@ -110,6 +111,11 @@ func beginPasskeyRegistrationHandler(w http.ResponseWriter, r *http.Request, d *
if !auth.IsWebAuthnEnabled() {
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
}
if d.User.LoginMethod == users.LoginMethodPassword && d.User.HasPasskeyMFA() {
if err := checkOtpActorPassword(r, d, d.User.Username); err != nil {
return http.StatusUnauthorized, err
}
}
registerRequestOrigin(r)
@@ -144,6 +150,11 @@ func finishPasskeyRegistrationHandler(w http.ResponseWriter, r *http.Request, d
if !auth.IsWebAuthnEnabled() {
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
}
if d.User.LoginMethod == users.LoginMethodPassword && d.User.HasPasskeyMFA() {
if err := checkOtpActorPassword(r, d, d.User.Username); err != nil {
return http.StatusUnauthorized, err
}
}
registerRequestOrigin(r)
@@ -188,6 +199,11 @@ func deletePasskeyCredentialHandler(w http.ResponseWriter, r *http.Request, d *C
if !auth.IsWebAuthnEnabled() {
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
}
if d.User.LoginMethod == users.LoginMethodPassword {
if err := checkOtpActorPassword(r, d, d.User.Username); err != nil {
return http.StatusUnauthorized, err
}
}
credentialID := r.PathValue("id")
if credentialID == "" {
+17
View File
@@ -206,6 +206,17 @@ func (ffs *filteredFileSystem) checkAccess(requestPath string) error {
return nil
}
func (ffs *filteredFileSystem) requireCreatePermissionForNewFile(ctx context.Context, requestPath string) error {
_, err := ffs.fs.Stat(ctx, requestPath)
if err == nil {
return nil
}
if os.IsNotExist(err) {
return fmt.Errorf("create permission required")
}
return err
}
// filteredFile wraps a webdav.File and filters Readdir results based on FileInfoFaster
type filteredFile struct {
webdav.File
@@ -322,6 +333,12 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
logger.Debugf("OpenFile: write access denied for %s: %v", requestPath, err)
return nil, err
}
if (flag&os.O_CREATE) != 0 && !ffs.sourceFilePerms().Create {
if err := ffs.requireCreatePermissionForNewFile(ctx, requestPath); err != nil {
return nil, err
}
}
}
file, err := ffs.fs.OpenFile(ctx, requestPath, flag, perm)
+34 -19
View File
@@ -981,8 +981,32 @@ func GenerateYaml() {
fmt.Printf("Generated YAML with comments (deprecated fields filtered): %s\n", output)
}
// finalizeSecretFields returns secret field metadata for YAML redaction.
// With no overrides, collected metadata is used (with reflection fallback). Pass nil to disable redaction.
func finalizeSecretFields(collected SecretFieldsMap, config *Settings, secretFields ...SecretFieldsMap) SecretFieldsMap {
if len(secretFields) > 0 {
return secretFields[0]
}
secrets := collected
secretsEmpty := true
for typeName := range secrets {
if len(secrets[typeName]) > 0 {
secretsEmpty = false
break
}
}
if secretsEmpty {
if secrets == nil {
secrets = make(SecretFieldsMap)
}
identifySecretFieldsByReflection(reflect.ValueOf(config), "Settings", secrets)
}
return secrets
}
// GenerateConfigYaml generates YAML from a given config with options for comments and filtering
func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filterDeprecated bool) (string, error) {
func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filterDeprecated bool, secretFields ...SecretFieldsMap) (string, error) {
// Try different source paths to handle both runtime and test scenarios
sourcePaths := []string{
".", // When running tests from settings directory
@@ -991,7 +1015,7 @@ func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filt
}
for _, sourcePath := range sourcePaths {
yamlOutput, err := GenerateConfigYamlWithSource(config, showComments, showFull, filterDeprecated, sourcePath)
yamlOutput, err := GenerateConfigYamlWithSource(config, showComments, showFull, filterDeprecated, sourcePath, secretFields...)
if err == nil {
return yamlOutput, nil
}
@@ -1002,11 +1026,11 @@ func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filt
}
// If all paths failed, try with empty maps to at least generate basic YAML
return GenerateConfigYamlWithEmptyMaps(config, showFull)
return GenerateConfigYamlWithEmptyMaps(config, showFull, secretFields...)
}
// GenerateConfigYamlWithEmbedded generates YAML from a given config using embedded YAML content as comment source
func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFull bool, filterDeprecated bool, embeddedYaml string) (string, error) {
func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFull bool, filterDeprecated bool, embeddedYaml string, secretFields ...SecretFieldsMap) (string, error) {
var comm CommentsMap
var secrets SecretFieldsMap
var deprecated DeprecatedFieldsMap
@@ -1035,6 +1059,8 @@ func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFul
deprecated = make(DeprecatedFieldsMap)
}
secrets = finalizeSecretFields(secrets, config, secretFields...)
var node *yaml.Node
if showFull {
@@ -1165,14 +1191,13 @@ func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, depr
}
// GenerateConfigYamlWithEmptyMaps generates YAML without comment parsing when source files are unavailable
func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, error) {
func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool, secretFields ...SecretFieldsMap) (string, error) {
// Create empty maps
comm := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
// Identify secret fields by reflection since we can't parse source files
identifySecretFieldsByReflection(reflect.ValueOf(config), "Settings", secrets)
secrets = finalizeSecretFields(secrets, config, secretFields...)
var node *yaml.Node
var err error
@@ -1217,7 +1242,7 @@ func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, e
}
// GenerateConfigYamlWithSource generates YAML from a given config with options for comments and filtering, using a custom source path
func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull bool, filterDeprecated bool, sourcePath string) (string, error) {
func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull bool, filterDeprecated bool, sourcePath string, secretFields ...SecretFieldsMap) (string, error) {
var comm CommentsMap
var secrets SecretFieldsMap
var deprecated DeprecatedFieldsMap
@@ -1239,17 +1264,7 @@ func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull
comm = make(CommentsMap)
}
// If secrets map is empty (directory parsing failed), use reflection to identify secrets
secretsEmpty := true
for typeName := range secrets {
if len(secrets[typeName]) > 0 {
secretsEmpty = false
break
}
}
if secretsEmpty {
identifySecretFieldsByReflection(reflect.ValueOf(config), "Settings", secrets)
}
secrets = finalizeSecretFields(secrets, config, secretFields...)
// If deprecated map is empty (directory parsing failed), use reflection to identify deprecated fields
deprecatedEmpty := true
+26
View File
@@ -515,3 +515,29 @@ func TestGenerateConfigYaml_OmitsEmptyRulesWhenFiltered(t *testing.T) {
t.Fatalf("expected empty rules to be omitted from filtered YAML, got:\n%s", out)
}
}
func TestGenerateSetupConfigYaml_IncludesSecretValues(t *testing.T) {
cfg := SetDefaults(false)
cfg.Server.Sources = []*Source{{Path: "./"}}
cfg.Auth.AdminUsername = "setup-admin"
cfg.Auth.AdminPassword = "setup-pass"
out, err := GenerateConfigYaml(&cfg, true, false, false, nil)
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "**hidden**") {
t.Fatalf("expected setup YAML to include secret values, got:\n%s", out)
}
if !strings.Contains(out, "setup-admin") || !strings.Contains(out, "setup-pass") {
t.Fatalf("expected admin credentials in setup YAML, got:\n%s", out)
}
hidden, err := GenerateConfigYaml(&cfg, true, false, false)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(hidden, "**hidden**") {
t.Fatal("expected secrets to be redacted in default config YAML export")
}
}
+14 -2
View File
@@ -1,6 +1,7 @@
package settings
import (
"path"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
@@ -22,12 +23,23 @@ func ExpandBackendScopesForCreateUserDir(u *users.User) {
if !ok {
continue
}
if source.Config.CreateUserDir && scopeBaseName(scope.Scope) != cleanedUserName {
u.BackendScopes[i].Scope = joinUnixScopePath(scope.Scope, cleanedUserName)
if !source.Config.CreateUserDir {
continue
}
// This runs more than once for a new user, so a scope that already ends in the user's
// directory is left alone. The source's defaultUserScope itself is not a user's home:
// a user named after its last element ("users" with "/users") gets "/users/users".
if scopeBaseName(scope.Scope) == cleanedUserName && !sameScopePath(scope.Scope, source.Config.DefaultUserScope) {
continue
}
u.BackendScopes[i].Scope = joinUnixScopePath(scope.Scope, cleanedUserName)
}
}
func sameScopePath(a, b string) bool {
return path.Clean("/"+a) == path.Clean("/"+b)
}
func scopeBaseName(scope string) string {
scope = strings.TrimSuffix(scope, "/")
if scope == "" || scope == "/" {
+36
View File
@@ -0,0 +1,36 @@
package settings
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestExpandBackendScopesForCreateUserDirNeverKeepsDefaultScope(t *testing.T) {
saved := Config
defer func() { Config = saved }()
Config = Settings{Server: Server{SourceMap: map[string]*Source{
"/srv": {Path: "/srv", Name: "main", Config: SourceConfig{CreateUserDir: true, DefaultUserScope: "/users"}},
}}}
cases := map[string]string{"bob": "/users/bob", "users": "/users/users", "first.last": "/users/first.last"}
for name, want := range cases {
u := &users.User{
FrontendUser: users.FrontendUser{Username: name},
BackendScopes: []users.BackendScope{{Path: "/srv", Scope: "/users"}},
}
ExpandBackendScopesForCreateUserDir(u)
ExpandBackendScopesForCreateUserDir(u) // runs more than once per new user; must be idempotent
if got := u.BackendScopes[0].Scope; got != want {
t.Fatalf("%s: scope=%q want %q", name, got, want)
}
}
// An admin-assigned scope that already ends in the username is kept.
u := &users.User{
FrontendUser: users.FrontendUser{Username: "bob"},
BackendScopes: []users.BackendScope{{Path: "/srv", Scope: "/users/bob"}},
}
ExpandBackendScopesForCreateUserDir(u)
if got := u.BackendScopes[0].Scope; got != "/users/bob" {
t.Fatalf("explicit scope changed: %q", got)
}
}
+4
View File
@@ -7295,6 +7295,10 @@ const docTemplate = `{
"description": "for backward compatibility",
"type": "string"
},
"minimal": {
"description": "Minimal distinguishes owner-inheriting tokens from customized caps. Nil means legacy (infer from stored permissions).",
"type": "boolean"
},
"name": {
"type": "string"
},
+4
View File
@@ -7284,6 +7284,10 @@
"description": "for backward compatibility",
"type": "string"
},
"minimal": {
"description": "Minimal distinguishes owner-inheriting tokens from customized caps. Nil means legacy (infer from stored permissions).",
"type": "boolean"
},
"name": {
"type": "string"
},
+4
View File
@@ -1522,6 +1522,10 @@ definitions:
key:
description: for backward compatibility
type: string
minimal:
description: Minimal distinguishes owner-inheriting tokens from customized
caps. Nil means legacy (infer from stored permissions).
type: boolean
name:
type: string
token:
+125 -9
View File
@@ -1,6 +1,8 @@
import { fetchJSON, fetchURL } from '@/api/utils'
import { notify } from '@/notify'
import { getApiPath } from '@/utils/url.js'
import { state, mutations } from '@/store'
import i18n from '@/i18n'
// POST /api/auth/password/change-required
export async function changeRequiredPassword(username, currentPassword, newPassword, otp) {
@@ -229,9 +231,9 @@ export async function deleteApiKey(params) {
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
bytes.forEach(b => {
binary += String.fromCharCode(b);
})
for (const b of bytes) {
binary += String.fromCharCode(b)
}
return btoa(binary).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}
@@ -327,17 +329,81 @@ export async function beginPasskeyLogin(username, password) {
}
}
export async function beginPasskeyRegistration() {
function passkeyActorPasswordHeaders(actorPassword) {
if (actorPassword === undefined || actorPassword === null) {
return {}
}
return { 'X-Password': encodeURIComponent(actorPassword) }
}
function isPasskeyActorPasswordRequired(status, message) {
if (status !== 401) {
return false
}
const msg = message || ''
return (
msg.includes('X-Password') ||
msg.includes('password is required') ||
msg.includes('invalid password')
)
}
function passkeyHttpError(message, status) {
const err = new Error(message)
err.status = status
return err
}
export function isPasskeyActorPasswordCancelled(err) {
return err?.cancelled === true || err?.name === 'PasskeyActorPasswordCancelled'
}
function passkeyActorPasswordCancelledError() {
const err = new Error('Password confirmation cancelled')
err.name = 'PasskeyActorPasswordCancelled'
err.cancelled = true
return err
}
function promptPasskeyActorPassword(i18nKey) {
return new Promise((resolve, reject) => {
let settled = false
const finish = (fn, value) => {
if (settled) {
return
}
settled = true
fn(value)
}
mutations.showPrompt({
name: 'password',
cancel: () => {
finish(reject, passkeyActorPasswordCancelledError())
},
props: {
infoText: i18n.global.t(i18nKey || 'prompts.confirmPasswordToSaveUser'),
submitLabel: i18n.global.t('general.confirm'),
submitCallback: async (actorPassword) => {
finish(resolve, actorPassword)
},
},
})
})
}
async function runBeginPasskeyRegistration(actorPassword) {
const passwordHeaders = passkeyActorPasswordHeaders(actorPassword)
const apiPath = getApiPath('auth/webauthn/begin-register')
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
headers: passwordHeaders,
})
const body = await res.json().catch(() => ({}))
if (res.status !== 200) {
const msg = body.message || 'Failed to begin passkey registration'
throw new Error(msg)
throw passkeyHttpError(msg, res.status)
}
const credential = await navigator.credentials.create({ publicKey: preprocessPublicKeyOptions(body.publicKey) })
@@ -348,25 +414,75 @@ export async function beginPasskeyRegistration() {
const finishRes = await fetch(finishPath, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...passwordHeaders,
},
body: JSON.stringify(formatted),
})
if (finishRes.status !== 200) {
const errBody = await finishRes.json().catch(() => ({}))
throw new Error(errBody.message || 'Failed to register passkey')
throw passkeyHttpError(errBody.message || 'Failed to register passkey', finishRes.status)
}
}
export async function deletePasskeyCredential(credentialId) {
export async function beginPasskeyRegistration(options = {}) {
if (options.actorPassword !== undefined) {
return runBeginPasskeyRegistration(options.actorPassword)
}
try {
return await runBeginPasskeyRegistration(undefined)
} catch (err) {
const canRetry =
state.user?.loginMethod === 'password' &&
options.skipActorPasswordConfirm !== true &&
isPasskeyActorPasswordRequired(err.status, err.message)
if (!canRetry) {
throw err
}
const actorPassword = await promptPasskeyActorPassword(options.actorPasswordPromptI18nKey)
return beginPasskeyRegistration({
...options,
actorPassword,
skipActorPasswordConfirm: true,
})
}
}
async function runDeletePasskeyCredential(credentialId, actorPassword) {
const apiPath = getApiPath(`auth/webauthn/${encodeURIComponent(credentialId)}`)
const res = await fetch(apiPath, {
method: 'DELETE',
credentials: 'same-origin',
headers: passkeyActorPasswordHeaders(actorPassword),
})
if (res.status !== 200) {
const errBody = await res.json().catch(() => ({}))
throw new Error(errBody.message || 'Failed to delete passkey')
throw passkeyHttpError(errBody.message || 'Failed to delete passkey', res.status)
}
}
export async function deletePasskeyCredential(credentialId, options = {}) {
if (options.actorPassword !== undefined) {
return runDeletePasskeyCredential(credentialId, options.actorPassword)
}
try {
return await runDeletePasskeyCredential(credentialId, undefined)
} catch (err) {
const canRetry =
state.user?.loginMethod === 'password' &&
options.skipActorPasswordConfirm !== true &&
isPasskeyActorPasswordRequired(err.status, err.message)
if (!canRetry) {
throw err
}
const actorPassword = await promptPasskeyActorPassword(options.actorPasswordPromptI18nKey)
return deletePasskeyCredential(credentialId, {
...options,
actorPassword,
skipActorPasswordConfirm: true,
})
}
}
+1 -1
View File
@@ -407,7 +407,7 @@ export default {
if (this.selectedSource === "__all__" || (this.selectedSource === "" && this.multipleSources)) {
return "/";
}
const result = url.extractSourceFromPath(decodeURIComponent(state.route.path));
const result = url.extractSourceFromPath(decodeURIComponent(state.route.path), Object.keys(state.sources.info));
if (this.selectedSource === "" || result.source === this.selectedSource) {
return result.path;
} else {
+80 -93
View File
@@ -15,104 +15,90 @@
:is="tooltip.component"
v-bind="tooltip.componentProps || {}"
/>
<div v-else v-html="tooltip.content"></div>
<div v-else class="tooltip-text">{{ tooltip.content }}</div>
</div>
</template>
<script>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import type { CSSProperties } from "vue";
import { useRoute } from "vue-router";
import { getters, state } from "@/store";
import { hideInteractiveTooltip } from "@/utils/tooltipHelp.js";
export default {
name: "Tooltip",
data() {
return {
adjustedX: 0,
adjustedY: 0,
margin: 15,
};
const margin = 15;
const adjustedX = ref(0);
const adjustedY = ref(0);
const tooltipRef = ref<HTMLElement | null>(null);
const route = useRoute();
const tooltip = computed(() => state.tooltip);
const hasComponent = computed(() => Boolean(tooltip.value.component));
const isDarkMode = computed(() => getters.isDarkMode());
const tooltipStyle = computed(() => {
const style: CSSProperties = {
top: `${adjustedY.value}px`,
left: `${adjustedX.value}px`,
};
if (tooltip.value.width) {
style.maxWidth = tooltip.value.width;
style.width = tooltip.value.width;
}
if (tooltip.value.width || tooltip.value.pointerEvents) {
style.maxHeight = "80vh";
style.overflowY = "auto";
}
return style;
});
async function updatePosition(x: number, y: number) {
await nextTick();
const tooltipEl = tooltipRef.value;
if (!tooltipEl) return;
const tooltipRect = tooltipEl.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if (x + margin + tooltipRect.width > windowWidth) {
adjustedX.value = x - margin - tooltipRect.width;
} else {
adjustedX.value = x + margin;
}
if (adjustedX.value < 0) {
adjustedX.value = margin;
}
if (y + margin + tooltipRect.height > windowHeight) {
adjustedY.value = y - margin - tooltipRect.height;
} else {
adjustedY.value = y + margin;
}
if (adjustedY.value < 0) {
adjustedY.value = margin;
}
}
watch(
() => route.fullPath,
() => hideInteractiveTooltip(true),
);
watch(
tooltip,
(newTooltip) => {
if (newTooltip.show) {
void updatePosition(newTooltip.x, newTooltip.y);
}
},
computed: {
tooltip() {
return state.tooltip;
},
hasComponent() {
return Boolean(this.tooltip.component);
},
isDarkMode() {
return getters.isDarkMode();
},
tooltipStyle() {
const style = {
top: `${this.adjustedY}px`,
left: `${this.adjustedX}px`,
};
if (this.tooltip.width) {
style.maxWidth = this.tooltip.width;
style.width = this.tooltip.width;
}
if (this.tooltip.width || this.tooltip.pointerEvents) {
style.maxHeight = "80vh";
style.overflowY = "auto";
}
return style;
},
},
watch: {
$route: {
handler() {
hideInteractiveTooltip(true);
},
},
tooltip: {
handler(newTooltip) {
if (newTooltip.show) {
this.updatePosition(newTooltip.x, newTooltip.y);
}
},
deep: true,
},
},
methods: {
/**
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
*/
updatePosition(x, y) {
this.$nextTick(() => {
const tooltipEl = this.$refs.tooltipRef;
if (!tooltipEl) return;
const tooltipRect = tooltipEl.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if (x + this.margin + tooltipRect.width > windowWidth) {
this.adjustedX = x - this.margin - tooltipRect.width;
} else {
this.adjustedX = x + this.margin;
}
if (this.adjustedX < 0) {
this.adjustedX = this.margin;
}
if (y + this.margin + tooltipRect.height > windowHeight) {
this.adjustedY = y - this.margin - tooltipRect.height;
} else {
this.adjustedY = y + this.margin;
}
if (this.adjustedY < 0) {
this.adjustedY = this.margin;
}
});
},
},
};
{ deep: true },
);
</script>
<style>
@@ -121,8 +107,8 @@ export default {
padding: 0.5em;
background-color: var(--alt-background);
color: var(--textPrimary);
border-radius: 1em;
box-shadow: 0 0.25em 1em rgba(0, 0, 0, 0.2);
border-radius: var(--borderRadius);
box-shadow: 0 0.25em 1em rgb(0 0 0 / 20%);
z-index: 9999;
pointer-events: none;
max-width: 20em;
@@ -146,6 +132,7 @@ export default {
pointer-events: auto;
cursor: pointer;
}
.tooltip-info-icon {
font-size: 1em !important;
padding: 0.1em !important;
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, h } from "vue";
const { mockState } = vi.hoisted(() => ({ mockState: {} }));
vi.mock("@/store", () => ({ state: mockState, getters: { getTime: () => "" } }));
vi.mock("@/api", () => ({ resourcesApi: {} }));
vi.mock("qrcode.vue", () => ({ default: { render: () => null } }));
import FilesShareInfo from "./ShareInfo.vue";
import PromptShareInfo from "../prompts/ShareInfo.vue";
// Shape returned by GET /public/api/share/info: `bannerUrl` is set, `banner` is not.
const publicShareInfo = {
hash: "abc",
shareType: "normal",
title: "Shared folder",
bannerUrl: "https://example.com/logo.svg",
shareURL: "https://example.com/public/share/abc",
};
function render(component, props = {}) {
const el = document.createElement("div");
const app = createApp({ render: () => h(component, props) });
app.config.globalProperties.$t = (key) => key;
app.mount(el);
return { el, app };
}
describe("share banner for public visitors", () => {
let mounted;
afterEach(() => mounted?.app.unmount());
it("sidebar share info shows the banner from bannerUrl", () => {
Object.assign(mockState, { shareInfo: { ...publicShareInfo }, req: {} });
mounted = render(FilesShareInfo, { hash: "abc", token: "" });
const img = mounted.el.querySelector(".banner img");
expect(img).not.toBeNull();
expect(img.getAttribute("src")).toBe(publicShareInfo.bannerUrl);
});
it("share info prompt shows the banner from bannerUrl", () => {
Object.assign(mockState, { shareInfo: { ...publicShareInfo }, req: {} });
mounted = render(PromptShareInfo);
const img = mounted.el.querySelector(".banner img");
expect(img).not.toBeNull();
expect(img.getAttribute("src")).toBe(publicShareInfo.bannerUrl);
});
it("no banner block when the share has no banner", () => {
Object.assign(mockState, { shareInfo: { ...publicShareInfo, bannerUrl: "" }, req: {} });
mounted = render(FilesShareInfo, { hash: "abc", token: "" });
expect(mounted.el.querySelector(".banner")).toBeNull();
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<div class="share-info">
<div class="share-info__box">
<div v-if="shareInfo.banner" class="banner">
<div v-if="shareInfo.bannerUrl" class="banner">
<img :src="shareInfo.bannerUrl" />
</div>
<div v-if="shareInfo.title" class="share-info__box__element">
@@ -12,7 +12,7 @@
</div>
<div>
<hr v-if="shareInfo.banner || shareInfo.title || shareInfo.description" />
<hr v-if="shareInfo.bannerUrl || shareInfo.title || shareInfo.description" />
<div v-if="showShareInfo">
<div class="share-info__box__element">
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
+42 -50
View File
@@ -1,5 +1,5 @@
<template>
<div class="card-content" v-html="body"></div>
<div class="card-content">{{ body }}</div>
<div class="card-actions">
<button
type="button"
@@ -14,56 +14,48 @@
</button>
</div>
</template>
<script>
<script setup lang="ts">
import { computed } from "vue";
// use this component for any prompt to display text message and custom buttons
// simple html body is supported, but anything complex should get its own component.
export default {
name: "generic-prompt",
props: {
title: {
type: String,
required: true,
},
body: {
type: String,
required: true,
},
buttons: {
type: Array,
required: false,
default: () => [],
},
// body is rendered as plain text, anything with markup should get its own component.
export interface PromptButton {
label: string;
action?: () => void;
className?: string;
}
const props = withDefaults(
defineProps<{
promptId?: string | number | null;
title: string;
body: string;
buttons?: PromptButton[];
}>(),
{
promptId: null,
buttons: () => [],
},
computed: {
displayButtons() {
// If buttons are provided, use them
if (this.buttons && this.buttons.length > 0) {
return this.buttons;
}
return [];
},
},
methods: {
handleButtonClick(button) {
// Execute the button's action
if (typeof button.action === 'function') {
try {
button.action();
} catch (error) {
console.error('Error executing button action:', error);
}
}
},
getButtonClass(button) {
// Default button classes
let classes = 'button button--flat';
// Add custom class if provided
if (button.className) {
classes += ` ${button.className}`;
}
return classes;
},
},
};
);
const displayButtons = computed(() => props.buttons);
function handleButtonClick(button: PromptButton) {
if (typeof button.action === "function") {
try {
button.action();
} catch (error) {
console.error("Error executing button action:", error);
}
}
}
function getButtonClass(button: PromptButton) {
let classes = "button button--flat";
if (button.className) {
classes += ` ${button.className}`;
}
return classes;
}
</script>
+108 -80
View File
@@ -34,7 +34,17 @@
<div class="debug-section">
<strong>{{ $t("onlyoffice.processSteps") }}:</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div v-html="debugInfo"></div>
<div v-for="(step, i) in debugInfo" :key="i">
{{ step.text }}
<template v-for="(line, j) in step.lines" :key="j">
<br/>
<strong v-if="line.label">{{ line.label }}</strong> {{ typeof line === "string" ? line : line.text }}
</template>
<template v-if="step.link">
<br/>
<a :href="wikiLink" target="_blank" rel="noopener noreferrer">📖 {{ $t("onlyoffice.troubleshootingGuide") }}</a> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</template>
</div>
</div>
<div v-if="onlyOfficeLogs.length > 0" class="debug-section debug-section-logs">
@@ -53,7 +63,18 @@
{{ noLogsMessage }}
</div>
<div v-if="overallStatus" v-html="overallStatus"></div>
<div v-if="overallStatus === 'error'" class="debug-status-error">
<strong>❌ {{ $t("onlyoffice.errorDetected") }}</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.integrationFailed") }}<br/>
<a :href="wikiLink" target="_blank" rel="noopener noreferrer">
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
📖 {{ $t("onlyoffice.troubleshootingGuide") }}
</a>
</div>
<div v-else-if="overallStatus === 'success'" class="debug-status-success">
<strong>🎉 {{ $t("onlyoffice.documentInitialized") }}</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.integrationSuccess") }}
</div>
</div>
</div>
</template>
@@ -69,6 +90,10 @@ const wikiLink = "https://filebrowserquantum.com/en/docs/integrations/office/tro
export default {
name: "officeDebug",
props: {
promptId: {
type: [String, Number],
default: null,
},
onlyOfficeUrl: {
type: String,
required: true,
@@ -84,7 +109,8 @@ export default {
},
data() {
return {
debugInfo: `⏳ ${this.$t("onlyoffice.startingInit")}`,
debugInfo: [{ text: `⏳ ${this.$t("onlyoffice.startingInit")}` }],
wikiLink,
clientConfig: {},
hasErrors: false,
onlyOfficeLogs: [],
@@ -151,28 +177,15 @@ export default {
return this.onlyOfficeLogs.slice(-10);
},
noLogsMessage() {
return !state.user.permissions.admin
return !state.user.permissions.admin
? this.$t("onlyoffice.logsNotAvailable")
: this.$t("onlyoffice.waitingForLogs");
},
overallStatus() {
if (this.hasErrors) {
return `
<div class="debug-status-error">
<strong>❌ ${this.$t("onlyoffice.errorDetected")}</strong><br/>
${this.$t("onlyoffice.integrationFailed")}<br/>
<a href="${wikiLink}" target="_blank">
📖 ${this.$t("onlyoffice.troubleshootingGuide")}
</a>
</div>
`;
} else if (this.debugInfo.includes("🎉") && !this.hasErrors) {
return `
<div class="debug-status-success">
<strong>🎉 ${this.$t("onlyoffice.documentInitialized")}</strong><br/>
${this.$t("onlyoffice.integrationSuccess")}
</div>
`;
return "error";
} else if (this.debugInfo.some((step) => step.text.includes("🎉"))) {
return "success";
}
return "";
},
@@ -188,7 +201,7 @@ export default {
this.sseConnection = null;
}
window.removeEventListener('onlyOfficeLogEvent', this.handleOnlyOfficeLogEvent);
if (state.user.permissions.admin && !state.user.permissions.realtime) {
console.log('🔌 Cleaning up OnlyOffice SSE connection');
events.stopSSE();
@@ -201,32 +214,32 @@ export default {
async initializeDebugger() {
try {
this.updateDebugStatus(`✅ ${this.$t("onlyoffice.configCheck")}`);
const configData = await officeApi.getConfig(state.req);
this.clientConfig = configData;
if (this.clientConfig.document?.key) {
this.documentId = this.clientConfig.document.key;
}
this.updateDebugStatus(`✅ ${this.$t("onlyoffice.apiRequest")}`);
this.updateDebugStatus(`✅ ${this.$t("onlyoffice.serverConnection")}`);
setTimeout(() => {
this.checkOnlyOfficeServer();
}, 100);
} catch (error) {
console.error("Error during OnlyOffice setup:", error);
const errorMsg = (error && typeof error === 'object' && 'message' in error) ? error.message : String(error);
this.updateDebugStatus(`❌ ${this.$t("onlyoffice.setupError")}: ${errorMsg}<br/><a href="${wikiLink}" target="_blank">📖 ${this.$t("onlyoffice.troubleshootingGuide")}</a>`);
this.updateDebugStatus({ text: `❌ ${this.$t("onlyoffice.setupError")}: ${errorMsg}`, link: true });
}
},
updateDebugStatus(message) {
this.debugInfo = `${this.debugInfo}<br/>${message}`;
this.debugInfo = [...this.debugInfo, typeof message === "string" ? { text: message } : message];
},
checkOnlyOfficeServer() {
const testUrl = `${this.onlyOfficeUrl}/web-apps/apps/api/documents/api.js`;
this.updateDebugStatus(`🔄 ${this.$t("onlyoffice.testingConnectivity")}`);
@@ -242,18 +255,24 @@ export default {
detectedNetworkError = true;
this.hasErrors = true;
let errorMsg = `❌ OnlyOffice Server Connection Refused<br/>`;
errorMsg += `<strong>Failed URL:</strong> ${errorStr.match(/GET ([^\s]+)/)?.[1] || testUrl}<br/>`;
errorMsg += `<strong>OnlyOffice Server:</strong> ${this.onlyOfficeUrl}<br/><br/>`;
errorMsg += `<strong>This means:</strong><br/>`;
errorMsg += `• OnlyOffice server is <strong>NOT RUNNING</strong><br/>`;
errorMsg += `• Wrong OnlyOffice URL configured<br/>`;
errorMsg += `• Network/firewall blocking connection<br/><br/>`;
errorMsg += `<strong>To fix:</strong><br/>`;
errorMsg += `• Start OnlyOffice Document Server<br/>`;
errorMsg += `• Verify ${this.onlyOfficeUrl} is accessible<br/>`;
errorMsg += `• Check Docker containers if using Docker<br/>`;
errorMsg += `<br/><a href="${wikiLink}" target="_blank" style="color: #1976d2;">📖 ${this.$t("onlyoffice.troubleshootingGuide")}</a>`;
const errorMsg = {
text: "❌ OnlyOffice Server Connection Refused",
lines: [
{ label: "Failed URL:", text: errorStr.match(/GET ([^\s]+)/)?.[1] || testUrl },
{ label: "OnlyOffice Server:", text: this.onlyOfficeUrl },
"",
{ label: "This means:" },
"• OnlyOffice server is NOT RUNNING",
"• Wrong OnlyOffice URL configured",
"• Network/firewall blocking connection",
"",
{ label: "To fix:" },
"• Start OnlyOffice Document Server",
`• Verify ${this.onlyOfficeUrl} is accessible`,
"• Check Docker containers if using Docker",
],
link: true,
};
this.updateDebugStatus(errorMsg);
}
@@ -269,7 +288,10 @@ export default {
.catch(() => {
if (!detectedNetworkError) {
this.hasErrors = true;
this.updateDebugStatus(`❌ OnlyOffice server test failed: ${testUrl}<br/>• Check if OnlyOffice server is running<br/>• Verify URL configuration`);
this.updateDebugStatus({
text: `❌ OnlyOffice server test failed: ${testUrl}`,
lines: ["• Check if OnlyOffice server is running", "• Verify URL configuration"],
});
}
});
@@ -283,7 +305,7 @@ export default {
}, 4000);
}, 1000);
},
monitorCallbackIssues() {
const originalFetch = window.fetch;
window.fetch = (...args) => {
@@ -291,41 +313,47 @@ export default {
if (response.url.includes('onlyoffice/callback')) {
if (response.status === 405) {
this.hasErrors = true;
this.updateDebugStatus(`
❌ Callback Method Not Allowed (405)<br/>
<strong>Issue:</strong> OnlyOffice server is sending ${args[1]?.method || 'GET'} requests to callback URL<br/>
<strong>Expected:</strong> POST requests<br/>
<strong>Root Cause:</strong> OnlyOffice server configuration issue<br/>
<strong>Solution:</strong><br/>
• Check OnlyOffice server callback configuration<br/>
• Verify callback URL is correctly set in OnlyOffice server<br/>
• Ensure OnlyOffice server is using correct HTTP method<br/>
• Check OnlyOffice server logs for callback configuration errors<br/>
`);
this.updateDebugStatus({
text: "❌ Callback Method Not Allowed (405)",
lines: [
{ label: "Issue:", text: `OnlyOffice server is sending ${args[1]?.method || 'GET'} requests to callback URL` },
{ label: "Expected:", text: "POST requests" },
{ label: "Root Cause:", text: "OnlyOffice server configuration issue" },
{ label: "Solution:" },
"• Check OnlyOffice server callback configuration",
"• Verify callback URL is correctly set in OnlyOffice server",
"• Ensure OnlyOffice server is using correct HTTP method",
"• Check OnlyOffice server logs for callback configuration errors",
],
});
} else if (response.status === 400) {
this.hasErrors = true;
this.updateDebugStatus(`
❌ Callback Bad Request (400)<br/>
<strong>Issue:</strong> OnlyOffice server sent invalid callback data<br/>
<strong>Root Cause:</strong> OnlyOffice server → FileBrowser API communication problem<br/>
<strong>Solution:</strong><br/>
• Check OnlyOffice server logs for callback generation errors<br/>
• Verify JWT secret matches between OnlyOffice and FileBrowser<br/>
• Check OnlyOffice server callback URL configuration<br/>
• Ensure OnlyOffice server can reach FileBrowser internal URL<br/>
`);
this.updateDebugStatus({
text: "❌ Callback Bad Request (400)",
lines: [
{ label: "Issue:", text: "OnlyOffice server sent invalid callback data" },
{ label: "Root Cause:", text: "OnlyOffice server → FileBrowser API communication problem" },
{ label: "Solution:" },
"• Check OnlyOffice server logs for callback generation errors",
"• Verify JWT secret matches between OnlyOffice and FileBrowser",
"• Check OnlyOffice server callback URL configuration",
"• Ensure OnlyOffice server can reach FileBrowser internal URL",
],
});
} else if (response.status === 500) {
this.hasErrors = true;
this.updateDebugStatus(`
❌ Callback Internal Server Error (500)<br/>
<strong>Issue:</strong> FileBrowser API error processing OnlyOffice callback<br/>
<strong>Root Cause:</strong> FileBrowser API configuration or processing issue<br/>
<strong>Solution:</strong><br/>
• Check FileBrowser server logs for callback processing errors<br/>
• Verify FileBrowser API configuration<br/>
• Check if OnlyOffice server is sending valid callback data<br/>
• Ensure FileBrowser has proper permissions to save files<br/>
`);
this.updateDebugStatus({
text: "❌ Callback Internal Server Error (500)",
lines: [
{ label: "Issue:", text: "FileBrowser API error processing OnlyOffice callback" },
{ label: "Root Cause:", text: "FileBrowser API configuration or processing issue" },
{ label: "Solution:" },
"• Check FileBrowser server logs for callback processing errors",
"• Verify FileBrowser API configuration",
"• Check if OnlyOffice server is sending valid callback data",
"• Ensure FileBrowser has proper permissions to save files",
],
});
}
}
return response;
@@ -336,7 +364,7 @@ export default {
window.fetch = originalFetch;
}, 15000);
},
setupOnlyOfficeLogStreaming() {
if (!state.user.permissions.admin) {
return;
@@ -376,7 +404,7 @@ export default {
this.onlyOfficeLogs = this.onlyOfficeLogs.slice(-50);
}
},
getLogLevelColor(level) {
switch (level) {
case 'ERROR': return '#f44336';
@@ -386,7 +414,7 @@ export default {
default: return '#666';
}
},
formatTimestamp(timestamp) {
return new Date(timestamp).toLocaleTimeString();
},
@@ -441,7 +469,7 @@ export default {
align-items: center;
margin-bottom: 8px;
padding-bottom: 5px;
border-bottom: 1px solid rgba(255,255,255,0.3);
border-bottom: 1px solid rgb(255 255 255 / 30%);
}
.debug-section-header-logs {
@@ -472,7 +500,7 @@ export default {
font-family: monospace;
padding: 2px 4px;
border-radius: 2px;
background: rgba(255,255,255,0.5);
background: rgb(255 255 255 / 50%);
color: #000;
}
+12 -30
View File
@@ -193,6 +193,7 @@ export default {
handler(newVal, oldVal) {
const prev = oldVal ?? [];
const oldIds = new Set(prev.map((p) => p.id));
const newIds = new Set(newVal.map((p) => p.id));
for (const p of newVal) {
if (!oldIds.has(p.id)) {
this.triggerPromptBorderFlash(p.id);
@@ -201,6 +202,11 @@ export default {
}
}
}
for (const id of oldIds) {
if (!newIds.has(id)) {
this.cleanupDragState(id);
}
}
},
deep: true,
},
@@ -406,37 +412,13 @@ export default {
}
},
closeTopPrompt(id) {
// Find the prompt we're trying to close
const promptToClose = state.prompts.find(p => p.id === id);
if (!promptToClose) return;
// Check if it's the upload prompt with active uploads
if (promptToClose.name === "upload") {
const hasActiveUploads = state.upload.isUploading;
const hasWarningPrompt = state.prompts.some(p => p.name === "CloseWithActiveUploads");
if (hasActiveUploads && !hasWarningPrompt) {
// Show warning prompt instead of closing
mutations.showPrompt({
name: "CloseWithActiveUploads",
pinned: true,
confirm: () => {
// User confirmed to close anyway - close the upload prompt
mutations.closeTopPrompt(id);
// Clean up state for this prompt
this.cleanupDragState(id);
},
cancel: () => {
// User cancelled - just close the warning prompt
mutations.closeTopPrompt();
},
});
return;
}
}
// Normal close behavior
const promptToClose = state.prompts.find((p) => p.id === id);
mutations.closeTopPrompt(id);
// Clean up state for this prompt
this.cleanupDragState(id);
// Only invoke cancel if the prompt was actually closed; the upload
// guard can intercept and keep it open.
if (promptToClose && !state.prompts.includes(promptToClose)) {
promptToClose.cancel?.();
}
},
cleanupDragState(id) {
this.dragOffsets = omitObjectProperty(this.dragOffsets, id);
+3 -10
View File
@@ -1,8 +1,8 @@
<template>
<div class="card-content">
<div class="share-info-content">
<div v-if="shareInfo.banner" class="banner">
<img :src="getShareBanner" />
<div v-if="shareInfo.bannerUrl" class="banner">
<img :src="shareInfo.bannerUrl" aria-hidden="true" />
</div>
<div v-if="shareInfo.title" class="share-info-element">
<h3>{{ shareInfo.title }}</h3>
@@ -12,7 +12,7 @@
</div>
<div v-if="showShareInfo">
<hr v-if="shareInfo.banner || shareInfo.title || shareInfo.description" />
<hr v-if="shareInfo.bannerUrl || shareInfo.title || shareInfo.description" />
<div class="share-info-element">
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
</div>
@@ -34,7 +34,6 @@
</template>
<script>
import { resourcesApi } from "@/api";
import { state, getters } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import QrcodeVue from "qrcode.vue";
@@ -54,12 +53,6 @@ export default {
}
return state.shareInfo?.passwordValid
},
getShareBanner() {
if (state.shareInfo?.banner.startsWith("http")) {
return state.shareInfo?.banner;
}
return resourcesApi.getDownloadURLPublic(state.shareInfo, [state.shareInfo?.banner]);
},
shareInfo() {
return state.shareInfo;
},
@@ -413,16 +413,6 @@ export default {
}
};
const handleBeforeUnload = (event) => {
// Warn user if they try to leave/refresh the page while uploads are active
if (isUploading.value) {
event.preventDefault();
// Chrome requires returnValue to be set
event.returnValue = '';
return '';
}
};
// Helper to get the destination path (from prop or fallback to current request)
const getDestinationPath = () => props.targetPath || state.req.path;
@@ -453,7 +443,6 @@ export default {
onMounted(async () => {
document.addEventListener("visibilitychange", void handleVisibilityChange);
window.addEventListener("beforeunload", handleBeforeUnload);
uploadManager.setOnConflict(handleConflict);
if (props.initialItems) {
await processItems(props.initialItems);
@@ -462,7 +451,6 @@ export default {
onUnmounted(() => {
document.removeEventListener("visibilitychange", void handleVisibilityChange);
window.removeEventListener("beforeunload", handleBeforeUnload);
uploadManager.setOnConflict(() => {}); // cleanup
releaseWakeLock();
});
@@ -1117,6 +1117,9 @@ export default {
notify.showSuccessToast(this.$t("profileSettings.passkeyAdded"));
setTimeout(() => { window.location.reload(); }, 500);
} catch (err) {
if (authApi.isPasskeyActorPasswordCancelled(err)) {
return;
}
notify.showError(err.message || this.$t("profileSettings.passkeyAddFailed"));
} finally {
this.addingPasskey = false;
@@ -1128,6 +1131,9 @@ export default {
notify.showSuccessToast(this.$t("profileSettings.passkeyDeleted"));
setTimeout(() => { window.location.reload(); }, 500);
} catch (err) {
if (authApi.isPasskeyActorPasswordCancelled(err)) {
return;
}
notify.showError(err.message || this.$t("profileSettings.passkeyDeleteFailed"));
}
},
@@ -30,6 +30,7 @@
import { notify } from "@/notify";
import { state } from "@/store";
import { authApi } from "@/api";
import { isPasskeyActorPasswordCancelled } from "@/api/auth.js";
export default {
name: "passkeySettings",
@@ -56,6 +57,9 @@ export default {
notify.showSuccessToast(this.$t("profileSettings.passkeyAdded"));
setTimeout(() => { window.location.reload(); }, 500);
} catch (err) {
if (isPasskeyActorPasswordCancelled(err)) {
return;
}
notify.showError(err.message || this.$t("profileSettings.passkeyAddFailed"));
} finally {
this.addingPasskey = false;
@@ -67,6 +71,9 @@ export default {
notify.showSuccessToast(this.$t("profileSettings.passkeyDeleted"));
setTimeout(() => { window.location.reload(); }, 500);
} catch (err) {
if (isPasskeyActorPasswordCancelled(err)) {
return;
}
notify.showError(err.message || this.$t("profileSettings.passkeyDeleteFailed"));
}
},
+19 -1
View File
@@ -5,7 +5,12 @@ import i18n from "@/i18n";
import { getters, mutations, state } from "@/store";
import { validateLogin } from "@/utils/auth";
import { sanitizePostLoginRedirect } from "@/utils/safeRedirect.js";
import { globalVars } from "@/utils/constants";
import { globalVars } from "@/utils/constants.js";
import {
evaluateUploadNavigationGuard,
isDifferentAppRoute,
showUploadNavigationConfirm,
} from "@/utils/uploadNavigationGuard";
import Errors from "@/views/Errors.vue";
import Files from "@/views/Files.vue";
import Layout from "@/views/Layout.vue";
@@ -177,6 +182,19 @@ router.beforeResolve(async (to, from, next) => {
return;
}
if (isDifferentAppRoute(to, from)) {
const uploadNav = evaluateUploadNavigationGuard(to);
if (uploadNav === "block") {
next(false);
return;
}
if (uploadNav === "prompt") {
showUploadNavigationConfirm(to, router);
next(false);
return;
}
}
// Clear any popup previews when navigating
mutations.setPreviewSource("");
+44 -1
View File
@@ -11,11 +11,24 @@ import { sortedItems } from "@/utils/sort.js";
import { isSourceSidebarCategory } from "@/utils/sidebarCategory";
import { updateManifestLink } from "@/utils/pwaManifest";
import { syncEventTheme } from "@/utils/theme";
import {
cancelCloseUploadPrompt,
confirmCloseUploadPrompt,
hasCloseWithActiveUploadsPrompt,
} from "@/utils/uploadNavigationGuard";
import { emitStateChanged } from './eventBus';
import { getters } from "./getters";
import { state } from "./state";
import type { SourceInfo, SourceInfoUpdate, UserObject } from "./types";
const uploadBeforeUnloadHandler = (event: BeforeUnloadEvent) => {
if (!state.upload.isUploading) {
return;
}
event.preventDefault();
event.returnValue = "";
};
export const mutations = {
disableEventThemes: () => {
if (state.disableEventThemes) {
@@ -319,6 +332,13 @@ export const mutations = {
return;
}
state.upload.isUploading = value;
if (typeof window !== "undefined") {
if (value) {
window.addEventListener("beforeunload", uploadBeforeUnloadHandler);
} else {
window.removeEventListener("beforeunload", uploadBeforeUnloadHandler);
}
}
emitStateChanged();
},
closeHovers: () => {
@@ -328,7 +348,30 @@ export const mutations = {
mutations.closeSidebar();
mutations.hideTooltip(true);
},
closeTopPrompt: (id) => {
closeTopPrompt: (id, options) => {
if (!options?.force && state.prompts.length > 0) {
const targetId =
id ?? state.prompts[state.prompts.length - 1]?.id;
const promptToClose = state.prompts.find((p) => p.id === targetId);
if (
promptToClose?.name === "upload" &&
state.upload.isUploading &&
!hasCloseWithActiveUploadsPrompt()
) {
mutations.showPrompt({
name: "CloseWithActiveUploads",
pinned: true,
confirm: () => {
confirmCloseUploadPrompt(targetId);
},
cancel: () => {
cancelCloseUploadPrompt();
},
});
return;
}
}
if (id === undefined) {
// close topmost prompt
if (state.prompts.length === 0) return;
+20 -5
View File
@@ -448,13 +448,12 @@ class UploadManager {
}
}
// Update isUploading state based on whether there are active or pending uploads
const hasActiveOrPending = this.activeUploads > 0 || this.hasPending();
mutations.setIsUploading(hasActiveOrPending);
// Update isUploading for close/navigation guards and beforeunload
mutations.setIsUploading(this.hasOngoingUploads());
// Only reload when we transition from having active uploads to having none
const hasNoActiveOrPending = this.activeUploads === 0 && !this.hasPending();
if (this.hadActiveUploads && hasNoActiveOrPending) {
const hasNoOngoingUploads = !this.hasOngoingUploads();
if (this.hadActiveUploads && hasNoOngoingUploads) {
console.log("all uploads processed ", this.queue);
// Only reload if there are no errors or conflicts - keep prompt open so users can see and retry
const hasErrorsOrConflicts = this.queue.some((item) =>
@@ -782,6 +781,8 @@ class UploadManager {
if (index !== -1) {
this.queue.splice(index, 1);
}
mutations.setIsUploading(this.hasOngoingUploads());
void this.processQueue();
}
retry(id, overwrite = false) {
@@ -833,6 +834,7 @@ class UploadManager {
if (hadCompleted && this.queue.length === 0) {
mutations.setReload(true);
}
mutations.setIsUploading(this.hasOngoingUploads());
}
findById(id) {
@@ -843,6 +845,19 @@ class UploadManager {
return this.queue.some((item) => item.status === "pending");
}
/** True while uploads may still run (including user- or stall-paused jobs). */
hasOngoingUploads() {
return (
this.activeUploads > 0 ||
this.queue.some(
(item) =>
item.status === "pending" ||
item.status === "uploading" ||
item.status === "paused"
)
);
}
getConflictingFolder() {
return this.conflictingFolder;
}
+40 -1
View File
@@ -26,7 +26,7 @@ vi.mock("@/utils/appNotifications", () => ({
import { resourcesApi } from "@/api";
import { notify } from "@/notify";
import { state } from "@/store";
import { mutations, state } from "@/store";
import { getters } from "@/store/getters";
import { isSameSize, numberedName, uploadManager } from "./upload";
@@ -396,3 +396,42 @@ describe("conflicts on loose files", () => {
expect(uploadManager.queue).toHaveLength(2);
});
});
describe("hasOngoingUploads", () => {
it("treats paused uploads as in progress", () => {
addUpload("paused");
uploadManager.activeUploads = 0;
expect(uploadManager.hasOngoingUploads()).toBe(true);
});
it("is false when the queue has only finished uploads", () => {
addUpload("completed");
uploadManager.activeUploads = 0;
expect(uploadManager.hasOngoingUploads()).toBe(false);
});
it("keeps isUploading true while uploads are paused", async () => {
addUpload("paused");
uploadManager.activeUploads = 0;
await uploadManager.processQueue();
expect(mutations.setIsUploading).toHaveBeenCalledWith(true);
});
it("clears isUploading when the last paused upload is cancelled", () => {
const item = addUpload("paused");
uploadManager.activeUploads = 0;
mutations.setIsUploading.mockClear();
uploadManager.cancel(item.id);
expect(mutations.setIsUploading).toHaveBeenCalledWith(false);
});
it("clears isUploading when clearAll removes the last paused upload", () => {
state.user.fileLoading.clearAll = true;
addUpload("paused");
uploadManager.activeUploads = 0;
mutations.setIsUploading.mockClear();
uploadManager.clearCompleted();
expect(mutations.setIsUploading).toHaveBeenCalledWith(false);
state.user.fileLoading.clearAll = false;
});
});
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { closeTopPrompt } = vi.hoisted(() => ({
closeTopPrompt: vi.fn(),
}));
vi.mock("@/store", () => ({
state: {
upload: { isUploading: true },
prompts: [],
},
mutations: {
closeTopPrompt,
showPrompt: vi.fn(),
},
}));
import { state } from "@/store";
import {
approveUploadNavigation,
cancelCloseUploadPrompt,
clearApprovedUploadNavigation,
consumeApprovedUploadNavigation,
dismissCloseWithActiveUploadsPrompt,
evaluateUploadNavigationGuard,
hasCloseWithActiveUploadsPrompt,
} from "./uploadNavigationGuard";
const to = (fullPath) => ({ fullPath, path: fullPath, hash: "", query: {} });
beforeEach(() => {
clearApprovedUploadNavigation();
state.upload.isUploading = true;
state.prompts = [];
closeTopPrompt.mockClear();
});
describe("uploadNavigationGuard", () => {
it("consumes a one-time approval for the matching fullPath", () => {
approveUploadNavigation("/files/docs/");
expect(consumeApprovedUploadNavigation(to("/files/docs/"))).toBe(true);
expect(consumeApprovedUploadNavigation(to("/files/docs/"))).toBe(false);
});
it("blocks navigation while the confirmation prompt is open", () => {
state.prompts = [{ id: 2, name: "CloseWithActiveUploads" }];
expect(hasCloseWithActiveUploadsPrompt()).toBe(true);
expect(evaluateUploadNavigationGuard(to("/files/other/"))).toBe("block");
});
it("clears approval on cancel helper", () => {
approveUploadNavigation("/files/a/");
state.prompts = [{ id: 9, name: "CloseWithActiveUploads" }];
cancelCloseUploadPrompt();
expect(closeTopPrompt).toHaveBeenCalledWith(9, { force: true });
state.prompts = [];
expect(evaluateUploadNavigationGuard(to("/files/a/"))).toBe("prompt");
});
it("does not reuse approval after uploads finish", () => {
approveUploadNavigation("/files/same/");
state.upload.isUploading = false;
expect(evaluateUploadNavigationGuard(to("/files/same/"))).toBe("allow");
state.upload.isUploading = true;
expect(evaluateUploadNavigationGuard(to("/files/same/"))).toBe("prompt");
});
it("dismisses all CloseWithActiveUploads prompts", () => {
state.prompts = [
{ id: 1, name: "upload" },
{ id: 2, name: "CloseWithActiveUploads" },
];
dismissCloseWithActiveUploadsPrompt();
expect(closeTopPrompt).toHaveBeenCalledWith(2, { force: true });
});
});
+105
View File
@@ -0,0 +1,105 @@
import type { RouteLocationNormalized, Router } from "vue-router";
import { mutations, state } from "@/store";
let approvedNavigationFullPath: string | null = null;
export function approveUploadNavigation(fullPath: string) {
approvedNavigationFullPath = fullPath;
}
export function clearApprovedUploadNavigation() {
approvedNavigationFullPath = null;
}
/** One-time bypass for the exact destination the user confirmed. */
export function consumeApprovedUploadNavigation(
to: RouteLocationNormalized
): boolean {
if (approvedNavigationFullPath === null) {
return false;
}
if (to.fullPath === approvedNavigationFullPath) {
approvedNavigationFullPath = null;
return true;
}
return false;
}
export function hasCloseWithActiveUploadsPrompt(): boolean {
return state.prompts.some((p) => p.name === "CloseWithActiveUploads");
}
export function dismissCloseWithActiveUploadsPrompt() {
const warnings = state.prompts.filter(
(p) => p.name === "CloseWithActiveUploads"
);
for (const warning of warnings) {
mutations.closeTopPrompt(warning.id, { force: true });
}
}
export function isDifferentAppRoute(
to: RouteLocationNormalized,
from: RouteLocationNormalized
): boolean {
return (
to.path !== from.path ||
to.hash !== from.hash ||
JSON.stringify(to.query) !== JSON.stringify(from.query)
);
}
export type UploadNavigationGuardResult = "allow" | "block" | "prompt";
export function evaluateUploadNavigationGuard(
to: RouteLocationNormalized
): UploadNavigationGuardResult {
if (!state.upload.isUploading) {
clearApprovedUploadNavigation();
return "allow";
}
if (consumeApprovedUploadNavigation(to)) {
return "allow";
}
if (hasCloseWithActiveUploadsPrompt()) {
return "block";
}
return "prompt";
}
export function showUploadNavigationConfirm(
to: RouteLocationNormalized,
router: Router
) {
const uploadPrompt = state.prompts.find((p) => p.name === "upload");
mutations.showPrompt({
name: "CloseWithActiveUploads",
pinned: true,
confirm: () => {
approveUploadNavigation(to.fullPath);
dismissCloseWithActiveUploadsPrompt();
if (uploadPrompt) {
mutations.closeTopPrompt(uploadPrompt.id, { force: true });
}
void router.push(to).finally(() => {
clearApprovedUploadNavigation();
});
},
cancel: () => {
clearApprovedUploadNavigation();
dismissCloseWithActiveUploadsPrompt();
},
});
}
export function confirmCloseUploadPrompt(uploadPromptId?: number) {
dismissCloseWithActiveUploadsPrompt();
if (uploadPromptId !== undefined) {
mutations.closeTopPrompt(uploadPromptId, { force: true });
}
}
export function cancelCloseUploadPrompt() {
clearApprovedUploadNavigation();
dismissCloseWithActiveUploadsPrompt();
}
+7 -4
View File
@@ -220,17 +220,20 @@ export function base64Encode(str) {
}
// Expect route paths like /files/{source}/... or, under a subpath baseURL, /{source}/...
export function extractSourceFromPath(url) {
// knownSources: optional list of configured source names used to distinguish a source
// literally named "files" from a doubled route-prefix produced by misconfigured redirects.
export function extractSourceFromPath(url, knownSources = []) {
const parts = removeTrailingSlash(url).split("/").filter(Boolean);
let i = 0;
if (parts[i] === "files") {
if (parts.at(i) === "files") {
i += 1;
}
// Bad redirects with baseURL can produce /files/files/{source}/... — not a source named "files".
if (parts[i] === "files" && parts.length > i + 2) {
// Only strip the extra segment when "files" is not a configured source name.
if (parts[i] === "files" && !knownSources.includes("files") && parts.length > i + 2) {
i += 1;
}
const source = parts[i] || "";
const source = parts.at(i) || "";
const rest = parts.slice(i + 1).join("/");
return { source, path: rest ? `/${rest}` : "/" };
}
+13
View File
@@ -136,4 +136,17 @@ describe('extractSourceFromPath', () => {
expect(result.path).toEqual(test.expected.path);
}
});
it('preserves source named "files" when it is a configured source', () => {
const knownSources = ["files"];
const tests = [
{ url: "/files/files/subfolder/nested", expected: { source: "files", path: "/subfolder/nested" } },
];
for (const test of tests) {
const result = extractSourceFromPath(test.url, knownSources);
expect(result.source).toEqual(test.expected.source);
expect(result.path).toEqual(test.expected.path);
}
});
});
+12 -1
View File
@@ -429,11 +429,22 @@ export default {
break;
}
}
if (removeTrailingSlash(targetPath) === "/files" && state.sources.current) {
targetPath = `/files/${encodeURIComponent(state.sources.current)}/`;
}
void router.push(targetPath);
return;
}
const result = extractSourceFromPath(getters.routePath());
const result = extractSourceFromPath(getters.routePath(), Object.keys(state.sources.info));
if (result.source === "" && state.sources.count === 1) {
const fallback = state.sources.defaultSource || state.sources.current;
if (fallback) {
result.source = fallback;
result.path = "/";
}
}
if (result.source === "") {
// No sources available - show a more graceful message instead of error popup
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B