diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 8755f17..264f654 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -3,6 +3,7 @@ package handler import ( "context" "database/sql" + "encoding/base64" "encoding/json" "fmt" "io" @@ -72,6 +73,11 @@ type flowItem struct { D int64 `json:"d"` } +const ( + pngDataURLPrefix = "data:image/png;base64," + maxBrandAssetDataURLBytes = 1024 * 1024 +) + func New(repo *repo.Repository, jwtSecret string) *Handler { h := &Handler{ repo: repo, @@ -747,7 +753,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) { if key == "" { continue } - if err := h.repo.UpsertConfig(key, v, now); err != nil { + + value, err := normalizeAndValidateConfigValue(key, v) + if err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + + if err := h.repo.UpsertConfig(key, value, now); err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return } @@ -767,16 +780,24 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("配置名称不能为空")) return } - if strings.TrimSpace(req.Name) == "" { + name := strings.TrimSpace(req.Name) + if name == "" { response.WriteJSON(w, response.ErrDefault("配置名称不能为空")) return } - if strings.TrimSpace(req.Value) == "" { + + value, err := normalizeAndValidateConfigValue(name, req.Value) + if err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + + if value == "" && name != "app_logo" && name != "app_favicon" { response.WriteJSON(w, response.ErrDefault("配置值不能为空")) return } - if err := h.repo.UpsertConfig(strings.TrimSpace(req.Name), req.Value, time.Now().UnixMilli()); err != nil { + if err := h.repo.UpsertConfig(name, value, time.Now().UnixMilli()); err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return } @@ -784,6 +805,37 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.OKEmpty()) } +func normalizeAndValidateConfigValue(key, value string) (string, error) { + switch strings.TrimSpace(key) { + case "app_logo", "app_favicon": + normalized := strings.TrimSpace(value) + if normalized == "" { + return "", nil + } + + if !strings.HasPrefix(normalized, pngDataURLPrefix) { + return "", fmt.Errorf("品牌图片必须通过上传生成 PNG 数据") + } + + if len(normalized) > maxBrandAssetDataURLBytes { + return "", fmt.Errorf("品牌图片过大,请上传更小图片") + } + + payload := strings.TrimSpace(strings.TrimPrefix(normalized, pngDataURLPrefix)) + if payload == "" { + return "", fmt.Errorf("品牌图片数据不能为空") + } + + if _, err := base64.StdEncoding.DecodeString(payload); err != nil { + return "", fmt.Errorf("品牌图片数据格式无效") + } + + return pngDataURLPrefix + payload, nil + default: + return value, nil + } +} + func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("请求失败")) diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index dd06073..fcdc8ca 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -214,7 +214,7 @@ func (GroupPermissionGrant) TableName() string { return "group_permission_grant" type ViteConfig struct { ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"` - Value string `gorm:"type:varchar(200);not null" json:"value"` + Value string `gorm:"type:text;not null" json:"value"` Time int64 `gorm:"not null" json:"time"` } diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 0e569eb..49e04fb 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -2482,9 +2482,10 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) { // ─── Migration ─────────────────────────────────────────────────────── -const currentSchemaVersion = 2 +const currentSchemaVersion = 3 var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults +var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType func getSchemaVersion(db *gorm.DB) int { var v model.SchemaVersion @@ -2536,10 +2537,55 @@ func migrateSchema(db *gorm.DB) error { return err } + if ver < 3 { + if err := migrateViteConfigValueColumnTypeFn(db); err != nil { + return err + } + } + setSchemaVersion(db, currentSchemaVersion) return nil } +func migrateViteConfigValueColumnType(db *gorm.DB) error { + if db == nil { + return errors.New("nil db") + } + + if !db.Migrator().HasTable(&model.ViteConfig{}) { + return nil + } + + if db.Dialector.Name() != "postgres" { + return nil + } + + type columnRow struct { + DataType string `gorm:"column:data_type"` + } + + var row columnRow + if err := db.Raw( + `SELECT data_type FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = ? + AND column_name = ?`, + "vite_config", "value", + ).Scan(&row).Error; err != nil { + return fmt.Errorf("inspect vite_config.value type: %w", err) + } + + if strings.EqualFold(row.DataType, "text") { + return nil + } + + if err := db.Exec(`ALTER TABLE "vite_config" ALTER COLUMN "value" TYPE TEXT`).Error; err != nil { + return fmt.Errorf("alter vite_config.value to text: %w", err) + } + + return nil +} + func ensurePostgresIDDefaults(db *gorm.DB) error { if db.Dialector.Name() != "postgres" { return nil diff --git a/go-backend/internal/store/repo/repository_migrate_test.go b/go-backend/internal/store/repo/repository_migrate_test.go index 4cbd612..a5c53ec 100644 --- a/go-backend/internal/store/repo/repository_migrate_test.go +++ b/go-backend/internal/store/repo/repository_migrate_test.go @@ -83,3 +83,95 @@ func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) { t.Fatalf("expected error %v, got %v", wantErr, err) } } + +func TestMigrateSchemaRunsViteConfigValueMigrationForLegacySchema(t *testing.T) { + db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + + if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil { + t.Fatalf("create schema_version: %v", err) + } + if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil { + t.Fatalf("seed schema_version: %v", err) + } + + originalIDRepair := ensurePostgresIDDefaultsFn + ensurePostgresIDDefaultsFn = func(db *gorm.DB) error { + return nil + } + t.Cleanup(func() { + ensurePostgresIDDefaultsFn = originalIDRepair + }) + + called := 0 + originalMigrate := migrateViteConfigValueColumnTypeFn + migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error { + called++ + return nil + } + t.Cleanup(func() { + migrateViteConfigValueColumnTypeFn = originalMigrate + }) + + if err := migrateSchema(db); err != nil { + t.Fatalf("migrateSchema: %v", err) + } + + if called != 1 { + t.Fatalf("expected vite_config migration to run once, got %d", called) + } +} + +func TestMigrateSchemaReturnsViteConfigMigrationError(t *testing.T) { + db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + + if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil { + t.Fatalf("create schema_version: %v", err) + } + if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil { + t.Fatalf("seed schema_version: %v", err) + } + + originalIDRepair := ensurePostgresIDDefaultsFn + ensurePostgresIDDefaultsFn = func(db *gorm.DB) error { + return nil + } + t.Cleanup(func() { + ensurePostgresIDDefaultsFn = originalIDRepair + }) + + wantErr := errors.New("vite config migration failed") + originalMigrate := migrateViteConfigValueColumnTypeFn + migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error { + return wantErr + } + t.Cleanup(func() { + migrateViteConfigValueColumnTypeFn = originalMigrate + }) + + err = migrateSchema(db) + if !errors.Is(err, wantErr) { + t.Fatalf("expected error %v, got %v", wantErr, err) + } +} diff --git a/vite-frontend/index.html b/vite-frontend/index.html index 9b5ed79..66a6184 100644 --- a/vite-frontend/index.html +++ b/vite-frontend/index.html @@ -2,7 +2,6 @@ - @@ -13,6 +12,47 @@