mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-15 13:35:55 -05:00
Compare commits
61
Commits
No files matched your search
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 == "/" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}` : "/" };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 |
Reference in new issue
Block a user