From d7c851bc4738efdbf803b6b9d61d6a8154dcc84d Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 19:29:25 +0800 Subject: [PATCH] autoresearch iter 35: BUGFIX a failed whitelist read is no longer cached as an admin decision --- .../domain/upload/cache/access_cache.go | 63 +++++++++++++++---- .../upload/cache/access_cache_retry_test.go | 56 +++++++++++++++++ 2 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 backend/plugins/domain/upload/cache/access_cache_retry_test.go diff --git a/backend/plugins/domain/upload/cache/access_cache.go b/backend/plugins/domain/upload/cache/access_cache.go index e755aa80..134a3a49 100644 --- a/backend/plugins/domain/upload/cache/access_cache.go +++ b/backend/plugins/domain/upload/cache/access_cache.go @@ -5,13 +5,18 @@ package cache import ( + "Wavelet/pkg/logger" "Wavelet/plugins/domain/upload/shared" "context" "encoding/json" + "errors" + "fmt" "strings" "sync" "time" + "gorm.io/gorm" + uploadstorage "Wavelet/plugins/domain/upload/storage" ) @@ -65,41 +70,75 @@ func loadFileAccessWhitelist(ctx context.Context) map[string]struct{} { return fileAccessWhitelistTypes } - fileAccessWhitelistTypes = fetchFileAccessWhitelist(ctx) + types, err := fetchFileAccessWhitelist(ctx) + if err != nil { + logger.ErrorF(ctx, "[Upload] 读取公开访问白名单失败: %v", err) + if fileAccessWhitelistTypes != nil { + // A previously loaded list is still the best answer; keep it until its + // TTL rather than narrowing access because one read failed. + fileAccessWhitelistValid = true + fileAccessWhitelistCheckedAt = time.Now() + return fileAccessWhitelistTypes + } + // Nothing cached yet: serve the restricted default but stay invalid so the + // next request retries instead of pinning it for the whole TTL. + return fallbackFileAccessWhitelist() + } + + fileAccessWhitelistTypes = types fileAccessWhitelistValid = true fileAccessWhitelistCheckedAt = time.Now() - return fileAccessWhitelistTypes + return types } -func fetchFileAccessWhitelist(ctx context.Context) map[string]struct{} { - whitelist := parseFileAccessWhitelist(ctx) +// fallbackFileAccessWhitelist is the restricted default used when nothing better is known. +func fallbackFileAccessWhitelist() map[string]struct{} { + return map[string]struct{}{strings.ToLower(shared.DefaultPublicUploadType): {}} +} + +func fetchFileAccessWhitelist(ctx context.Context) (map[string]struct{}, error) { + whitelist, err := parseFileAccessWhitelist(ctx) + if err != nil { + return nil, err + } types := make(map[string]struct{}, len(whitelist)) for _, item := range whitelist { types[strings.ToLower(item)] = struct{}{} } - return types + return types, nil } -func parseFileAccessWhitelist(ctx context.Context) []string { +// parseFileAccessWhitelist reads the configured public access types. +// +// A missing row or an empty value is a real answer and yields the default; only a +// read that actually fails is reported as an error, so a database outage is no +// longer mistaken for "the admin never configured a whitelist". +func parseFileAccessWhitelist(ctx context.Context) ([]string, error) { var sc struct{ Value string } db := shared.GetDB(ctx) - if db != nil { - _ = db.Table("w_system_configs").Where("key = ?", "file_access_whitelist").First(&sc).Error + if db == nil { + return nil, errors.New("database not available") + } + if err := db.Table("w_system_configs").Where("key = ?", "file_access_whitelist").First(&sc).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return []string{shared.DefaultPublicUploadType}, nil + } + return nil, fmt.Errorf("read file_access_whitelist: %w", err) } if sc.Value == "" { - return []string{shared.DefaultPublicUploadType} + return []string{shared.DefaultPublicUploadType}, nil } var whitelist []string if err := json.Unmarshal([]byte(sc.Value), &whitelist); err == nil && len(whitelist) > 0 { - return whitelist + return whitelist, nil } whitelist = parseCommaSeparatedWhitelist(sc.Value) if len(whitelist) == 0 { - return []string{shared.DefaultPublicUploadType} + return []string{shared.DefaultPublicUploadType}, nil } - return whitelist + return whitelist, nil } func parseCommaSeparatedWhitelist(value string) []string { diff --git a/backend/plugins/domain/upload/cache/access_cache_retry_test.go b/backend/plugins/domain/upload/cache/access_cache_retry_test.go new file mode 100644 index 00000000..432c0ed8 --- /dev/null +++ b/backend/plugins/domain/upload/cache/access_cache_retry_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "Wavelet/plugins/domain/upload/shared" + "context" + "testing" +) + +// configRow mirrors just enough of w_system_configs to rebuild it mid-test. +type configRow struct { + Key string `gorm:"primaryKey;size:64"` + Value string `gorm:"type:text;not null"` + Type string `gorm:"size:32;not null"` +} + +// TableName returns the system config table. +func (configRow) TableName() string { return "w_system_configs" } + +// TestWhitelistReadFailureIsNotCachedAsConfigured pins the defect where a failed +// read of the public access whitelist was discarded and the resulting restricted +// default was then cached as though the admin had configured it, narrowing access +// for the whole TTL with nothing to retry it sooner. +func TestWhitelistReadFailureIsNotCachedAsConfigured(t *testing.T) { + db, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ResetAccessCaches() + t.Cleanup(ResetAccessCaches) + + ctx := context.Background() + + // Make the config unreadable, then take one sample through the failure path. + if err := db.Exec("DROP TABLE w_system_configs").Error; err != nil { + t.Fatalf("drop config table: %v", err) + } + if IsFilePublic(ctx, "document") { + t.Fatal(`document reported public while the whitelist cannot be read`) + } + + // Restore a configured whitelist that includes document. + if err := db.AutoMigrate(&configRow{}); err != nil { + t.Fatalf("recreate config table: %v", err) + } + row := configRow{Key: "file_access_whitelist", Value: `["avatar","document"]`, Type: "system"} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed whitelist: %v", err) + } + + // A failed first read must not have been cached as a real answer, so this call + // has to re-read and see the configured list. + if !IsFilePublic(ctx, "document") { + t.Error("whitelist stayed pinned to the default after a read failure; the failed lookup must be retried") + } +}