autoresearch iter 35: BUGFIX a failed whitelist read is no longer cached as an admin decision

This commit is contained in:
ryan
2026-08-29 19:29:25 +08:00
parent db9d12f8c9
commit d7c851bc47
2 changed files with 107 additions and 12 deletions
+51 -12
View File
@@ -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 {
@@ -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")
}
}