YMhut Setup
Setup frontend is not built. Run npm install && npm run build in web/setup.
` + index + `
diff --git a/server/unified-management/internal/config/branding.go b/server/unified-management/internal/config/branding.go new file mode 100644 index 0000000..f49620a --- /dev/null +++ b/server/unified-management/internal/config/branding.go @@ -0,0 +1,48 @@ +package config + +import "strings" + +type SafeBrandingConfig struct { + SiteIconURL string `json:"siteIconUrl"` + DeveloperAvatarURL string `json:"developerAvatarUrl"` + DeveloperName string `json:"developerName"` + FeedbackEmail string `json:"feedbackEmail"` +} + +func SafeBranding(cfg BrandingConfig) SafeBrandingConfig { + return SafeBrandingConfig{ + SiteIconURL: strings.TrimSpace(cfg.SiteIconURL), + DeveloperAvatarURL: strings.TrimSpace(cfg.DeveloperAvatarURL), + DeveloperName: strings.TrimSpace(firstNonEmpty(cfg.DeveloperName, "YMhut")), + FeedbackEmail: strings.TrimSpace(firstNonEmpty(cfg.FeedbackEmail, "support@ymhut.cn")), + } +} + +func NormalizeBranding(current BrandingConfig, incoming BrandingConfig) BrandingConfig { + next := current + if value := strings.TrimSpace(incoming.SiteIconURL); value != "" { + next.SiteIconURL = value + } + if value := strings.TrimSpace(incoming.DeveloperAvatarURL); value != "" { + next.DeveloperAvatarURL = value + } + if value := strings.TrimSpace(incoming.DeveloperName); value != "" { + next.DeveloperName = value + } + if value := strings.TrimSpace(incoming.FeedbackEmail); value != "" { + next.FeedbackEmail = value + } + if next.SiteIconURL == "" { + next.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp" + } + if next.DeveloperAvatarURL == "" { + next.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp" + } + if next.DeveloperName == "" { + next.DeveloperName = "YMhut" + } + if next.FeedbackEmail == "" { + next.FeedbackEmail = "support@ymhut.cn" + } + return next +} diff --git a/server/unified-management/internal/config/config.go b/server/unified-management/internal/config/config.go index cb821bd..29d9991 100644 --- a/server/unified-management/internal/config/config.go +++ b/server/unified-management/internal/config/config.go @@ -36,6 +36,8 @@ type Config struct { MaxRequestBytes int64 `json:"max_request_bytes"` MaxPackageBytes int64 `json:"max_package_bytes"` Database DatabaseConfig `json:"database"` + Mail MailConfig `json:"mail"` + Branding BrandingConfig `json:"branding"` UploadGuard UploadGuardConfig `json:"upload_guard"` SourceCheckSeconds int `json:"source_check_seconds"` } @@ -44,6 +46,11 @@ type DatabaseConfig struct { Provider string `json:"provider"` SQLitePath string `json:"sqlite_path"` MySQLDSN string `json:"mysql_dsn"` + MySQLHost string `json:"mysql_host"` + MySQLPort int `json:"mysql_port"` + MySQLDatabase string `json:"mysql_database"` + MySQLUser string `json:"mysql_user"` + MySQLPassword string `json:"mysql_password"` FailoverEnabled bool `json:"failover_enabled"` HotSyncEnabled bool `json:"hot_sync_enabled"` HealthIntervalSec int `json:"health_interval_sec"` @@ -52,6 +59,25 @@ type DatabaseConfig struct { ConnMaxLifetimeSeconds int `json:"conn_max_lifetime_seconds"` } +type MailConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Secure string `json:"secure"` + Username string `json:"username"` + Password string `json:"password"` + FromAddress string `json:"from_address"` + FromName string `json:"from_name"` + DeveloperAddress string `json:"developer_address"` + TimeoutSeconds int `json:"timeout_seconds"` +} + +type BrandingConfig struct { + SiteIconURL string `json:"site_icon_url"` + DeveloperAvatarURL string `json:"developer_avatar_url"` + DeveloperName string `json:"developer_name"` + FeedbackEmail string `json:"feedback_email"` +} + type UploadGuardConfig struct { MaxZipFiles int `json:"max_zip_files"` MaxDecompressedBytes int64 `json:"max_decompressed_bytes"` @@ -120,6 +146,8 @@ func defaults(root string) *Config { Database: DatabaseConfig{ Provider: "sqlite", SQLitePath: filepath.Join(root, "storage", "unified.sqlite"), + MySQLHost: "127.0.0.1", + MySQLPort: 3306, FailoverEnabled: true, HotSyncEnabled: true, HealthIntervalSec: 30, @@ -127,6 +155,19 @@ func defaults(root string) *Config { MaxIdleConns: 4, ConnMaxLifetimeSeconds: 300, }, + Mail: MailConfig{ + Port: 465, + Secure: "ssl", + FromName: "YMhut Box Feedback", + DeveloperAddress: "support@ymhut.cn", + TimeoutSeconds: 20, + }, + Branding: BrandingConfig{ + SiteIconURL: "https://img.ymhut.cn/file/1782108850041_icon.webp", + DeveloperAvatarURL: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp", + DeveloperName: "YMhut", + FeedbackEmail: "support@ymhut.cn", + }, UploadGuard: UploadGuardConfig{ MaxZipFiles: 80, MaxDecompressedBytes: 30 * 1024 * 1024, @@ -184,6 +225,66 @@ func applyEnv(cfg *Config) { if value := os.Getenv("YMHUT_MYSQL_DSN"); value != "" { cfg.Database.MySQLDSN = value } + if value := os.Getenv("YMHUT_MYSQL_HOST"); value != "" { + cfg.Database.MySQLHost = value + } + if value := os.Getenv("YMHUT_MYSQL_PORT"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + cfg.Database.MySQLPort = parsed + } + } + if value := os.Getenv("YMHUT_MYSQL_DATABASE"); value != "" { + cfg.Database.MySQLDatabase = value + } + if value := os.Getenv("YMHUT_MYSQL_USER"); value != "" { + cfg.Database.MySQLUser = value + } + if value := os.Getenv("YMHUT_MYSQL_PASSWORD"); value != "" { + cfg.Database.MySQLPassword = value + } + if value := os.Getenv("YMHUT_MAIL_HOST"); value != "" { + cfg.Mail.Host = value + } + if value := os.Getenv("YMHUT_MAIL_PORT"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + cfg.Mail.Port = parsed + } + } + if value := os.Getenv("YMHUT_MAIL_SECURE"); value != "" { + cfg.Mail.Secure = value + } + if value := os.Getenv("YMHUT_MAIL_USERNAME"); value != "" { + cfg.Mail.Username = value + } + if value := os.Getenv("YMHUT_MAIL_PASSWORD"); value != "" { + cfg.Mail.Password = value + } + if value := os.Getenv("YMHUT_MAIL_FROM_ADDRESS"); value != "" { + cfg.Mail.FromAddress = value + } + if value := os.Getenv("YMHUT_MAIL_FROM_NAME"); value != "" { + cfg.Mail.FromName = value + } + if value := os.Getenv("YMHUT_MAIL_DEVELOPER_ADDRESS"); value != "" { + cfg.Mail.DeveloperAddress = value + } + if value := os.Getenv("YMHUT_MAIL_TIMEOUT_SECONDS"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + cfg.Mail.TimeoutSeconds = parsed + } + } + if value := os.Getenv("YMHUT_BRAND_ICON_URL"); value != "" { + cfg.Branding.SiteIconURL = value + } + if value := os.Getenv("YMHUT_BRAND_DEVELOPER_AVATAR_URL"); value != "" { + cfg.Branding.DeveloperAvatarURL = value + } + if value := os.Getenv("YMHUT_BRAND_DEVELOPER_NAME"); value != "" { + cfg.Branding.DeveloperName = value + } + if value := os.Getenv("YMHUT_BRAND_FEEDBACK_EMAIL"); value != "" { + cfg.Branding.FeedbackEmail = value + } if value := os.Getenv("YMHUT_CLIENT_SIGNATURE_KEY"); value != "" { cfg.ClientSignatureKey = value } @@ -267,10 +368,30 @@ func normalize(root string, cfg *Config) { if cfg.Database.Provider == "" { cfg.Database.Provider = "sqlite" } + cfg.Database.Provider = strings.ToLower(strings.TrimSpace(cfg.Database.Provider)) if cfg.Database.SQLitePath == "" { cfg.Database.SQLitePath = filepath.Join(cfg.StorageDir, "unified.sqlite") } cfg.Database.SQLitePath = absPath(cfg.BaseDir, cfg.Database.SQLitePath) + if cfg.Database.MySQLHost == "" { + cfg.Database.MySQLHost = "127.0.0.1" + } + if cfg.Database.MySQLPort <= 0 { + cfg.Database.MySQLPort = 3306 + } + if cfg.Database.Provider == "mysql" && cfg.Database.MySQLDSN == "" && cfg.Database.MySQLDatabase != "" && cfg.Database.MySQLUser != "" { + if dsn, err := BuildMySQLDSN(MySQLInput{ + Host: cfg.Database.MySQLHost, + Port: cfg.Database.MySQLPort, + Database: cfg.Database.MySQLDatabase, + Username: cfg.Database.MySQLUser, + Password: cfg.Database.MySQLPassword, + Charset: "utf8mb4", + ParseTime: true, + }); err == nil { + cfg.Database.MySQLDSN = dsn + } + } if cfg.Database.HealthIntervalSec <= 0 { cfg.Database.HealthIntervalSec = 30 } @@ -316,6 +437,37 @@ func normalize(root string, cfg *Config) { if cfg.SourceCheckSeconds <= 0 { cfg.SourceCheckSeconds = 300 } + if cfg.Mail.Port <= 0 { + cfg.Mail.Port = 465 + } + cfg.Mail.Secure = strings.ToLower(strings.TrimSpace(cfg.Mail.Secure)) + if cfg.Mail.Secure == "" { + cfg.Mail.Secure = "ssl" + } + if cfg.Mail.FromName == "" { + cfg.Mail.FromName = "YMhut Box Feedback" + } + if cfg.Mail.FromAddress == "" { + cfg.Mail.FromAddress = cfg.Mail.Username + } + if cfg.Mail.TimeoutSeconds <= 0 { + cfg.Mail.TimeoutSeconds = 20 + } + if cfg.Branding.SiteIconURL == "" { + cfg.Branding.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp" + } + if cfg.Branding.DeveloperAvatarURL == "" { + cfg.Branding.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp" + } + if cfg.Branding.DeveloperName == "" { + cfg.Branding.DeveloperName = "YMhut" + } + if cfg.Branding.FeedbackEmail == "" { + cfg.Branding.FeedbackEmail = "support@ymhut.cn" + } + if cfg.Mail.DeveloperAddress == "" { + cfg.Mail.DeveloperAddress = cfg.Branding.FeedbackEmail + } } func ResolveBaseDir() (string, error) { diff --git a/server/unified-management/internal/config/database.go b/server/unified-management/internal/config/database.go new file mode 100644 index 0000000..03f040c --- /dev/null +++ b/server/unified-management/internal/config/database.go @@ -0,0 +1,189 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "path/filepath" + "strconv" + "strings" +) + +type MySQLInput struct { + Host string `json:"host"` + Port int `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + Charset string `json:"charset"` + ParseTime bool `json:"parseTime"` + TLS string `json:"tls"` +} + +type SafeDatabaseConfig struct { + Provider string `json:"provider"` + SQLitePath string `json:"sqlitePath"` + MySQLDSN string `json:"mysqlDsn"` + MySQLHost string `json:"mysqlHost"` + MySQLPort int `json:"mysqlPort"` + MySQLDatabase string `json:"mysqlDatabase"` + MySQLUser string `json:"mysqlUser"` + HasPassword bool `json:"hasPassword"` +} + +func BuildMySQLDSN(input MySQLInput) (string, error) { + host := strings.TrimSpace(input.Host) + if host == "" { + host = "127.0.0.1" + } + port := input.Port + if port <= 0 { + port = 3306 + } + database := strings.TrimSpace(input.Database) + username := strings.TrimSpace(input.Username) + if database == "" { + return "", errors.New("mysql database is required") + } + if username == "" { + return "", errors.New("mysql username is required") + } + params := url.Values{} + params.Set("charset", firstNonEmpty(strings.TrimSpace(input.Charset), "utf8mb4")) + params.Set("parseTime", strconv.FormatBool(input.ParseTime)) + if tls := strings.TrimSpace(input.TLS); tls != "" { + params.Set("tls", tls) + } + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", username, input.Password, host, port, database, params.Encode()), nil +} + +func NormalizeDatabase(baseDir string, current DatabaseConfig, incoming DatabaseConfig, keepPassword bool) (DatabaseConfig, error) { + next := current + structuredChanged := false + if incoming.Provider != "" { + next.Provider = strings.ToLower(strings.TrimSpace(incoming.Provider)) + } + if next.Provider == "" { + next.Provider = "sqlite" + } + if incoming.SQLitePath != "" { + next.SQLitePath = incoming.SQLitePath + } + if next.SQLitePath != "" && !filepath.IsAbs(next.SQLitePath) && !strings.HasPrefix(strings.ToLower(next.SQLitePath), "file:") { + next.SQLitePath = filepath.Join(baseDir, next.SQLitePath) + } + if incoming.MySQLHost != "" { + next.MySQLHost = strings.TrimSpace(incoming.MySQLHost) + structuredChanged = true + } + if incoming.MySQLPort > 0 { + next.MySQLPort = incoming.MySQLPort + structuredChanged = true + } + if incoming.MySQLDatabase != "" { + next.MySQLDatabase = strings.TrimSpace(incoming.MySQLDatabase) + structuredChanged = true + } + if incoming.MySQLUser != "" { + next.MySQLUser = strings.TrimSpace(incoming.MySQLUser) + structuredChanged = true + } + if incoming.MySQLPassword != "" || !keepPassword { + next.MySQLPassword = incoming.MySQLPassword + structuredChanged = true + } + if incoming.MySQLDSN != "" { + next.MySQLDSN = strings.TrimSpace(incoming.MySQLDSN) + } + if next.MySQLHost == "" { + next.MySQLHost = "127.0.0.1" + } + if next.MySQLPort <= 0 { + next.MySQLPort = 3306 + } + if next.Provider == "sqlite" { + next.MySQLDSN = "" + } else if next.Provider == "mysql" { + if structuredChanged || next.MySQLDSN == "" { + dsn, err := BuildMySQLDSN(MySQLInput{ + Host: next.MySQLHost, + Port: next.MySQLPort, + Database: next.MySQLDatabase, + Username: next.MySQLUser, + Password: next.MySQLPassword, + Charset: "utf8mb4", + ParseTime: true, + }) + if err != nil { + return DatabaseConfig{}, err + } + next.MySQLDSN = dsn + } + if strings.TrimSpace(next.MySQLDSN) == "" { + return DatabaseConfig{}, errors.New("mysql connection is required") + } + } else { + return DatabaseConfig{}, errors.New("provider must be sqlite or mysql") + } + if strings.TrimSpace(next.SQLitePath) == "" { + return DatabaseConfig{}, errors.New("sqlite path is required") + } + if next.MaxOpenConns <= 0 { + next.MaxOpenConns = 10 + } + if next.MaxIdleConns <= 0 { + next.MaxIdleConns = 4 + } + if next.ConnMaxLifetimeSeconds <= 0 { + next.ConnMaxLifetimeSeconds = 300 + } + if next.HealthIntervalSec <= 0 { + next.HealthIntervalSec = 30 + } + return next, nil +} + +func SafeDatabase(baseDir string, cfg DatabaseConfig) SafeDatabaseConfig { + return SafeDatabaseConfig{ + Provider: firstNonEmpty(cfg.Provider, "sqlite"), + SQLitePath: relativeToBase(baseDir, cfg.SQLitePath), + MySQLDSN: MaskDSN(cfg.MySQLDSN), + MySQLHost: cfg.MySQLHost, + MySQLPort: cfg.MySQLPort, + MySQLDatabase: cfg.MySQLDatabase, + MySQLUser: cfg.MySQLUser, + HasPassword: strings.TrimSpace(cfg.MySQLPassword) != "" || dsnHasPassword(cfg.MySQLDSN), + } +} + +func MaskDSN(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + at := strings.Index(value, "@") + colon := strings.Index(value, ":") + if at > -1 && colon > -1 && colon < at { + return value[:colon+1] + "******" + value[at:] + } + return value +} + +func relativeToBase(base, value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + if base != "" { + if rel, err := filepath.Rel(base, value); err == nil && !strings.HasPrefix(rel, "..") && rel != "." { + return filepath.ToSlash(rel) + } + } + return filepath.ToSlash(value) +} + +func dsnHasPassword(value string) bool { + value = strings.TrimSpace(value) + at := strings.Index(value, "@") + colon := strings.Index(value, ":") + return at > -1 && colon > -1 && colon < at && colon+1 < at +} diff --git a/server/unified-management/internal/db/audit_store.go b/server/unified-management/internal/db/audit_store.go index 38edc67..6e9049a 100644 --- a/server/unified-management/internal/db/audit_store.go +++ b/server/unified-management/internal/db/audit_store.go @@ -2,6 +2,7 @@ package db import ( "fmt" + "strings" "time" ) @@ -40,7 +41,7 @@ func (s *Store) DashboardOverview(limit int) (map[string]any, error) { } func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) { - rows, err := s.query(`SELECT h.id, e.source_id, e.name, h.status, h.latency_ms, h.error, h.checked_at + rows, err := s.query(`SELECT h.id, h.source_db_id, COALESCE(e.source_id, ''), COALESCE(e.name, ''), h.status, h.latency_ms, h.error, h.checked_at FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, limit) if err != nil { @@ -49,13 +50,19 @@ func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) { defer rows.Close() items := []map[string]any{} for rows.Next() { - var id int64 + var id, sourceDBID int64 var sourceID, name, status, message, checkedAt string var latency int - if err := rows.Scan(&id, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil { + if err := rows.Scan(&id, &sourceDBID, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil { return nil, err } - items = append(items, map[string]any{"id": id, "sourceId": sourceID, "name": name, "status": status, "latencyMs": latency, "error": message, "checkedAt": checkedAt}) + if sourceID == "" { + sourceID = fmt.Sprintf("deleted-%d", sourceDBID) + } + if name == "" { + name = fmt.Sprintf("已删除接口 #%d", sourceDBID) + } + items = append(items, map[string]any{"id": id, "sourceDbId": sourceDBID, "sourceId": sourceID, "name": name, "status": status, "latencyMs": latency, "error": message, "checkedAt": checkedAt}) } return items, rows.Err() } @@ -100,6 +107,59 @@ func (s *Store) ListAuditLogs(limit int) ([]AuditLog, error) { return scanAuditRows(rows) } +func (s *Store) ListAuditLogsPage(filters AuditFilters) (AuditPage, error) { + page := filters.Page + if page <= 0 { + page = 1 + } + perPage := filters.PerPage + if perPage <= 0 { + perPage = 35 + } + if perPage > 100 { + perPage = 100 + } + where, args := auditWhere(filters) + var total int + if err := s.queryRow(`SELECT COUNT(*) FROM audit_logs`+where, args...).Scan(&total); err != nil { + return AuditPage{}, err + } + offset := (page - 1) * perPage + queryArgs := append(append([]any{}, args...), perPage, offset) + rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs`+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + return AuditPage{}, err + } + defer rows.Close() + items, err := scanAuditRows(rows) + if err != nil { + return AuditPage{}, err + } + return AuditPage{Items: items, Total: total, Page: page, PerPage: perPage}, nil +} + +func auditWhere(filters AuditFilters) (string, []any) { + clauses := []string{} + args := []any{} + if value := strings.TrimSpace(filters.Type); value != "" { + clauses = append(clauses, "type = ?") + args = append(args, sanitize(value)) + } + if value := strings.TrimSpace(filters.Target); value != "" { + clauses = append(clauses, "target = ?") + args = append(args, sanitize(value)) + } + if value := strings.TrimSpace(filters.Query); value != "" { + clauses = append(clauses, "(actor LIKE ? OR type LIKE ? OR target LIKE ? OR message LIKE ? OR ip LIKE ?)") + like := "%" + sanitize(value) + "%" + args = append(args, like, like, like, like, like) + } + if len(clauses) == 0 { + return "", args + } + return " WHERE " + strings.Join(clauses, " AND "), args +} + func (s *Store) ListAuditLogsForTarget(target string, limit int) ([]AuditLog, error) { if limit <= 0 || limit > 200 { limit = 100 diff --git a/server/unified-management/internal/db/database_sync.go b/server/unified-management/internal/db/database_sync.go index d272596..58b931e 100644 --- a/server/unified-management/internal/db/database_sync.go +++ b/server/unified-management/internal/db/database_sync.go @@ -21,6 +21,10 @@ func (s *Store) CopyRemoteToSQLite() (string, error) { } func (s *Store) ImportSQLiteToRemote() (SyncResult, error) { + if !s.trySyncLock() { + return SyncResult{Direction: "sqlite_to_remote", Status: "running", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"database sync is already running"}}, errors.New("database sync is already running") + } + defer s.syncMu.Unlock() s.mu.RLock() remote := s.remoteDB remoteDialect := s.remoteDialect @@ -28,9 +32,9 @@ func (s *Store) ImportSQLiteToRemote() (SyncResult, error) { localDialect := s.localDialect s.mu.RUnlock() if remote == nil { - err := errors.New("remote database is not configured") - s.setSyncStatus(SyncResult{Direction: "sqlite_to_remote", Tables: map[string]int{}, FinishedAt: Now()}, err) - return SyncResult{}, err + result := SyncResult{Direction: "sqlite_to_remote", Status: "skipped", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"remote database is not configured"}} + s.setSyncStatus(result, nil) + return result, nil } result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote") s.setSyncStatus(result, err) @@ -38,6 +42,10 @@ func (s *Store) ImportSQLiteToRemote() (SyncResult, error) { } func (s *Store) SyncNow() (SyncResult, error) { + if !s.trySyncLock() { + return SyncResult{Direction: "remote_to_sqlite", Status: "running", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"database sync is already running"}}, errors.New("database sync is already running") + } + defer s.syncMu.Unlock() s.mu.RLock() remote := s.remoteDB remoteDialect := s.remoteDialect @@ -45,7 +53,7 @@ func (s *Store) SyncNow() (SyncResult, error) { localDialect := s.localDialect s.mu.RUnlock() if remote == nil { - result := SyncResult{Direction: "remote_to_sqlite", Tables: map[string]int{}, FinishedAt: Now()} + result := SyncResult{Direction: "remote_to_sqlite", Status: "skipped", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"remote database is not configured"}} s.setSyncStatus(result, nil) return result, nil } @@ -66,6 +74,10 @@ func (s *Store) setSyncStatus(result SyncResult, err error) { s.status.LastSyncError = "" } +func (s *Store) trySyncLock() bool { + return s.syncMu.TryLock() +} + type tableSpec struct { Name string Columns []string @@ -88,6 +100,7 @@ var syncTables = []tableSpec{ {"source_endpoints", []string{"id", "category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"}, []string{"id"}}, {"endpoint_health_checks", []string{"id", "source_db_id", "status", "latency_ms", "error", "checked_at"}, []string{"id"}}, {"endpoint_call_logs", []string{"id", "source_id", "status", "latency_ms", "error", "client", "created_at"}, []string{"id"}}, + {"system_settings", []string{"key", "value", "updated_at"}, []string{"key"}}, {"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}}, {"legacy_json_revisions", []string{"id", "name", "raw", "note", "created_by", "created_at"}, []string{"id"}}, {"webhook_deliveries", []string{"id", "webhook_name", "event", "status", "attempts", "response_code", "error_message", "payload_sha256", "created_at", "finished_at"}, []string{"id"}}, @@ -95,19 +108,22 @@ var syncTables = []tableSpec{ } func copyAllTables(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, direction string) (SyncResult, error) { - result := SyncResult{Direction: direction, Tables: map[string]int{}, FinishedAt: Now()} + result := SyncResult{Direction: direction, Status: "completed", Tables: map[string]int{}, FinishedAt: Now()} for _, table := range syncTables { count, err := copyTable(src, srcDialect, dst, dstDialect, table) if err != nil { + result.Status = "failed" + result.FinishedAt = Now() return result, err } result.Tables[table.Name] = count } + result.FinishedAt = Now() return result, nil } func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) { - rows, err := src.Query(srcDialect.rebind("SELECT " + strings.Join(spec.Columns, ", ") + " FROM " + spec.Name)) + rows, err := src.Query(srcDialect.rebind("SELECT " + srcDialect.columnList(spec.Columns) + " FROM " + spec.Name)) if err != nil { return 0, err } diff --git a/server/unified-management/internal/db/dialect.go b/server/unified-management/internal/db/dialect.go index 255f6b7..62dc706 100644 --- a/server/unified-management/internal/db/dialect.go +++ b/server/unified-management/internal/db/dialect.go @@ -42,6 +42,46 @@ func (d dialect) idType() string { return "INTEGER PRIMARY KEY AUTOINCREMENT" } +func (d dialect) keyTextType() string { + if d.name == "mysql" { + return "VARCHAR(191)" + } + return "TEXT" +} + +func (d dialect) shortTextType() string { + if d.name == "mysql" { + return "VARCHAR(255)" + } + return "TEXT" +} + +func (d dialect) mediumTextType() string { + if d.name == "mysql" { + return "VARCHAR(1024)" + } + return "TEXT" +} + +func (d dialect) longTextType() string { + if d.name == "mysql" { + return "LONGTEXT" + } + return "TEXT" +} + +func (d dialect) quoteIdent(identifier string) string { + return "`" + strings.ReplaceAll(identifier, "`", "``") + "`" +} + +func (d dialect) columnList(columns []string) string { + quoted := make([]string, len(columns)) + for index, column := range columns { + quoted[index] = d.quoteIdent(column) + } + return strings.Join(quoted, ", ") +} + func (d dialect) boolExpr(value bool) int { if value { return 1 @@ -54,7 +94,7 @@ func (d dialect) upsert(table string, columns, conflict []string) string { for i := range placeholders { placeholders[i] = "?" } - base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), strings.Join(placeholders, ", ")) + base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, d.columnList(columns), strings.Join(placeholders, ", ")) conflictSet := map[string]bool{} for _, column := range conflict { conflictSet[column] = true @@ -64,10 +104,11 @@ func (d dialect) upsert(table string, columns, conflict []string) string { if conflictSet[column] { continue } + quoted := d.quoteIdent(column) if d.name == "mysql" { - updates = append(updates, fmt.Sprintf("%s = VALUES(%s)", column, column)) + updates = append(updates, fmt.Sprintf("%s = VALUES(%s)", quoted, quoted)) } else { - updates = append(updates, fmt.Sprintf("%s = excluded.%s", column, column)) + updates = append(updates, fmt.Sprintf("%s = excluded.%s", quoted, quoted)) } } if len(updates) == 0 { @@ -79,7 +120,7 @@ func (d dialect) upsert(table string, columns, conflict []string) string { if d.name == "mysql" { return base + " ON DUPLICATE KEY UPDATE " + strings.Join(updates, ", ") } - return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updates, ", ") + return base + " ON CONFLICT (" + d.columnList(conflict) + ") DO UPDATE SET " + strings.Join(updates, ", ") } func (d dialect) limitOffset(limit, offset int) string { diff --git a/server/unified-management/internal/db/feedback_store.go b/server/unified-management/internal/db/feedback_store.go index f0ab302..eeee1c4 100644 --- a/server/unified-management/internal/db/feedback_store.go +++ b/server/unified-management/internal/db/feedback_store.go @@ -304,6 +304,36 @@ func (s *Store) UpsertMailRecord(item LegacyMailRecord) error { return err } +func (s *Store) InsertMailRecord(item LegacyMailRecord) (int64, error) { + if item.CreatedAt == "" { + item.CreatedAt = Now() + } + id, err := s.insertID(`INSERT INTO mail_records ( + feedback_code, kind, status, to_address, subject, plain_body, html_body, + attachment_path, attachment_name, error_message, created_at, sent_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sanitize(item.FeedbackCode), sanitize(firstNonEmpty(item.Kind, "feedback")), sanitize(firstNonEmpty(item.Status, "pending")), + sanitize(item.ToAddress), sanitizeLong(item.Subject, 1000), sanitizeLong(item.PlainBody, 12000), sanitizeLong(item.HTMLBody, 12000), + item.AttachmentPath, item.AttachmentName, sanitizeLong(item.ErrorMessage, 1000), item.CreatedAt, item.SentAt) + return id, err +} + +func (s *Store) UpdateMailState(id int64, status, errorMessage string) error { + sentAt := "" + if status == "sent" { + sentAt = Now() + } + _, err := s.exec(`UPDATE mail_records SET status = ?, error_message = ?, sent_at = ? WHERE id = ?`, + sanitize(status), sanitizeLong(errorMessage, 1000), sentAt, id) + return err +} + +func (s *Store) UpdateFeedbackMailState(code string, sent bool) error { + _, err := s.exec(`UPDATE feedback_tickets SET mail_sent = ?, updated_at = ?, last_activity_at = ? WHERE code = ?`, + boolInt(sent), Now(), Now(), code) + return err +} + func (s *Store) ListFeedbackEvents(code string, limit int) ([]LegacyFeedbackEvent, error) { if limit <= 0 || limit > 200 { limit = 100 diff --git a/server/unified-management/internal/db/models.go b/server/unified-management/internal/db/models.go index e3a6fe6..ee5d2b4 100644 --- a/server/unified-management/internal/db/models.go +++ b/server/unified-management/internal/db/models.go @@ -34,6 +34,9 @@ type DatabaseStatus struct { type SyncResult struct { Direction string `json:"direction"` + Status string `json:"status"` + Skipped bool `json:"skipped"` + Warnings []string `json:"warnings,omitempty"` Tables map[string]int `json:"tables"` FinishedAt string `json:"finishedAt"` } @@ -121,6 +124,8 @@ type LegacyMailRecord struct { Status string `json:"status"` ToAddress string `json:"toAddress"` Subject string `json:"subject"` + PlainBody string `json:"plainBody,omitempty"` + HTMLBody string `json:"htmlBody,omitempty"` AttachmentPath string `json:"attachmentPath"` AttachmentName string `json:"attachmentName"` ErrorMessage string `json:"errorMessage"` @@ -272,6 +277,21 @@ type AuditLog struct { CreatedAt string `json:"createdAt"` } +type AuditFilters struct { + Page int + PerPage int + Type string + Target string + Query string +} + +type AuditPage struct { + Items []AuditLog `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"perPage"` +} + type LegacyJsonRevision struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/server/unified-management/internal/db/schema.go b/server/unified-management/internal/db/schema.go index 0668990..4ed41ba 100644 --- a/server/unified-management/internal/db/schema.go +++ b/server/unified-management/internal/db/schema.go @@ -26,252 +26,261 @@ func (s *Store) migrate(conn *sql.DB, d dialect) error { } func schemaStatements(d dialect) []string { + keyText := d.keyTextType() + shortText := d.shortTextType() + mediumText := d.mediumTextType() + longText := d.longTextType() return []string{ - `CREATE TABLE IF NOT EXISTS schema_migrations ( + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS schema_migrations ( version VARCHAR(64) NOT NULL PRIMARY KEY, - applied_at TEXT NOT NULL, + applied_at %s NOT NULL, description VARCHAR(255) NOT NULL DEFAULT '' - )`, + )`, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS admin_users ( id %s, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, + username %s NOT NULL UNIQUE, + password_hash %s NOT NULL, password_changed INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, d.idType()), + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, d.idType(), keyText, shortText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS sessions ( id %s, - session_id TEXT NOT NULL UNIQUE, - username TEXT NOT NULL, - csrf TEXT NOT NULL, - expires_at TEXT NOT NULL, - created_at TEXT NOT NULL - )`, d.idType()), + session_id %s NOT NULL UNIQUE, + username %s NOT NULL, + csrf %s NOT NULL, + expires_at %s NOT NULL, + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, shortText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_packages ( id %s, - product TEXT NOT NULL, - version TEXT NOT NULL, - platform TEXT NOT NULL, - arch TEXT NOT NULL, - file_name TEXT NOT NULL UNIQUE, - url TEXT NOT NULL, - sha256 TEXT NOT NULL, + product %s NOT NULL, + version %s NOT NULL, + platform %s NOT NULL, + arch %s NOT NULL, + file_name %s NOT NULL UNIQUE, + url %s NOT NULL, + sha256 %s NOT NULL, size_bytes BIGINT NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, d.idType()), + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, d.idType(), keyText, keyText, keyText, keyText, keyText, mediumText, shortText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notices ( id %s, - version TEXT NOT NULL UNIQUE, - build TEXT NOT NULL DEFAULT '', - channel TEXT NOT NULL DEFAULT 'stable', - title TEXT NOT NULL DEFAULT '', - message TEXT NOT NULL DEFAULT '', - release_notes TEXT NOT NULL DEFAULT '', - message_md TEXT NOT NULL DEFAULT '', - release_notes_md TEXT NOT NULL DEFAULT '', - download_url TEXT NOT NULL DEFAULT '', - notice_file TEXT NOT NULL DEFAULT '', - raw_json TEXT NOT NULL, - published_at TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, d.idType()), + version %s NOT NULL UNIQUE, + build %s NOT NULL DEFAULT '', + channel %s NOT NULL DEFAULT 'stable', + title %s NOT NULL DEFAULT '', + message %s NOT NULL, + release_notes %s NOT NULL, + message_md %s NOT NULL, + release_notes_md %s NOT NULL, + download_url %s NOT NULL DEFAULT '', + notice_file %s NOT NULL DEFAULT '', + raw_json %s NOT NULL, + published_at %s NOT NULL DEFAULT '', + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, d.idType(), keyText, shortText, shortText, mediumText, longText, longText, longText, longText, mediumText, keyText, longText, shortText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notice_revisions ( id %s, - version TEXT NOT NULL, - raw_json TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '', - created_by TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )`, d.idType()), + version %s NOT NULL, + raw_json %s NOT NULL, + note %s NOT NULL DEFAULT '', + created_by %s NOT NULL DEFAULT '', + created_at %s NOT NULL + )`, d.idType(), keyText, longText, mediumText, keyText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tickets ( - code TEXT PRIMARY KEY, - title TEXT NOT NULL, - type TEXT NOT NULL, - severity TEXT NOT NULL, - category TEXT NOT NULL DEFAULT '', - priority TEXT NOT NULL DEFAULT '', - contact TEXT NOT NULL DEFAULT '', - body TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL, - status_detail TEXT NOT NULL DEFAULT '', - public_reply TEXT NOT NULL DEFAULT '', - note TEXT NOT NULL DEFAULT '', - assignee TEXT NOT NULL DEFAULT '', - handled_by TEXT NOT NULL DEFAULT '', - due_at TEXT NOT NULL DEFAULT '', - resolved_at TEXT NOT NULL DEFAULT '', - archived_at TEXT NOT NULL DEFAULT '', - sla_level TEXT NOT NULL DEFAULT '', - source_channel TEXT NOT NULL DEFAULT '', + code %s PRIMARY KEY, + title %s NOT NULL, + type %s NOT NULL, + severity %s NOT NULL, + category %s NOT NULL DEFAULT '', + priority %s NOT NULL DEFAULT '', + contact %s NOT NULL DEFAULT '', + body %s NOT NULL, + status %s NOT NULL, + status_detail %s NOT NULL DEFAULT '', + public_reply %s NOT NULL, + note %s NOT NULL, + assignee %s NOT NULL DEFAULT '', + handled_by %s NOT NULL DEFAULT '', + due_at %s NOT NULL DEFAULT '', + resolved_at %s NOT NULL DEFAULT '', + archived_at %s NOT NULL DEFAULT '', + sla_level %s NOT NULL DEFAULT '', + source_channel %s NOT NULL DEFAULT '', risk_score INTEGER NOT NULL DEFAULT 0, - resolution TEXT NOT NULL DEFAULT '', - attachment TEXT NOT NULL DEFAULT '', - package_path TEXT NOT NULL DEFAULT '', - encrypted_package_path TEXT NOT NULL DEFAULT '', - package_sha256 TEXT NOT NULL DEFAULT '', - plain_package_sha256 TEXT NOT NULL DEFAULT '', - summary_text TEXT NOT NULL DEFAULT '', - included_files TEXT NOT NULL DEFAULT '', + resolution %s NOT NULL, + attachment %s NOT NULL DEFAULT '', + package_path %s NOT NULL DEFAULT '', + encrypted_package_path %s NOT NULL DEFAULT '', + package_sha256 %s NOT NULL DEFAULT '', + plain_package_sha256 %s NOT NULL DEFAULT '', + summary_text %s NOT NULL, + included_files %s NOT NULL, mail_sent INTEGER NOT NULL DEFAULT 0, - remote_addr TEXT NOT NULL DEFAULT '', - tags TEXT NOT NULL DEFAULT '[]', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - last_activity_at TEXT NOT NULL - )`), + remote_addr %s NOT NULL DEFAULT '', + tags %s NOT NULL, + created_at %s NOT NULL, + updated_at %s NOT NULL, + last_activity_at %s NOT NULL + )`, keyText, mediumText, keyText, keyText, keyText, keyText, mediumText, longText, keyText, mediumText, longText, longText, keyText, keyText, shortText, shortText, shortText, keyText, keyText, longText, mediumText, mediumText, mediumText, shortText, shortText, longText, longText, shortText, longText, shortText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_comments ( id %s, - feedback_code TEXT NOT NULL, - author TEXT NOT NULL DEFAULT '', - body TEXT NOT NULL, + feedback_code %s NOT NULL, + author %s NOT NULL DEFAULT '', + body %s NOT NULL, internal INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL - )`, d.idType()), + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, longText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_attachments ( id %s, - feedback_code TEXT NOT NULL, - kind TEXT NOT NULL, - path TEXT NOT NULL, - file_name TEXT NOT NULL, - sha256 TEXT NOT NULL DEFAULT '', + feedback_code %s NOT NULL, + kind %s NOT NULL, + path %s NOT NULL, + file_name %s NOT NULL, + sha256 %s NOT NULL DEFAULT '', size_bytes BIGINT NOT NULL DEFAULT 0, - created_at TEXT NOT NULL - )`, d.idType()), + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, mediumText, mediumText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_events ( id %s, - feedback_code TEXT NOT NULL, - event_type TEXT NOT NULL, - actor TEXT NOT NULL DEFAULT '', - from_value TEXT NOT NULL DEFAULT '', - to_value TEXT NOT NULL DEFAULT '', - message TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )`, d.idType()), - `CREATE TABLE IF NOT EXISTS feedback_tags ( - feedback_code TEXT NOT NULL, - tag TEXT NOT NULL, - created_at TEXT NOT NULL, + feedback_code %s NOT NULL, + event_type %s NOT NULL, + actor %s NOT NULL DEFAULT '', + from_value %s NOT NULL DEFAULT '', + to_value %s NOT NULL DEFAULT '', + message %s NOT NULL DEFAULT '', + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, keyText, mediumText, mediumText, mediumText, shortText), + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tags ( + feedback_code %s NOT NULL, + tag %s NOT NULL, + created_at %s NOT NULL, PRIMARY KEY (feedback_code, tag) - )`, + )`, keyText, keyText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS mail_records ( id %s, - feedback_code TEXT NOT NULL DEFAULT '', - kind TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT '', - to_address TEXT NOT NULL DEFAULT '', - subject TEXT NOT NULL DEFAULT '', - plain_body TEXT NOT NULL DEFAULT '', - html_body TEXT NOT NULL DEFAULT '', - attachment_path TEXT NOT NULL DEFAULT '', - attachment_name TEXT NOT NULL DEFAULT '', - error_message TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - sent_at TEXT NOT NULL DEFAULT '' - )`, d.idType()), + feedback_code %s NOT NULL DEFAULT '', + kind %s NOT NULL DEFAULT '', + status %s NOT NULL DEFAULT '', + to_address %s NOT NULL DEFAULT '', + subject %s NOT NULL DEFAULT '', + plain_body %s NOT NULL, + html_body %s NOT NULL, + attachment_path %s NOT NULL DEFAULT '', + attachment_name %s NOT NULL DEFAULT '', + error_message %s NOT NULL, + created_at %s NOT NULL, + sent_at %s NOT NULL DEFAULT '' + )`, d.idType(), keyText, keyText, keyText, mediumText, mediumText, longText, longText, mediumText, mediumText, longText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_categories ( id %s, - category_id TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, + category_id %s NOT NULL UNIQUE, + name %s NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, - ui_config TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, d.idType()), + ui_config %s NOT NULL, + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, d.idType(), keyText, shortText, longText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_endpoints ( id %s, - category_id TEXT NOT NULL, - category_name TEXT NOT NULL, - source_id TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - method TEXT NOT NULL DEFAULT 'GET', - api_url TEXT NOT NULL DEFAULT '', - url_template TEXT NOT NULL DEFAULT '', - thumbnail_url TEXT NOT NULL DEFAULT '', - proxy_mode TEXT NOT NULL DEFAULT 'client_direct', + category_id %s NOT NULL, + category_name %s NOT NULL, + source_id %s NOT NULL UNIQUE, + name %s NOT NULL, + description %s NOT NULL DEFAULT '', + method %s NOT NULL DEFAULT 'GET', + api_url %s NOT NULL DEFAULT '', + url_template %s NOT NULL DEFAULT '', + thumbnail_url %s NOT NULL DEFAULT '', + proxy_mode %s NOT NULL DEFAULT 'client_direct', timeout_ms INTEGER NOT NULL DEFAULT 8000, retry_count INTEGER NOT NULL DEFAULT 1, cache_seconds INTEGER NOT NULL DEFAULT 300, check_interval_sec INTEGER NOT NULL DEFAULT 300, enabled INTEGER NOT NULL DEFAULT 1, client_visible INTEGER NOT NULL DEFAULT 1, - supported_formats TEXT NOT NULL DEFAULT '[]', - last_status TEXT NOT NULL DEFAULT 'unknown', + supported_formats %s NOT NULL, + last_status %s NOT NULL DEFAULT 'unknown', last_latency_ms INTEGER NOT NULL DEFAULT 0, - last_checked_at TEXT NOT NULL DEFAULT '', - last_error TEXT NOT NULL DEFAULT '', + last_checked_at %s NOT NULL DEFAULT '', + last_error %s NOT NULL, consecutive_failure INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, d.idType()), + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, d.idType(), keyText, shortText, keyText, shortText, mediumText, keyText, mediumText, mediumText, mediumText, keyText, longText, keyText, shortText, longText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_health_checks ( id %s, source_db_id BIGINT NOT NULL, - status TEXT NOT NULL, + status %s NOT NULL, latency_ms INTEGER NOT NULL DEFAULT 0, - error TEXT NOT NULL DEFAULT '', - checked_at TEXT NOT NULL - )`, d.idType()), + error %s NOT NULL, + checked_at %s NOT NULL + )`, d.idType(), keyText, longText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_call_logs ( id %s, - source_id TEXT NOT NULL, - status TEXT NOT NULL, + source_id %s NOT NULL, + status %s NOT NULL, latency_ms INTEGER NOT NULL DEFAULT 0, - error TEXT NOT NULL DEFAULT '', - client TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )`, d.idType()), + error %s NOT NULL, + client %s NOT NULL DEFAULT '', + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, longText, mediumText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS database_sync_jobs ( id %s, - direction TEXT NOT NULL, - status TEXT NOT NULL, - message TEXT NOT NULL DEFAULT '', - tables_json TEXT NOT NULL DEFAULT '{}', - started_at TEXT NOT NULL, - finished_at TEXT NOT NULL DEFAULT '' - )`, d.idType()), + direction %s NOT NULL, + status %s NOT NULL, + message %s NOT NULL, + tables_json %s NOT NULL, + started_at %s NOT NULL, + finished_at %s NOT NULL DEFAULT '' + )`, d.idType(), keyText, keyText, longText, longText, shortText, shortText), + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS system_settings ( + %s %s NOT NULL PRIMARY KEY, + value %s NOT NULL, + updated_at %s NOT NULL + )`, d.quoteIdent("key"), keyText, longText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_sync_jobs ( id %s, - status TEXT NOT NULL, - summary TEXT NOT NULL DEFAULT '', - stats_json TEXT NOT NULL DEFAULT '{}', - started_at TEXT NOT NULL, - finished_at TEXT NOT NULL DEFAULT '' - )`, d.idType()), + status %s NOT NULL, + summary %s NOT NULL, + stats_json %s NOT NULL, + started_at %s NOT NULL, + finished_at %s NOT NULL DEFAULT '' + )`, d.idType(), keyText, longText, longText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS audit_logs ( id %s, - actor TEXT NOT NULL DEFAULT '', - type TEXT NOT NULL, - target TEXT NOT NULL DEFAULT '', - message TEXT NOT NULL DEFAULT '', - ip TEXT NOT NULL DEFAULT '', - user_agent TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )`, d.idType()), + actor %s NOT NULL DEFAULT '', + type %s NOT NULL, + target %s NOT NULL DEFAULT '', + message %s NOT NULL, + ip %s NOT NULL DEFAULT '', + user_agent %s NOT NULL DEFAULT '', + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, keyText, longText, keyText, mediumText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_json_revisions ( id %s, - name TEXT NOT NULL, - raw TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '', - created_by TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )`, d.idType()), + name %s NOT NULL, + raw %s NOT NULL, + note %s NOT NULL DEFAULT '', + created_by %s NOT NULL DEFAULT '', + created_at %s NOT NULL + )`, d.idType(), keyText, longText, mediumText, keyText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS webhook_deliveries ( id %s, - webhook_name TEXT NOT NULL DEFAULT '', - event TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT '', + webhook_name %s NOT NULL DEFAULT '', + event %s NOT NULL DEFAULT '', + status %s NOT NULL DEFAULT '', attempts INTEGER NOT NULL DEFAULT 0, response_code INTEGER NOT NULL DEFAULT 0, - error_message TEXT NOT NULL DEFAULT '', - payload_sha256 TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - finished_at TEXT NOT NULL DEFAULT '' - )`, d.idType()), + error_message %s NOT NULL, + payload_sha256 %s NOT NULL DEFAULT '', + created_at %s NOT NULL, + finished_at %s NOT NULL DEFAULT '' + )`, d.idType(), keyText, keyText, keyText, longText, shortText, shortText, shortText), `CREATE INDEX IF NOT EXISTS idx_feedback_tickets_activity ON feedback_tickets(last_activity_at)`, `CREATE INDEX IF NOT EXISTS idx_feedback_comments_code ON feedback_comments(feedback_code)`, `CREATE INDEX IF NOT EXISTS idx_feedback_attachments_code ON feedback_attachments(feedback_code)`, @@ -279,6 +288,8 @@ func schemaStatements(d dialect) []string { `CREATE INDEX IF NOT EXISTS idx_mail_records_code ON mail_records(feedback_code)`, `CREATE INDEX IF NOT EXISTS idx_endpoint_call_logs_source ON endpoint_call_logs(source_id)`, `CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_audit_logs_type ON audit_logs(type)`, + `CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target)`, `CREATE INDEX IF NOT EXISTS idx_legacy_json_revisions_name ON legacy_json_revisions(name, id)`, `CREATE INDEX IF NOT EXISTS idx_release_notices_version ON release_notices(version)`, `CREATE INDEX IF NOT EXISTS idx_release_notice_revisions_version ON release_notice_revisions(version, id)`, diff --git a/server/unified-management/internal/db/settings_store.go b/server/unified-management/internal/db/settings_store.go new file mode 100644 index 0000000..8292817 --- /dev/null +++ b/server/unified-management/internal/db/settings_store.go @@ -0,0 +1,17 @@ +package db + +func (s *Store) GetSetting(key string) (string, error) { + var value string + err := s.queryRow("SELECT value FROM system_settings WHERE `key` = ?", sanitize(key)).Scan(&value) + return value, err +} + +func (s *Store) UpsertSetting(key, value string) error { + columns := []string{"key", "value", "updated_at"} + conn, d := s.active() + _, err := conn.Exec(d.rebind(d.upsert("system_settings", columns, []string{"key"})), sanitize(key), value, Now()) + if err != nil { + s.markFailover(err) + } + return err +} diff --git a/server/unified-management/internal/db/source_store.go b/server/unified-management/internal/db/source_store.go index 53dd94b..2c580f4 100644 --- a/server/unified-management/internal/db/source_store.go +++ b/server/unified-management/internal/db/source_store.go @@ -110,8 +110,8 @@ func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int, return err } if status == "ok" { - _, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = '', consecutive_failure = 0, updated_at = ? WHERE id = ?`, - status, latency, now, now, sourceDBID) + _, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = 0, updated_at = ? WHERE id = ?`, + status, latency, now, sanitize(message), now, sourceDBID) } else if status == "redirected" { _, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = 0, updated_at = ? WHERE id = ?`, status, latency, now, sanitize(message), now, sourceDBID) diff --git a/server/unified-management/internal/db/store.go b/server/unified-management/internal/db/store.go index 053bc3c..4e29184 100644 --- a/server/unified-management/internal/db/store.go +++ b/server/unified-management/internal/db/store.go @@ -14,6 +14,7 @@ import ( type Store struct { mu sync.RWMutex + syncMu sync.Mutex cfg *config.Config path string db *sql.DB @@ -114,6 +115,80 @@ func (s *Store) Path() string { return s.path } +func (s *Store) ReconfigureDatabase(cfg *config.Config) error { + if cfg == nil { + return nil + } + path := cfg.Database.SQLitePath + if strings.TrimSpace(path) == "" { + path = filepath.Join(cfg.StorageDir, "unified.sqlite") + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + if err := os.MkdirAll(cfg.StorageDir, 0o750); err != nil { + return err + } + localCfg := cfg.Database + localCfg.Provider = "sqlite" + localCfg.SQLitePath = path + local, localDialect, err := openSQLDatabase(localCfg) + if err != nil { + return err + } + local.SetMaxOpenConns(1) + if err := s.migrate(local, localDialect); err != nil { + _ = local.Close() + return err + } + var remote *sql.DB + var remoteDialect dialect + if strings.EqualFold(cfg.Database.Provider, "mysql") { + remote, remoteDialect, err = openSQLDatabase(cfg.Database) + if err != nil { + _ = local.Close() + return err + } + if err := s.migrate(remote, remoteDialect); err != nil { + _ = remote.Close() + _ = local.Close() + return err + } + } + s.mu.Lock() + oldLocal := s.localDB + oldRemote := s.remoteDB + s.cfg.Database = cfg.Database + s.path = path + s.localDB = local + s.localDialect = localDialect + s.remoteDB = remote + s.remoteDialect = remoteDialect + s.status.ConfigProvider = cfg.Database.Provider + s.status.SQLiteReady = true + s.status.RemoteReady = remote != nil + s.status.LastError = "" + s.status.FailoverActive = false + if remote != nil { + s.db = remote + s.dialect = remoteDialect + s.status.ActiveProvider = "mysql" + } else { + s.db = local + s.dialect = localDialect + s.status.ActiveProvider = "sqlite" + } + s.status.LastRecoveredAt = Now() + s.mu.Unlock() + if oldRemote != nil && oldRemote != oldLocal && oldRemote != remote { + _ = oldRemote.Close() + } + if oldLocal != nil && oldLocal != local { + _ = oldLocal.Close() + } + return nil +} + func (s *Store) active() (*sql.DB, dialect) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/server/unified-management/internal/db/store_test.go b/server/unified-management/internal/db/store_test.go index 7757d4d..3478ae3 100644 --- a/server/unified-management/internal/db/store_test.go +++ b/server/unified-management/internal/db/store_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "ymhut-box/server/unified-management/internal/config" @@ -273,3 +274,75 @@ func TestChangeAdminPasswordRejectsWeakPasswords(t *testing.T) { } } } + +func TestMySQLSchemaAvoidsTextKeys(t *testing.T) { + statements := strings.Join(schemaStatements(dialectFor("mysql")), "\n") + for _, forbidden := range []string{ + "TEXT NOT NULL UNIQUE", + "TEXT PRIMARY KEY", + "TEXT NOT NULL PRIMARY KEY", + "key VARCHAR(191) NOT NULL PRIMARY KEY", + } { + if strings.Contains(statements, forbidden) { + t.Fatalf("mysql schema contains forbidden fragment %q:\n%s", forbidden, statements) + } + } + if !strings.Contains(statements, "`key` VARCHAR(191) NOT NULL PRIMARY KEY") { + t.Fatalf("system_settings.key must be quoted for MySQL:\n%s", statements) + } +} + +func TestDashboardOverviewKeepsChecksForDeletedSources(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "unified.sqlite") + store, err := Open(&config.Config{ + StorageDir: root, + Database: config.DatabaseConfig{ + Provider: "sqlite", + SQLitePath: path, + FailoverEnabled: true, + HealthIntervalSec: 3600, + MaxOpenConns: 1, + MaxIdleConns: 1, + ConnMaxLifetimeSeconds: 60, + }, + }) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + source, err := store.UpsertSource(Source{ + CategoryID: "video", + CategoryName: "视频", + SourceID: "video-demo", + Name: "演示接口", + APIURL: "https://example.com/video.json", + Enabled: true, + ClientVisible: true, + }) + if err != nil { + t.Fatal(err) + } + if err := store.RecordSourceCheck(source.ID, "ok", 123, ""); err != nil { + t.Fatal(err) + } + if err := store.DeleteSource(source.SourceID); err != nil { + t.Fatal(err) + } + + overview, err := store.DashboardOverview(10) + if err != nil { + t.Fatal(err) + } + checks, ok := overview["heartbeats"].([]map[string]any) + if !ok { + t.Fatalf("heartbeats has unexpected type %T", overview["heartbeats"]) + } + if len(checks) != 1 { + t.Fatalf("expected deleted source check to remain visible, got %d", len(checks)) + } + if checks[0]["sourceId"] == "" || checks[0]["name"] == "" { + t.Fatalf("deleted source check should have fallback sourceId/name: %#v", checks[0]) + } +} diff --git a/server/unified-management/internal/feedback/feedback.go b/server/unified-management/internal/feedback/feedback.go index 867803e..e922f6b 100644 --- a/server/unified-management/internal/feedback/feedback.go +++ b/server/unified-management/internal/feedback/feedback.go @@ -23,6 +23,7 @@ import ( "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" + feedbackmail "ymhut-box/server/unified-management/internal/mail" ) const PackageMagic = "YMHUTFB1" @@ -79,14 +80,70 @@ func NewService(cfg *config.Config, store *db.Store) *Service { func (s *Service) Submit(r *http.Request) (db.Feedback, error) { contentType := r.Header.Get("Content-Type") + var item db.Feedback + var err error if strings.Contains(contentType, "multipart/form-data") { - if item, err := s.submitMultipart(r); err == nil { + if item, err = s.submitMultipart(r); err == nil { + if !DuplicateSubmission(r) && s.NotifyFeedback(item) == nil { + item.MailSent = true + } return item, nil } else if hasSignedFields(r) || !strings.Contains(strings.ToLower(err.Error()), "signed multipart fields are required") { return db.Feedback{}, err } } - return s.submitSimple(r) + item, err = s.submitSimple(r) + if err == nil && s.NotifyFeedback(item) == nil { + item.MailSent = true + } + return item, err +} + +func (s *Service) RetryMail(code string) error { + item, err := s.store.GetFeedback(NormalizeCode(code)) + if err != nil { + return err + } + return s.NotifyFeedback(item) +} + +func (s *Service) NotifyFeedback(item db.Feedback) error { + message, err := feedbackmail.BuildFeedbackMessage(s.cfg, item) + if err != nil { + _, _ = s.store.InsertMailRecord(db.LegacyMailRecord{ + FeedbackCode: item.Code, + Kind: "feedback", + Status: "failed", + Subject: "反馈邮件未发送", + ErrorMessage: err.Error(), + CreatedAt: db.Now(), + }) + _ = s.store.UpdateFeedbackMailState(item.Code, false) + return err + } + mailID, err := s.store.InsertMailRecord(db.LegacyMailRecord{ + FeedbackCode: item.Code, + Kind: "feedback", + Status: "pending", + ToAddress: message.To, + Subject: message.Subject, + PlainBody: message.PlainBody, + HTMLBody: message.HTMLBody, + AttachmentPath: message.AttachmentPath, + AttachmentName: message.AttachmentName, + CreatedAt: db.Now(), + }) + if err != nil { + return err + } + if err := feedbackmail.Send(s.cfg, message); err != nil { + _ = s.store.UpdateMailState(mailID, "failed", err.Error()) + _ = s.store.UpdateFeedbackMailState(item.Code, false) + return err + } + _ = s.store.UpdateMailState(mailID, "sent", "") + _ = s.store.UpdateFeedbackMailState(item.Code, true) + return nil } func (s *Service) submitSimple(r *http.Request) (db.Feedback, error) { diff --git a/server/unified-management/internal/legacy/legacy.go b/server/unified-management/internal/legacy/legacy.go index 74011c4..8fee3d7 100644 --- a/server/unified-management/internal/legacy/legacy.go +++ b/server/unified-management/internal/legacy/legacy.go @@ -230,12 +230,12 @@ func validate(name string, parsed map[string]any) error { case "update-info": if _, ok := parsed["app_version"]; !ok { if _, ok := parsed["title"]; !ok { - return errors.New("update-info requires app_version or title") + return errors.New("更新 JSON 需要填写 app_version 或 title") } } case "media-types": if _, ok := parsed["categories"].([]any); !ok { - return errors.New("media-types requires categories array") + return errors.New("媒体源 JSON 需要包含 categories 数组") } if _, ok := parsed["layout_version"]; !ok { parsed["layout_version"] = "1.0.0" diff --git a/server/unified-management/internal/mail/mail.go b/server/unified-management/internal/mail/mail.go new file mode 100644 index 0000000..ec75ebf --- /dev/null +++ b/server/unified-management/internal/mail/mail.go @@ -0,0 +1,357 @@ +package mail + +import ( + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "mime" + "net" + "net/smtp" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "ymhut-box/server/unified-management/internal/config" + "ymhut-box/server/unified-management/internal/db" +) + +type Message struct { + From string + FromName string + To string + Subject string + PlainBody string + HTMLBody string + AttachmentPath string + AttachmentName string +} + +func SafeConfig(cfg config.MailConfig) map[string]any { + return map[string]any{ + "host": cfg.Host, + "port": cfg.Port, + "secure": cfg.Secure, + "username": cfg.Username, + "fromAddress": cfg.FromAddress, + "fromName": cfg.FromName, + "developerAddress": cfg.DeveloperAddress, + "timeoutSeconds": cfg.TimeoutSeconds, + "hasPassword": strings.TrimSpace(cfg.Password) != "", + "configured": IsConfigured(cfg), + } +} + +func IsConfigured(cfg config.MailConfig) bool { + channel := normalize(cfg) + return channel.Host != "" && channel.FromAddress != "" && channel.DeveloperAddress != "" +} + +func BuildFeedbackMessage(cfg *config.Config, record db.Feedback) (Message, error) { + channel, err := channel(cfg.Mail) + if err != nil { + return Message{}, err + } + attachment := record.PackagePath + name := "" + if attachment != "" { + name = record.Code + ".zip" + } + subject := "[" + record.Code + "] YMhut Box 反馈:" + truncate(record.Title, 80) + return Message{ + From: channel.FromAddress, + FromName: channel.FromName, + To: channel.DeveloperAddress, + Subject: subject, + PlainBody: feedbackPlain(record), + HTMLBody: feedbackHTML(record), + AttachmentPath: attachment, + AttachmentName: name, + }, nil +} + +func BuildTestMessage(cfg *config.Config) (Message, error) { + channel, err := channel(cfg.Mail) + if err != nil { + return Message{}, err + } + now := time.Now().UTC().Format(time.RFC3339) + return Message{ + From: channel.FromAddress, + FromName: channel.FromName, + To: channel.DeveloperAddress, + Subject: "YMhut Box 反馈通知测试", + PlainBody: "这是一封来自 unified-management 的测试通知。\n时间:" + now, + HTMLBody: "
这是一封来自 unified-management 的测试通知。
时间:" + htmlEscape(now) + "
", + }, nil +} + +func Send(cfg *config.Config, message Message) error { + channel, err := channel(cfg.Mail) + if err != nil { + return err + } + raw, err := BuildMIME(message) + if err != nil { + return err + } + return smtpSend(channel, message.From, message.To, raw) +} + +func BuildMIME(message Message) (string, error) { + boundary := "ymhut_" + randomish() + altBoundary := "ymhut_alt_" + randomish() + headers := []string{ + "Date: " + time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05") + " +0000", + "From: " + mimeAddress(message.From, message.FromName), + "To: " + message.To, + "Subject: " + mime.BEncoding.Encode("UTF-8", message.Subject), + "MIME-Version: 1.0", + `Content-Type: multipart/mixed; boundary="` + boundary + `"`, + } + body := []string{ + "--" + boundary, + `Content-Type: multipart/alternative; boundary="` + altBoundary + `"`, + "", + "--" + altBoundary, + "Content-Type: text/plain; charset=UTF-8", + "Content-Transfer-Encoding: base64", + "", + wrapBase64([]byte(message.PlainBody)), + "--" + altBoundary, + "Content-Type: text/html; charset=UTF-8", + "Content-Transfer-Encoding: base64", + "", + wrapBase64([]byte(message.HTMLBody)), + "--" + altBoundary + "--", + } + if message.AttachmentPath != "" { + data, err := os.ReadFile(message.AttachmentPath) + if err == nil { + name := firstNonEmpty(message.AttachmentName, filepath.Base(message.AttachmentPath)) + escaped := strings.ReplaceAll(strings.ReplaceAll(name, `\`, `\\`), `"`, `\"`) + body = append(body, + "--"+boundary, + `Content-Type: application/zip; name="`+escaped+`"`, + "Content-Transfer-Encoding: base64", + `Content-Disposition: attachment; filename="`+escaped+`"`, + "", + wrapBase64(data), + ) + } + } + body = append(body, "--"+boundary+"--") + return strings.Join(headers, "\r\n") + "\r\n\r\n" + strings.Join(body, "\r\n"), nil +} + +func channel(cfg config.MailConfig) (config.MailConfig, error) { + cfg = normalize(cfg) + if cfg.Host == "" || cfg.FromAddress == "" || cfg.DeveloperAddress == "" { + return cfg, errors.New("mail is not configured") + } + return cfg, nil +} + +func normalize(cfg config.MailConfig) config.MailConfig { + cfg.Secure = strings.ToLower(strings.TrimSpace(cfg.Secure)) + if cfg.Secure == "" { + cfg.Secure = "ssl" + } + if cfg.Port <= 0 { + cfg.Port = 465 + } + if cfg.FromAddress == "" { + cfg.FromAddress = cfg.Username + } + if cfg.FromName == "" { + cfg.FromName = "YMhut Box Feedback" + } + if cfg.TimeoutSeconds <= 0 { + cfg.TimeoutSeconds = 20 + } + return cfg +} + +func smtpSend(channel config.MailConfig, from, to, rawMessage string) error { + address := net.JoinHostPort(channel.Host, fmt.Sprintf("%d", channel.Port)) + timeout := time.Duration(channel.TimeoutSeconds) * time.Second + var client *smtp.Client + if channel.Secure == "ssl" || channel.Secure == "tls" { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", address, &tls.Config{ServerName: channel.Host}) + if err != nil { + return fmt.Errorf("邮件服务器连接失败:%w", err) + } + var clientErr error + client, clientErr = smtp.NewClient(conn, channel.Host) + if clientErr != nil { + _ = conn.Close() + return clientErr + } + } else { + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + return fmt.Errorf("邮件服务器连接失败:%w", err) + } + var clientErr error + client, clientErr = smtp.NewClient(conn, channel.Host) + if clientErr != nil { + _ = conn.Close() + return clientErr + } + } + defer client.Close() + if channel.Secure == "starttls" { + if err := client.StartTLS(&tls.Config{ServerName: channel.Host}); err != nil { + return fmt.Errorf("邮件加密握手失败:%w", err) + } + } + if channel.Username != "" || channel.Password != "" { + if err := client.Auth(smtp.PlainAuth("", channel.Username, channel.Password, channel.Host)); err != nil { + return fmt.Errorf("邮件认证失败:%w", err) + } + } + if err := client.Mail(extractEmail(from)); err != nil { + return err + } + if err := client.Rcpt(extractEmail(to)); err != nil { + return err + } + writer, err := client.Data() + if err != nil { + return err + } + if _, err := writer.Write([]byte(rawMessage)); err != nil { + _ = writer.Close() + return err + } + if err := writer.Close(); err != nil { + return err + } + return client.Quit() +} + +func feedbackPlain(record db.Feedback) string { + return strings.Join([]string{ + "YMhut Box 反馈工单", + "反馈编号:" + record.Code, + "标题:" + record.Title, + "类型:" + typeLabel(record.Type), + "优先级:" + priorityLabel(record.Priority, record.Severity), + "联系方式:" + record.Contact, + "接收时间:" + record.CreatedAt, + "包含文件:" + record.IncludedFiles, + "反馈包 SHA256:" + record.PlainPackageSha256, + "", + "正文:", + record.Body, + "", + "反馈包摘要:", + record.SummaryText, + }, "\n") +} + +func feedbackHTML(record db.Feedback) string { + rows := [][2]string{ + {"反馈编号", record.Code}, + {"标题", record.Title}, + {"类型", typeLabel(record.Type)}, + {"优先级", priorityLabel(record.Priority, record.Severity)}, + {"联系方式", record.Contact}, + {"接收时间", record.CreatedAt}, + {"包含文件", record.IncludedFiles}, + {"反馈包 SHA256", record.PlainPackageSha256}, + } + html := `| ` + htmlEscape(row[0]) + " | " + strings.ReplaceAll(htmlEscape(row[1]), "\n", " ") + " |
|---|
` + htmlEscape(record.Body) + "
" + html += `` + htmlEscape(record.SummaryText) + "" + return html +} + +func typeLabel(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "suggestion": + return "建议" + case "ui": + return "界面反馈" + case "other": + return "其他" + default: + return "问题" + } +} + +func priorityLabel(priority, severity string) string { + value := firstNonEmpty(priority, severity) + switch strings.ToLower(strings.TrimSpace(value)) { + case "urgent", "blocking": + return "紧急" + case "high", "major": + return "高" + case "low", "minor": + return "低" + default: + return "普通" + } +} + +func htmlEscape(value string) string { + value = strings.ReplaceAll(value, "&", "&") + value = strings.ReplaceAll(value, "<", "<") + value = strings.ReplaceAll(value, ">", ">") + value = strings.ReplaceAll(value, `"`, """) + return strings.ReplaceAll(value, "'", "'") +} + +func mimeAddress(address, name string) string { + if name == "" { + return address + } + return mime.BEncoding.Encode("UTF-8", name) + " <" + extractEmail(address) + ">" +} + +func extractEmail(value string) string { + re := regexp.MustCompile(`<([^>]+)>`) + if match := re.FindStringSubmatch(value); len(match) == 2 { + return strings.TrimSpace(match[1]) + } + return strings.TrimSpace(value) +} + +func wrapBase64(data []byte) string { + encoded := base64.StdEncoding.EncodeToString(data) + var builder strings.Builder + for len(encoded) > 76 { + builder.WriteString(encoded[:76]) + builder.WriteString("\r\n") + encoded = encoded[76:] + } + builder.WriteString(encoded) + return builder.String() +} + +func randomish() string { + return strings.ReplaceAll(fmt.Sprintf("%d", time.Now().UnixNano()), "-", "") +} + +func truncate(value string, max int) string { + runes := []rune(strings.TrimSpace(value)) + if len(runes) <= max { + return string(runes) + } + return string(runes[:max]) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/server/unified-management/internal/notices/notices.go b/server/unified-management/internal/notices/notices.go index 4eaa04a..8f97a57 100644 --- a/server/unified-management/internal/notices/notices.go +++ b/server/unified-management/internal/notices/notices.go @@ -139,6 +139,34 @@ func (s *Service) Save(ctx context.Context, version string, req SaveRequest, act return s.Get(saved.Version) } +func (s *Service) SyncFromLegacyUpdateInfo(ctx context.Context, raw string, actor string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + item, parsed, formatted, err := parseNotice([]byte(raw), "", "") + if err != nil { + return err + } + item.RawJSON = formatted + current, err := s.store.GetReleaseNotice(item.Version) + if err == nil && current.RawJSON != "" && current.RawJSON != formatted { + _, _ = s.store.SaveReleaseNoticeRevision(item.Version, current.RawJSON, "auto backup before legacy update-info sync", actor) + } + saved, err := s.store.UpsertReleaseNotice(item) + if err != nil { + return err + } + _, _ = s.store.SaveReleaseNoticeRevision(saved.Version, formatted, "synced from update-info.json", actor) + if err := s.writeNoticeFile(saved, formatted); err != nil { + return err + } + if err := s.writeTotalIndex(saved, parsed); err != nil { + return err + } + _ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.synced", Target: saved.Version, Message: "版本日志已从兼容 update-info.json 同步"}) + return nil +} + func (s *Service) Restore(ctx context.Context, version string, revisionID int64, actor string) (Document, error) { revision, err := s.store.GetReleaseNoticeRevision(version, revisionID) if err != nil { @@ -227,10 +255,7 @@ func (s *Service) writeTotalIndex(item db.ReleaseNotice, parsed map[string]any) func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string]any) error { path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json") - payload := map[string]any{} - if data, err := os.ReadFile(path); err == nil { - _ = json.Unmarshal(data, &payload) - } + payload := s.legacyUpdateBase(path) payload["app_version"] = item.Version setNonEmpty(payload, "build", item.Build) setNonEmpty(payload, "channel", item.Channel) @@ -256,6 +281,36 @@ func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string] return atomicWrite(path, append(data, '\n')) } +func (s *Service) legacyUpdateBase(currentPath string) map[string]any { + payload := map[string]any{} + for _, path := range []string{ + filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"), + currentPath, + } { + if data, err := os.ReadFile(path); err == nil { + var doc map[string]any + if json.Unmarshal(data, &doc) == nil { + for key, value := range doc { + payload[key] = value + } + } + } + } + if payload["app_version"] == nil { + if value, ok := payload["appVersion"]; ok { + payload["app_version"] = value + } else if value, ok := payload["latestVersion"]; ok { + payload["app_version"] = value + } + } + if payload["manifest_version"] == nil { + if value, ok := payload["manifestVersion"]; ok { + payload["manifest_version"] = value + } + } + return payload +} + func parseAndFormat(data []byte, fallbackVersion, noticeFile string) (map[string]any, string, error) { _, parsed, formatted, err := parseNotice(data, fallbackVersion, noticeFile) return parsed, formatted, err @@ -270,7 +325,7 @@ func parseNotice(data []byte, fallbackVersion, noticeFile string) (db.ReleaseNot } version := firstNonEmpty(stringValue(parsed, "app_version"), stringValue(parsed, "version"), fallbackVersion) if version == "" { - return db.ReleaseNotice{}, nil, "", errors.New("version or app_version is required") + return db.ReleaseNotice{}, nil, "", errors.New("版本日志需要填写 version 或 app_version") } if noticeFile == "" { noticeFile = version + ".json" diff --git a/server/unified-management/internal/notices/notices_test.go b/server/unified-management/internal/notices/notices_test.go index 17b52f6..0035d5c 100644 --- a/server/unified-management/internal/notices/notices_test.go +++ b/server/unified-management/internal/notices/notices_test.go @@ -69,6 +69,56 @@ func TestSaveNoticeSyncsFilesAndLegacyUpdateInfo(t *testing.T) { } } +func TestSyncFromLegacyUpdateInfoUpdatesNoticeIndex(t *testing.T) { + root := t.TempDir() + public := filepath.Join(root, "public") + noticeDir := filepath.Join(root, "update-notice") + if err := os.MkdirAll(public, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(noticeDir, 0o755); err != nil { + t.Fatal(err) + } + writeJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{"schema_version": 1, "versions": []any{}}) + + cfg := &config.Config{ + StorageDir: filepath.Join(root, "storage"), + UpdatePublicDir: public, + UpdateNoticeDir: noticeDir, + Database: config.DatabaseConfig{ + Provider: "sqlite", + SQLitePath: filepath.Join(root, "storage", "unified.sqlite"), + FailoverEnabled: true, + HealthIntervalSec: 3600, + MaxOpenConns: 1, + MaxIdleConns: 1, + ConnMaxLifetimeSeconds: 60, + }, + } + store, err := db.Open(cfg) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + service := NewService(cfg, store) + raw := `{"app_version":"2.0.7.5","title":"YMhut Box 2.0.7.5","message":"随机放映室优化","release_notes":"修复图片源和全屏预览","download_url":"https://update.ymhut.cn/downloads/app.exe"}` + if err := service.SyncFromLegacyUpdateInfo(context.Background(), raw, "admin"); err != nil { + t.Fatal(err) + } + items, err := service.List(10) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Version != "2.0.7.5" || items[0].Title != "YMhut Box 2.0.7.5" { + t.Fatalf("notice list not synced: %#v", items) + } + total := readJSONFile(t, filepath.Join(noticeDir, "total.json")) + if total["latest_version"] != "2.0.7.5" { + t.Fatalf("total index not updated: %#v", total) + } +} + func writeJSON(t *testing.T, path string, payload any) { t.Helper() data, err := json.Marshal(payload) diff --git a/server/unified-management/internal/releases/releases.go b/server/unified-management/internal/releases/releases.go index 5361635..d88b5e7 100644 --- a/server/unified-management/internal/releases/releases.go +++ b/server/unified-management/internal/releases/releases.go @@ -61,7 +61,7 @@ func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.S } func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any { - payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")) + payload := s.legacyUpdateBase() manifest := s.Manifest(r) for _, key := range []string{"app_version", "download_url", "download_mirrors", "detected_product", "detected_packages", "packages", "modules", "manifest_version", "release_notes", "release_notes_md", "message", "message_md", "notices", "latest_notice"} { if value, ok := manifest[key]; ok { @@ -72,7 +72,7 @@ func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any { } func (s *Service) Manifest(r *http.Request) map[string]any { - payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")) + payload := s.legacyUpdateBase() packages := s.ScanPackages(r) modules := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "modules.json"))["modules"] if modules == nil { @@ -116,6 +116,20 @@ func (s *Service) Manifest(r *http.Request) map[string]any { return payload } +func (s *Service) PublishLegacyUpdateInfo(r *http.Request, actor string) error { + payload := s.LegacyUpdateInfo(r) + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json") + if err := atomicWrite(path, append(data, '\n')); err != nil { + return err + } + _, _ = s.store.SaveLegacyRevision("update-info", string(append(data, '\n')), "generated from release database", firstNonEmpty(actor, "system")) + return nil +} + func setIfMissing(payload map[string]any, key, value string) { if strings.TrimSpace(value) == "" { return @@ -244,7 +258,7 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error { path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json") - payload := readJSON(path) + payload := s.legacyUpdateBase() payload["app_version"] = pkg.Version payload["download_url"] = pkg.URL payload["package_sha256"] = pkg.SHA256 @@ -265,10 +279,32 @@ func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error { if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { - return err + return atomicWrite(path, append(data, '\n')) +} + +func (s *Service) legacyUpdateBase() map[string]any { + payload := map[string]any{} + for _, path := range []string{ + filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"), + filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"), + } { + for key, value := range readJSON(path) { + payload[key] = value + } } - return os.WriteFile(path, append(data, '\n'), 0o640) + if payload["app_version"] == nil { + if value, ok := payload["appVersion"]; ok { + payload["app_version"] = value + } else if value, ok := payload["latestVersion"]; ok { + payload["app_version"] = value + } + } + if payload["manifest_version"] == nil { + if value, ok := payload["manifestVersion"]; ok { + payload["manifest_version"] = value + } + } + return payload } func readJSON(path string) map[string]any { @@ -283,6 +319,30 @@ func readJSON(path string) map[string]any { return payload } +func atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, 0o640); err != nil { + return err + } + return os.Rename(tmpName, path) +} + func requestBaseURL(r *http.Request, fallback string) string { if r != nil { scheme := r.Header.Get("X-Forwarded-Proto") diff --git a/server/unified-management/internal/sources/sources.go b/server/unified-management/internal/sources/sources.go index 598e0f4..272400f 100644 --- a/server/unified-management/internal/sources/sources.go +++ b/server/unified-management/internal/sources/sources.go @@ -5,10 +5,12 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "os" "path/filepath" + "regexp" "strings" "sync" "time" @@ -44,10 +46,28 @@ type CheckJob struct { LastError string `json:"lastError"` } +type mediaResolution struct { + URL string + Key string + MediaType string + Direct bool +} + +type mediaCandidate struct { + Resolution mediaResolution + Score int + Depth int + Order int +} + type legacyMedia struct { Categories []legacyCategory `json:"categories"` } +const maxSourceProbeBytes int64 = 2 * 1024 * 1024 + +var absoluteURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\]+`) + type legacyCategory struct { ID string `json:"id"` Name string `json:"name"` @@ -152,6 +172,42 @@ func (s *Service) ImportLegacyMediaTypes(ctx context.Context) error { return nil } +func (s *Service) DeleteSourceAndPublishCompatibility(ctx context.Context, sourceID, actor string) error { + sourceID = strings.TrimSpace(sourceID) + if sourceID == "" || strings.ContainsAny(sourceID, `/\`) || strings.Contains(sourceID, "..") { + return errors.New("invalid source id") + } + if _, err := s.store.GetSourceBySourceID(sourceID); err != nil { + return err + } + if err := s.store.DeleteSource(sourceID); err != nil { + return err + } + if err := s.PublishLegacyMediaTypes(ctx, actor); err != nil { + return err + } + _ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "source.deleted", Target: sourceID, Message: "客户端接口已删除并同步兼容 media-types.json"}) + return nil +} + +func (s *Service) PublishLegacyMediaTypes(ctx context.Context, actor string) error { + catalog, err := s.Catalog(false) + if err != nil { + return err + } + data, err := json.MarshalIndent(catalog, "", " ") + if err != nil { + return err + } + formatted := append(data, '\n') + path := filepath.Join(s.cfg.UpdatePublicDir, "media-types.json") + if err := atomicWrite(path, formatted); err != nil { + return err + } + _, _ = s.store.SaveLegacyRevision("media-types", string(formatted), "generated from source database", firstNonEmpty(actor, "system")) + return nil +} + func (s *Service) Catalog(includeHidden bool) (map[string]any, error) { items, err := s.store.ListSources(includeHidden) if err != nil { @@ -194,6 +250,7 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) { "meta": parseHealthMeta(item.LastError), }, } + applyResolvedFields(sub, item.LastError) cat["subcategories"] = append(cat["subcategories"].([]map[string]any), sub) } out := []map[string]any{} @@ -216,7 +273,7 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) { for _, item := range items { var formats []string _ = json.Unmarshal([]byte(item.SupportedFormats), &formats) - out = append(out, map[string]any{ + endpoint := map[string]any{ "id": item.SourceID, "category": item.CategoryID, "name": item.Name, @@ -235,7 +292,9 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) { "consecutiveFailure": item.ConsecutiveFailure, "meta": parseHealthMeta(item.LastError), }, - }) + } + applyResolvedFields(endpoint, item.LastError) + out = append(out, endpoint) } return out, nil } @@ -434,6 +493,18 @@ func (s *Service) CheckOneStatus(ctx context.Context, item db.Source) (string, e "error": resp.Status, }) } + if resp.StatusCode < 400 { + meta := parseHealthMeta(message) + meta["finalUrl"] = resp.Request.URL.String() + meta["finalStatus"] = resp.StatusCode + if resolution := resolveMediaFromResponse(resp); resolution.URL != "" { + meta["resolvedUrl"] = resolution.URL + meta["resolvedKey"] = resolution.Key + meta["mediaType"] = resolution.MediaType + meta["directMedia"] = resolution.Direct + } + message = healthMetaMessage(meta) + } if err := s.store.RecordSourceCheck(item.ID, status, latency, message); err != nil { return status, err } @@ -486,6 +557,259 @@ func isHTTPURL(value *url.URL) bool { return scheme == "http" || scheme == "https" } +func resolveMediaFromResponse(resp *http.Response) mediaResolution { + if resp == nil || resp.Request == nil || resp.Request.URL == nil { + return mediaResolution{} + } + finalURL := resp.Request.URL + contentType := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])) + if mediaType := mediaTypeFromContentType(contentType); mediaType != "" || looksLikeMediaURL(finalURL) { + return mediaResolution{URL: finalURL.String(), Key: "response", MediaType: firstNonEmpty(mediaType, mediaTypeFromURL(finalURL)), Direct: true} + } + if !canProbeText(contentType, resp.ContentLength) { + return mediaResolution{} + } + reader := io.LimitReader(resp.Body, maxSourceProbeBytes+1) + data, err := io.ReadAll(reader) + if err != nil || int64(len(data)) > maxSourceProbeBytes { + return mediaResolution{} + } + text := strings.TrimSpace(string(data)) + if text == "" { + return mediaResolution{} + } + var decoded any + if json.Unmarshal(data, &decoded) == nil { + if candidate, ok := bestJSONMediaCandidate(decoded, finalURL); ok { + return candidate.Resolution + } + } + if candidate, ok := bestTextMediaCandidate(text, finalURL); ok { + return candidate.Resolution + } + return mediaResolution{} +} + +func canProbeText(contentType string, length int64) bool { + if length > maxSourceProbeBytes { + return false + } + if contentType == "" || strings.Contains(contentType, "json") { + return true + } + return strings.HasPrefix(contentType, "text/") || + strings.Contains(contentType, "javascript") || + strings.Contains(contentType, "xml") || + strings.Contains(contentType, "form") +} + +func bestJSONMediaCandidate(value any, base *url.URL) (mediaCandidate, bool) { + candidates := []mediaCandidate{} + order := 0 + collectJSONMediaCandidates(value, "", base, 0, &order, &candidates) + return bestCandidate(candidates) +} + +func collectJSONMediaCandidates(value any, key string, base *url.URL, depth int, order *int, candidates *[]mediaCandidate) { + switch typed := value.(type) { + case map[string]any: + for childKey, childValue := range typed { + nextKey := childKey + if key != "" { + nextKey = key + "." + childKey + } + collectJSONMediaCandidates(childValue, nextKey, base, depth+1, order, candidates) + } + case []any: + for _, childValue := range typed { + collectJSONMediaCandidates(childValue, key, base, depth+1, order, candidates) + } + case string: + *order = *order + 1 + if candidate, ok := candidateFromString(key, typed, base, depth, *order); ok { + *candidates = append(*candidates, candidate) + } + } +} + +func bestTextMediaCandidate(text string, base *url.URL) (mediaCandidate, bool) { + candidates := []mediaCandidate{} + matches := absoluteURLPattern.FindAllString(text, 30) + for index, match := range matches { + if candidate, ok := candidateFromString("text", strings.TrimRight(match, ".,);]}'\""), base, 0, index+1); ok { + candidates = append(candidates, candidate) + } + } + return bestCandidate(candidates) +} + +func candidateFromString(key, value string, base *url.URL, depth, order int) (mediaCandidate, bool) { + raw := strings.TrimSpace(value) + if raw == "" { + return mediaCandidate{}, false + } + urls := []string{raw} + if !strings.Contains(raw, "://") { + urls = append(urls, absoluteURLPattern.FindAllString(raw, 10)...) + } + for _, candidate := range urls { + resolved, ok := resolveCandidateURL(candidate, base) + if !ok { + continue + } + mediaType := mediaTypeFromURL(resolved) + if mediaType == "" { + continue + } + keyScore := mediaKeyScore(key) + score := 100 + keyScore - depth + return mediaCandidate{ + Resolution: mediaResolution{ + URL: resolved.String(), + Key: key, + MediaType: mediaType, + }, + Score: score, + Depth: depth, + Order: order, + }, true + } + return mediaCandidate{}, false +} + +func resolveCandidateURL(value string, base *url.URL) (*url.URL, bool) { + value = strings.TrimSpace(strings.Trim(value, `"'`)) + if value == "" { + return nil, false + } + parsed, err := url.Parse(value) + if err != nil { + return nil, false + } + if !parsed.IsAbs() { + if base == nil { + return nil, false + } + parsed = base.ResolveReference(parsed) + } + if !isHTTPURL(parsed) { + return nil, false + } + return parsed, true +} + +func bestCandidate(candidates []mediaCandidate) (mediaCandidate, bool) { + if len(candidates) == 0 { + return mediaCandidate{}, false + } + best := candidates[0] + for _, candidate := range candidates[1:] { + if candidate.Score > best.Score || + (candidate.Score == best.Score && candidate.Depth < best.Depth) || + (candidate.Score == best.Score && candidate.Depth == best.Depth && candidate.Order < best.Order) { + best = candidate + } + } + return best, true +} + +func mediaKeyScore(key string) int { + last := key + if index := strings.LastIndex(last, "."); index >= 0 { + last = last[index+1:] + } + normalized := strings.ToLower(strings.TrimSpace(last)) + switch normalized { + case "url", "src", "image", "img", "pic", "cover", "thumbnail", "video", "file", "media": + return 80 + case "href", "poster", "preview", "download", "play", "audio": + return 60 + } + for _, token := range []string{"url", "src", "image", "img", "pic", "cover", "thumb", "video", "file", "media"} { + if strings.Contains(normalized, token) { + return 40 + } + } + return 0 +} + +func looksLikeMediaURL(value *url.URL) bool { + return mediaTypeFromURL(value) != "" +} + +func mediaTypeFromURL(value *url.URL) string { + if value == nil { + return "" + } + extension := strings.ToLower(strings.TrimPrefix(filepath.Ext(value.Path), ".")) + switch extension { + case "jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff": + return "image" + case "mp4", "webm", "m3u8", "mkv", "mov", "m4v", "avi", "wmv": + return "video" + case "mp3", "wav", "flac", "aac", "m4a", "ogg", "wma": + return "audio" + default: + return "" + } +} + +func mediaTypeFromContentType(value string) string { + switch { + case strings.HasPrefix(value, "image/"): + return "image" + case strings.HasPrefix(value, "video/") || value == "application/vnd.apple.mpegurl" || value == "application/x-mpegurl": + return "video" + case strings.HasPrefix(value, "audio/"): + return "audio" + default: + return "" + } +} + +func applyResolvedFields(target map[string]any, message string) { + meta := parseHealthMeta(message) + resolvedURL, _ := meta["resolvedUrl"].(string) + resolvedKey, _ := meta["resolvedKey"].(string) + mediaType, _ := meta["mediaType"].(string) + if strings.TrimSpace(resolvedURL) != "" { + target["resolvedUrl"] = resolvedURL + target["resolved_url"] = resolvedURL + } + if strings.TrimSpace(resolvedKey) != "" { + target["resolvedKey"] = resolvedKey + target["resolved_key"] = resolvedKey + } + if strings.TrimSpace(mediaType) != "" { + target["mediaType"] = mediaType + target["media_type"] = mediaType + } +} + +func atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, 0o640); err != nil { + return err + } + return os.Rename(tmpName, path) +} + func healthMetaMessage(meta map[string]any) string { data, err := json.Marshal(meta) if err != nil { diff --git a/server/unified-management/internal/sources/sources_test.go b/server/unified-management/internal/sources/sources_test.go index 69cb26f..6106f56 100644 --- a/server/unified-management/internal/sources/sources_test.go +++ b/server/unified-management/internal/sources/sources_test.go @@ -2,6 +2,7 @@ package sources import ( "context" + "encoding/json" "net/http" "net/http/httptest" "path/filepath" @@ -119,6 +120,115 @@ func TestSubscribeEventsBroadcastsToAllSubscribers(t *testing.T) { assertEvent("subscriber B", eventsB) } +func TestCheckOneResolvesNestedJSONMediaURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "data": map[string]any{ + "ignored": "https://example.test/readme.txt", + "items": []map[string]any{ + {"name": "first"}, + {"cover": "/media/poster.webp"}, + }, + }, + }) + })) + defer server.Close() + cfg, store := testStore(t) + service := NewService(cfg, store) + item, err := store.UpsertSource(db.Source{ + CategoryID: "image", + CategoryName: "Image", + SourceID: "json-cover", + Name: "JSON Cover", + Method: "GET", + APIURL: server.URL + "/api/random", + TimeoutMS: 3000, + CheckIntervalSec: 300, + Enabled: true, + ClientVisible: true, + }) + if err != nil { + t.Fatal(err) + } + if err := service.CheckOne(context.Background(), item); err != nil { + t.Fatal(err) + } + checked, err := store.GetSourceBySourceID("json-cover") + if err != nil { + t.Fatal(err) + } + meta := parseHealthMeta(checked.LastError) + if meta["resolvedUrl"] != server.URL+"/media/poster.webp" { + t.Fatalf("resolvedUrl = %#v, want relative media URL", meta["resolvedUrl"]) + } + if meta["resolvedKey"] != "data.items.cover" { + t.Fatalf("resolvedKey = %#v", meta["resolvedKey"]) + } + if meta["mediaType"] != "image" { + t.Fatalf("mediaType = %#v, want image", meta["mediaType"]) + } + catalog, err := service.Catalog(false) + if err != nil { + t.Fatal(err) + } + categories := catalog["categories"].([]map[string]any) + sub := categories[0]["subcategories"].([]map[string]any)[0] + if sub["resolvedUrl"] != server.URL+"/media/poster.webp" { + t.Fatalf("catalog resolvedUrl = %#v", sub["resolvedUrl"]) + } + endpoints, err := service.Endpoints(false) + if err != nil { + t.Fatal(err) + } + if endpoints[0]["resolvedUrl"] != server.URL+"/media/poster.webp" { + t.Fatalf("endpoint resolvedUrl = %#v", endpoints[0]["resolvedUrl"]) + } + if endpoints[0]["urlTemplate"] != server.URL+"/api/random" { + t.Fatalf("urlTemplate changed: %#v", endpoints[0]["urlTemplate"]) + } +} + +func TestCheckOneResolvesTextMediaURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(`play: https://cdn.example.test/video/sample.mp4`)) + })) + defer server.Close() + cfg, store := testStore(t) + service := NewService(cfg, store) + item, err := store.UpsertSource(db.Source{ + CategoryID: "video", + CategoryName: "Video", + SourceID: "text-video", + Name: "Text Video", + Method: "GET", + APIURL: server.URL, + TimeoutMS: 3000, + CheckIntervalSec: 300, + Enabled: true, + ClientVisible: true, + }) + if err != nil { + t.Fatal(err) + } + if err := service.CheckOne(context.Background(), item); err != nil { + t.Fatal(err) + } + checked, err := store.GetSourceBySourceID("text-video") + if err != nil { + t.Fatal(err) + } + meta := parseHealthMeta(checked.LastError) + if meta["resolvedUrl"] != "https://cdn.example.test/video/sample.mp4" { + t.Fatalf("resolvedUrl = %#v", meta["resolvedUrl"]) + } + if meta["mediaType"] != "video" { + t.Fatalf("mediaType = %#v, want video", meta["mediaType"]) + } +} + func testStore(t *testing.T) (*config.Config, *db.Store) { t.Helper() dir := t.TempDir() diff --git a/server/unified-management/internal/web/admin_feedback_routes.go b/server/unified-management/internal/web/admin_feedback_routes.go index 5344932..5c72550 100644 --- a/server/unified-management/internal/web/admin_feedback_routes.go +++ b/server/unified-management/internal/web/admin_feedback_routes.go @@ -117,10 +117,21 @@ func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "comment": comment}) return } + if req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/feedbacks/") && strings.HasSuffix(path, "/mail/retry") { + code := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/feedbacks/"), "/mail/retry") + if err := r.feedback.RetryMail(code); err != nil { + writeError(w, http.StatusBadGateway, "MAIL_RETRY_FAILED", err) + return + } + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "feedback.mail.retry", Target: code, Message: "反馈邮件已重试发送", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } if req.Method == http.MethodPatch && strings.HasPrefix(path, "/api/admin/feedbacks/") { code := strings.TrimPrefix(path, "/api/admin/feedbacks/") var body struct { Status string `json:"status"` + Priority string `json:"priority"` StatusDetail string `json:"statusDetail"` PublicReply string `json:"publicReply"` } @@ -128,7 +139,7 @@ func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request) writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) return } - if err := r.store.UpdateFeedbackTicket(code, db.FeedbackUpdate{Status: firstNonEmpty(body.Status, "new"), StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Actor: "admin"}); err != nil { + if err := r.store.UpdateFeedbackTicket(code, db.FeedbackUpdate{Status: firstNonEmpty(body.Status, "new"), Priority: body.Priority, StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Actor: "admin"}); err != nil { writeError(w, http.StatusInternalServerError, "FEEDBACK_UPDATE_FAILED", err) return } diff --git a/server/unified-management/internal/web/admin_legacy_routes.go b/server/unified-management/internal/web/admin_legacy_routes.go index 78883dd..bf46c23 100644 --- a/server/unified-management/internal/web/admin_legacy_routes.go +++ b/server/unified-management/internal/web/admin_legacy_routes.go @@ -50,6 +50,9 @@ func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) { if name == "media-types" { _ = r.sources.ImportLegacyMediaTypes(req.Context()) } + if name == "update-info" { + r.syncNoticeFromLegacyUpdateInfo(req, doc.Raw) + } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc}) return } @@ -83,8 +86,18 @@ func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) { if name == "media-types" { _ = r.sources.ImportLegacyMediaTypes(req.Context()) } + if name == "update-info" { + r.syncNoticeFromLegacyUpdateInfo(req, doc.Raw) + } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc}) return } http.NotFound(w, req) } + +func (r *router) syncNoticeFromLegacyUpdateInfo(req *http.Request, raw string) { + if r.notices == nil { + return + } + _ = r.notices.SyncFromLegacyUpdateInfo(req.Context(), raw, "admin") +} diff --git a/server/unified-management/internal/web/admin_source_routes.go b/server/unified-management/internal/web/admin_source_routes.go index d9ea229..d852731 100644 --- a/server/unified-management/internal/web/admin_source_routes.go +++ b/server/unified-management/internal/web/admin_source_routes.go @@ -56,13 +56,17 @@ func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) { writeError(w, http.StatusInternalServerError, "SOURCE_SAVE_FAILED", err) return } + _ = r.sources.PublishLegacyMediaTypes(req.Context(), "admin") + _ = r.releases.PublishLegacyUpdateInfo(req, "admin") + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "source.saved", Target: saved.SourceID, Message: "客户端接口已保存并同步兼容 media-types.json", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": saved}) case req.Method == http.MethodDelete && strings.HasPrefix(path, "/api/admin/sources/"): sourceID := strings.TrimPrefix(path, "/api/admin/sources/") - if err := r.store.DeleteSource(sourceID); err != nil { + if err := r.sources.DeleteSourceAndPublishCompatibility(req.Context(), sourceID, "admin"); err != nil { writeError(w, http.StatusInternalServerError, "SOURCE_DELETE_FAILED", err) return } + _ = r.releases.PublishLegacyUpdateInfo(req, "admin") writeJSON(w, http.StatusOK, map[string]any{"ok": true}) default: http.NotFound(w, req) diff --git a/server/unified-management/internal/web/admin_system_routes.go b/server/unified-management/internal/web/admin_system_routes.go index e135684..91f324a 100644 --- a/server/unified-management/internal/web/admin_system_routes.go +++ b/server/unified-management/internal/web/admin_system_routes.go @@ -4,38 +4,57 @@ import ( "encoding/json" "errors" "net/http" + "path/filepath" + "strconv" "time" "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" "ymhut-box/server/unified-management/internal/health" + feedbackmail "ymhut-box/server/unified-management/internal/mail" ) func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) { path := cleanPath(req.URL.Path) switch { + case req.Method == http.MethodGet && path == "/api/admin/database/config": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)}) case req.Method == http.MethodGet && path == "/api/admin/database/status": - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status()}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)}) case req.Method == http.MethodPost && path == "/api/admin/database/test": - var body config.DatabaseConfig - if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true) + if err != nil { writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) return } - if body.Provider == "" { - body.Provider = r.cfg.Database.Provider - } - if body.SQLitePath == "" { - body.SQLitePath = r.cfg.Database.SQLitePath - } - if body.MySQLDSN == "" { - body.MySQLDSN = r.cfg.Database.MySQLDSN - } if err := db.TestDatabase(body); err != nil { writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err) return } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": config.SafeDatabase(r.cfg.BaseDir, body)}) + case req.Method == http.MethodPost && path == "/api/admin/database/save": + body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) + return + } + if err := db.TestDatabase(body); err != nil { + writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err) + return + } + next := *r.cfg + next.Database = body + if err := config.Save(&next); err != nil { + writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err) + return + } + r.cfg.Database = next.Database + if err := r.store.ReconfigureDatabase(r.cfg); err != nil { + writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err) + return + } + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.database.saved", Target: body.Provider, Message: "数据库配置已保存并热切换", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)}) case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite": result, err := r.store.ImportSQLiteToRemote() if err != nil { @@ -55,6 +74,52 @@ func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) { } } +type adminDatabaseRequest struct { + Provider string `json:"provider"` + SQLitePath string `json:"sqlite_path"` + SQLitePathAlt string `json:"sqlitePath"` + MySQLDSN string `json:"mysql_dsn"` + MySQLDSNAlt string `json:"mysqlDsn"` + MySQLHost string `json:"mysql_host"` + MySQLHostAlt string `json:"mysqlHost"` + MySQLPort int `json:"mysql_port"` + MySQLPortAlt int `json:"mysqlPort"` + MySQLDatabase string `json:"mysql_database"` + MySQLDBAlt string `json:"mysqlDatabase"` + MySQLUser string `json:"mysql_user"` + MySQLUserAlt string `json:"mysqlUser"` + MySQLPassword string `json:"mysql_password"` + MySQLPassAlt string `json:"mysqlPassword"` + MySQL config.MySQLInput `json:"mysql"` +} + +func decodeAdminDatabaseConfig(req *http.Request, baseDir string, current config.DatabaseConfig, keepPassword bool) (config.DatabaseConfig, error) { + var body adminDatabaseRequest + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + return config.DatabaseConfig{}, err + } + incoming := config.DatabaseConfig{ + Provider: body.Provider, + SQLitePath: firstNonEmpty(body.SQLitePath, body.SQLitePathAlt), + MySQLDSN: firstNonEmpty(body.MySQLDSN, body.MySQLDSNAlt), + MySQLHost: firstNonEmpty(body.MySQLHost, body.MySQLHostAlt, body.MySQL.Host), + MySQLPort: firstPositive(body.MySQLPort, body.MySQLPortAlt, body.MySQL.Port), + MySQLDatabase: firstNonEmpty(body.MySQLDatabase, body.MySQLDBAlt, body.MySQL.Database), + MySQLUser: firstNonEmpty(body.MySQLUser, body.MySQLUserAlt, body.MySQL.Username), + MySQLPassword: firstNonEmpty(body.MySQLPassword, body.MySQLPassAlt, body.MySQL.Password), + } + return config.NormalizeDatabase(baseDir, current, incoming, keepPassword) +} + +func firstPositive(values ...int) int { + for _, value := range values { + if value > 0 { + return value + } + } + return 0 +} + func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request) { path := cleanPath(req.URL.Path) if req.Method != http.MethodGet || path != "/api/admin/dashboard/overview" { @@ -141,12 +206,26 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) { case "/api/admin/system/health": writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store)) case "/api/admin/system/audit": - items, err := r.store.ListAuditLogs(100) + page, err := r.store.ListAuditLogsPage(db.AuditFilters{ + Page: queryInt(req, "page", 1), + PerPage: queryInt(req, "perPage", 35), + Type: req.URL.Query().Get("type"), + Target: req.URL.Query().Get("target"), + Query: req.URL.Query().Get("q"), + }) if err != nil { writeError(w, http.StatusInternalServerError, "AUDIT_FAILED", err) return } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": page.Items, "page": page}) + case "/api/admin/system/mail/config": + r.handleMailConfig(w, req) + case "/api/admin/system/mail/test": + r.handleMailTest(w, req) + case "/api/admin/system/branding": + r.handleBranding(w, req) + case "/api/admin/system/migration": + r.handleMigrationStatus(w, req) case "/api/admin/system/database/sync": if req.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required")) @@ -162,3 +241,188 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) { http.NotFound(w, req) } } + +func queryInt(req *http.Request, key string, fallback int) int { + value, err := strconv.Atoi(req.URL.Query().Get(key)) + if err != nil || value <= 0 { + return fallback + } + return value +} + +func (r *router) handleMigrationStatus(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required")) + return + } + status := r.store.Status() + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "migration": map[string]any{ + "strategy": "database_first_with_file_assets", + "databaseCovers": []string{ + "系统设置与品牌", + "管理员与会话元数据", + "反馈工单、附件元数据与邮件记录", + "来源目录、客户端接口与健康记录", + "发布元数据、版本公告与兼容 JSON 修订", + "审计日志、旧项目同步记录与数据库同步状态", + }, + "fileAssets": []map[string]string{ + {"name": "downloads", "path": r.cfg.DownloadsDir, "description": "发布包和下载文件"}, + {"name": "update public", "path": r.cfg.UpdatePublicDir, "description": "旧客户端兼容 JSON 生成物"}, + {"name": "feedback packages", "path": filepath.Join(r.cfg.StorageDir, "feedback-packages"), "description": "反馈附件包"}, + }, + "sqlitePath": r.store.Path(), + "mysql": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database), + "lastSyncAt": status.LastSyncAt, + "lastSyncError": status.LastSyncError, + "activeProvider": status.ActiveProvider, + }, + }) +} + +func (r *router) handleBranding(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())}) + case http.MethodPost: + var body struct { + SiteIconURL string `json:"siteIconUrl"` + SiteIconURLSnake string `json:"site_icon_url"` + DeveloperAvatarURL string `json:"developerAvatarUrl"` + DeveloperAvatarAlt string `json:"developer_avatar_url"` + DeveloperName string `json:"developerName"` + DeveloperNameSnake string `json:"developer_name"` + FeedbackEmail string `json:"feedbackEmail"` + FeedbackEmailSnake string `json:"feedback_email"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) + return + } + next := config.BrandingConfig{ + SiteIconURL: firstNonEmpty(body.SiteIconURL, body.SiteIconURLSnake), + DeveloperAvatarURL: firstNonEmpty(body.DeveloperAvatarURL, body.DeveloperAvatarAlt), + DeveloperName: firstNonEmpty(body.DeveloperName, body.DeveloperNameSnake), + FeedbackEmail: firstNonEmpty(body.FeedbackEmail, body.FeedbackEmailSnake), + } + if err := r.saveBranding(next); err != nil { + writeError(w, http.StatusInternalServerError, "BRANDING_SAVE_FAILED", err) + return + } + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.branding.saved", Target: r.cfg.Branding.DeveloperName, Message: "站点品牌信息已保存", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())}) + default: + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET or POST required")) + } +} + +func (r *router) handleMailConfig(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": feedbackmail.SafeConfig(r.cfg.Mail)}) + case http.MethodPost: + nextMail, err := decodeMailConfig(req, r.cfg.Mail) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) + return + } + next := *r.cfg + next.Mail = nextMail + if err := config.Save(&next); err != nil { + writeError(w, http.StatusInternalServerError, "MAIL_CONFIG_FAILED", err) + return + } + r.cfg.Mail = next.Mail + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.mail.saved", Target: nextMail.Host, Message: "邮件通知配置已保存", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": feedbackmail.SafeConfig(r.cfg.Mail)}) + default: + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET or POST required")) + } +} + +func (r *router) handleMailTest(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required")) + return + } + message, err := feedbackmail.BuildTestMessage(r.cfg) + if err != nil { + writeError(w, http.StatusBadRequest, "MAIL_TEST_FAILED", err) + return + } + if err := feedbackmail.Send(r.cfg, message); err != nil { + writeError(w, http.StatusBadGateway, "MAIL_TEST_FAILED", err) + return + } + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.mail.test", Target: message.To, Message: "测试邮件已发送", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +type mailConfigRequest struct { + Host string `json:"host"` + Port int `json:"port"` + Secure string `json:"secure"` + Username string `json:"username"` + Password string `json:"password"` + FromAddress string `json:"from_address"` + FromAddressAlt string `json:"fromAddress"` + FromName string `json:"from_name"` + FromNameAlt string `json:"fromName"` + DeveloperAddress string `json:"developer_address"` + DeveloperAlt string `json:"developerAddress"` + TimeoutSeconds int `json:"timeout_seconds"` + TimeoutAlt int `json:"timeoutSeconds"` +} + +func decodeMailConfig(req *http.Request, current config.MailConfig) (config.MailConfig, error) { + var body mailConfigRequest + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + return config.MailConfig{}, err + } + next := current + if body.Host != "" { + next.Host = body.Host + } + if body.Port > 0 { + next.Port = body.Port + } + if body.Secure != "" { + next.Secure = body.Secure + } + if body.Username != "" { + next.Username = body.Username + } + if body.Password != "" { + next.Password = body.Password + } + if value := firstNonEmpty(body.FromAddress, body.FromAddressAlt); value != "" { + next.FromAddress = value + } + if value := firstNonEmpty(body.FromName, body.FromNameAlt); value != "" { + next.FromName = value + } + if value := firstNonEmpty(body.DeveloperAddress, body.DeveloperAlt); value != "" { + next.DeveloperAddress = value + } + if timeout := firstPositive(body.TimeoutSeconds, body.TimeoutAlt); timeout > 0 { + next.TimeoutSeconds = timeout + } + if next.Port <= 0 { + next.Port = 465 + } + if next.Secure == "" { + next.Secure = "ssl" + } + if next.FromName == "" { + next.FromName = "YMhut Box Feedback" + } + if next.FromAddress == "" { + next.FromAddress = next.Username + } + if next.TimeoutSeconds <= 0 { + next.TimeoutSeconds = 20 + } + return next, nil +} diff --git a/server/unified-management/internal/web/branding.go b/server/unified-management/internal/web/branding.go new file mode 100644 index 0000000..b1722b9 --- /dev/null +++ b/server/unified-management/internal/web/branding.go @@ -0,0 +1,42 @@ +package web + +import ( + "encoding/json" + + "ymhut-box/server/unified-management/internal/config" +) + +const brandingSettingKey = "branding" + +func (r *router) effectiveBranding() config.BrandingConfig { + branding := config.NormalizeBranding(config.BrandingConfig{}, r.cfg.Branding) + if r.store == nil { + return branding + } + raw, err := r.store.GetSetting(brandingSettingKey) + if err != nil || raw == "" { + return branding + } + var stored config.BrandingConfig + if json.Unmarshal([]byte(raw), &stored) != nil { + return branding + } + return config.NormalizeBranding(branding, stored) +} + +func (r *router) saveBranding(branding config.BrandingConfig) error { + branding = config.NormalizeBranding(r.cfg.Branding, branding) + next := *r.cfg + next.Branding = branding + next.Mail.DeveloperAddress = firstNonEmpty(next.Mail.DeveloperAddress, branding.FeedbackEmail) + if err := config.Save(&next); err != nil { + return err + } + r.cfg.Branding = next.Branding + r.cfg.Mail = next.Mail + data, err := json.Marshal(r.cfg.Branding) + if err != nil { + return err + } + return r.store.UpsertSetting(brandingSettingKey, string(data)) +} diff --git a/server/unified-management/internal/web/client_routes.go b/server/unified-management/internal/web/client_routes.go index 3d949db..2df7d21 100644 --- a/server/unified-management/internal/web/client_routes.go +++ b/server/unified-management/internal/web/client_routes.go @@ -42,6 +42,7 @@ func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) "release": release, "sources": sourceCatalog, "feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"}, + "branding": config.SafeBranding(r.effectiveBranding()), "health": health.Snapshot(r.cfg, r.store), }) } diff --git a/server/unified-management/internal/web/response.go b/server/unified-management/internal/web/response.go index 875a413..1f5cb3f 100644 --- a/server/unified-management/internal/web/response.go +++ b/server/unified-management/internal/web/response.go @@ -60,10 +60,18 @@ func localizedErrorMessage(code, message string) string { "database is not available": "数据库当前不可用", "provider must be sqlite or mysql": "数据库类型必须是 SQLite 或 MySQL", "mysql connection is required": "请填写 MySQL 连接信息", + "mysql database is required": "请填写 MySQL 数据库名", + "mysql username is required": "请填写 MySQL 数据库用户", "sqlite path is required": "请填写 SQLite 路径", "mysql_dsn is required": "请填写 MySQL DSN", + "remote database is not configured": "远端 MySQL 未配置", + "database sync is already running": "数据库同步正在执行,请稍后再试", + "mail is not configured": "邮件通知尚未配置完整", "release notices are not configured": "版本日志功能尚未配置", "legacy sync service is not configured": "旧项目同步服务尚未配置", + "update-info requires app_version or title": "更新 JSON 需要填写 app_version 或 title", + "media-types requires categories array": "媒体源 JSON 需要包含 categories 数组", + "version or app_version is required": "版本日志需要填写 version 或 app_version", } if translated, ok := exact[lower]; ok { return translated @@ -74,6 +82,7 @@ func localizedErrorMessage(code, message string) string { "PASSWORD_CHANGE_FAILED": "密码修改失败", "INVALID_PAYLOAD": "提交内容格式不正确", "DATABASE_TEST_FAILED": "数据库连接测试失败", + "DATABASE_SAVE_FAILED": "数据库配置保存失败", "DATABASE_IMPORT_FAILED": "SQLite 导入远端库失败", "DATABASE_SYNC_FAILED": "远端库同步回本地失败", "LEGACY_SAVE_FAILED": "兼容 JSON 保存失败", @@ -97,6 +106,9 @@ func localizedErrorMessage(code, message string) string { "AUDIT_FAILED": "审计日志加载失败", "FEEDBACK_LIST_FAILED": "反馈列表加载失败", "FEEDBACK_UPDATE_FAILED": "反馈工单更新失败", + "MAIL_CONFIG_FAILED": "邮件配置保存失败", + "MAIL_TEST_FAILED": "测试邮件发送失败", + "MAIL_RETRY_FAILED": "反馈邮件重试失败", "NOTICE_NOT_FOUND": "未找到版本日志", "NOTICES_FAILED": "版本日志加载失败", "MEDIA_TYPES_FAILED": "媒体源 JSON 加载失败", diff --git a/server/unified-management/internal/web/router_test.go b/server/unified-management/internal/web/router_test.go index 2ea6fa8..ca01973 100644 --- a/server/unified-management/internal/web/router_test.go +++ b/server/unified-management/internal/web/router_test.go @@ -130,12 +130,111 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) { } continue } + if path == "/api/client/bootstrap" { + branding, ok := payload["branding"].(map[string]any) + if !ok { + t.Fatalf("bootstrap missing branding: %#v", payload) + } + if branding["developerName"] != "YMhut" || branding["feedbackEmail"] != "support@ymhut.cn" { + t.Fatalf("unexpected branding defaults: %#v", branding) + } + } if payload["ok"] != true { t.Fatalf("%s missing ok=true: %#v", path, payload) } } } +func TestAdminDeleteSourcePublishesCompatibilityJSON(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + session, csrf, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodDelete, "/api/admin/sources/demo", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + req.Header.Set("X-CSRF-Token", csrf) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("delete source returned %d: %s", res.Code, res.Body.String()) + } + + mediaReq := httptest.NewRequest(http.MethodGet, "/media-types.json", nil) + mediaRes := httptest.NewRecorder() + handler.ServeHTTP(mediaRes, mediaReq) + if mediaRes.Code != http.StatusOK { + t.Fatalf("media-types returned %d: %s", mediaRes.Code, mediaRes.Body.String()) + } + if strings.Contains(mediaRes.Body.String(), `"demo"`) { + t.Fatalf("deleted source leaked into media-types.json: %s", mediaRes.Body.String()) + } + + updateReq := httptest.NewRequest(http.MethodGet, "/update-info.json", nil) + updateRes := httptest.NewRecorder() + handler.ServeHTTP(updateRes, updateReq) + if updateRes.Code != http.StatusOK { + t.Fatalf("update-info returned %d: %s", updateRes.Code, updateRes.Body.String()) + } + var updatePayload map[string]any + if err := json.Unmarshal(updateRes.Body.Bytes(), &updatePayload); err != nil { + t.Fatal(err) + } + assertJSONKeys(t, "update-info after source delete", updatePayload, []string{"app_version", "manifest_version", "packages", "modules"}) +} + +func TestAdminAuditPaginationAndBranding(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + session, csrf, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + + for i := 0; i < 40; i++ { + body := strings.NewReader(`{"developerName":"YMhut","feedbackEmail":"support@ymhut.cn"}`) + req := httptest.NewRequest(http.MethodPost, "/api/admin/system/branding", body) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + req.Header.Set("X-CSRF-Token", csrf) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("branding save %d returned %d: %s", i, res.Code, res.Body.String()) + } + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/system/audit?page=1&perPage=35&type=system.branding.saved", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("audit returned %d: %s", res.Code, res.Body.String()) + } + var payload struct { + Items []any `json:"items"` + Page struct { + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"perPage"` + } `json:"page"` + } + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Page.Page != 1 || payload.Page.PerPage != 35 { + t.Fatalf("unexpected audit page metadata: %#v", payload.Page) + } + if payload.Page.Total < 40 { + t.Fatalf("expected at least 40 branding audit records, got %d", payload.Page.Total) + } + if len(payload.Items) > 35 { + t.Fatalf("expected at most 35 audit items, got %d", len(payload.Items)) + } +} + func TestLegacyFeedbackStatusDTOContract(t *testing.T) { payload := legacyFeedbackStatus(db.Feedback{ Code: "FB-20260626-ABCDEF", @@ -419,6 +518,80 @@ func TestAdminLegacyRequiresAuth(t *testing.T) { } } +func TestAdminLegacyUpdateInfoSyncsReleaseNotice(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + + session, csrf, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + body, _ := json.Marshal(map[string]string{ + "raw": `{"app_version":"2.0.7.5","title":"YMhut Box 2.0.7.5","message":"随机放映室优化","release_notes":"修复图片源和全屏预览"}`, + }) + req := httptest.NewRequest(http.MethodPut, "/api/admin/legacy/update-info", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", csrf) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("save update-info returned %d: %s", res.Code, res.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/admin/releases/notices", nil) + listReq.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + listRes := httptest.NewRecorder() + handler.ServeHTTP(listRes, listReq) + if listRes.Code != http.StatusOK { + t.Fatalf("notice list returned %d: %s", listRes.Code, listRes.Body.String()) + } + var payload struct { + Items []struct { + Version string `json:"version"` + Title string `json:"title"` + } `json:"items"` + } + if err := json.Unmarshal(listRes.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + found := false + for _, item := range payload.Items { + if item.Version == "2.0.7.5" && item.Title == "YMhut Box 2.0.7.5" { + found = true + } + } + if !found { + t.Fatalf("synced release notice not found: %#v", payload.Items) + } +} + +func TestAdminLegacyValidationErrorIsChinese(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + + session, csrf, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + body, _ := json.Marshal(map[string]string{"raw": `{"message":"missing version and title"}`}) + req := httptest.NewRequest(http.MethodPost, "/api/admin/legacy/update-info/validate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", csrf) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusBadRequest { + t.Fatalf("expected validation failure, got %d: %s", res.Code, res.Body.String()) + } + if strings.Contains(res.Body.String(), "update-info requires app_version or title") { + t.Fatalf("english validation leaked: %s", res.Body.String()) + } + if !strings.Contains(res.Body.String(), "更新 JSON 需要填写 app_version 或 title") { + t.Fatalf("missing chinese validation message: %s", res.Body.String()) + } +} + func TestAdminWriteRequiresCSRF(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() @@ -599,9 +772,12 @@ func testRouter(t *testing.T) (http.Handler, func()) { }) mustWriteJSON(t, filepath.Join(noticeDir, "2.0.0.json"), map[string]any{"app_version": "2.0.0", "title": "YMhut Box 2.0.0", "release_notes": "Initial release", "release_notes_md": "## Initial"}) cfg := &config.Config{ + BaseDir: root, + ConfigPath: filepath.Join(root, "config.json"), Listen: ":0", BaseURL: "https://update.ymhut.cn", StorageDir: filepath.Join(root, "storage"), + DataDir: filepath.Join(root, "data"), UpdatePublicDir: public, UpdateNoticeDir: noticeDir, DownloadsDir: filepath.Join(public, "downloads"), diff --git a/server/unified-management/internal/web/setup.go b/server/unified-management/internal/web/setup.go index e09a0af..ff86a6b 100644 --- a/server/unified-management/internal/web/setup.go +++ b/server/unified-management/internal/web/setup.go @@ -3,11 +3,8 @@ package web import ( "encoding/json" "errors" - "fmt" "net/http" - "net/url" "path/filepath" - "strconv" "strings" "time" @@ -24,18 +21,7 @@ type setupRequest struct { BaseURL string `json:"baseUrl"` SQLitePath string `json:"sqlitePath"` MySQLDSN string `json:"mysqlDsn"` - MySQL setupMySQLConfig `json:"mysql"` -} - -type setupMySQLConfig struct { - Host string `json:"host"` - Port int `json:"port"` - Database string `json:"database"` - Username string `json:"username"` - Password string `json:"password"` - Charset string `json:"charset"` - ParseTime bool `json:"parseTime"` - TLS string `json:"tls"` + MySQL config.MySQLInput `json:"mysql"` } func NewSetupRouter(cfg *config.Config) http.Handler { @@ -73,7 +59,7 @@ func (r *setupRouter) status() map[string]any { "defaults": map[string]any{ "provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"), "sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.SQLitePath), - "mysqlDsn": maskDSN(r.cfg.Database.MySQLDSN), + "mysqlDsn": config.MaskDSN(r.cfg.Database.MySQLDSN), "baseUrl": r.cfg.BaseURL, }, } @@ -103,7 +89,7 @@ func (r *setupRouter) handleDatabaseTest(w http.ResponseWriter, req *http.Reques "provider": next.Provider, "baseUrl": firstNonEmpty(body.BaseURL, r.cfg.BaseURL), "sqlitePath": relativeToBase(r.cfg.BaseDir, next.SQLitePath), - "mysqlDsn": maskDSN(next.MySQLDSN), + "mysqlDsn": config.MaskDSN(next.MySQLDSN), }, }) } @@ -153,44 +139,18 @@ func (r *setupRouter) decodeSetupDatabase(req *http.Request) (config.DatabaseCon if err := json.NewDecoder(req.Body).Decode(&body); err != nil { return config.DatabaseConfig{}, body, err } - next := r.cfg.Database - next.Provider = strings.ToLower(strings.TrimSpace(firstNonEmpty(body.Provider, next.Provider, "sqlite"))) - if body.SQLitePath != "" { - next.SQLitePath = body.SQLitePath + incoming := config.DatabaseConfig{ + Provider: body.Provider, + SQLitePath: body.SQLitePath, + MySQLDSN: body.MySQLDSN, + MySQLHost: body.MySQL.Host, + MySQLPort: body.MySQL.Port, + MySQLDatabase: body.MySQL.Database, + MySQLUser: body.MySQL.Username, + MySQLPassword: body.MySQL.Password, } - if next.SQLitePath != "" && !filepath.IsAbs(next.SQLitePath) && !strings.HasPrefix(strings.ToLower(next.SQLitePath), "file:") { - next.SQLitePath = filepath.Join(r.cfg.BaseDir, next.SQLitePath) - } - if next.Provider == "sqlite" { - next.MySQLDSN = "" - } else if body.MySQLDSN != "" { - next.MySQLDSN = body.MySQLDSN - } else if body.MySQL.Host != "" || body.MySQL.Database != "" || body.MySQL.Username != "" { - dsn, err := buildMySQLDSN(body.MySQL) - if err != nil { - return config.DatabaseConfig{}, body, err - } - next.MySQLDSN = dsn - } - if next.Provider != "sqlite" && next.Provider != "mysql" { - return config.DatabaseConfig{}, body, errors.New("provider must be sqlite or mysql") - } - if next.Provider == "mysql" && strings.TrimSpace(next.MySQLDSN) == "" { - return config.DatabaseConfig{}, body, errors.New("mysql connection is required") - } - if next.MaxOpenConns <= 0 { - next.MaxOpenConns = 10 - } - if next.MaxIdleConns <= 0 { - next.MaxIdleConns = 4 - } - if next.ConnMaxLifetimeSeconds <= 0 { - next.ConnMaxLifetimeSeconds = 300 - } - if next.HealthIntervalSec <= 0 { - next.HealthIntervalSec = 30 - } - return next, body, nil + next, err := config.NormalizeDatabase(r.cfg.BaseDir, r.cfg.Database, incoming, false) + return next, body, err } func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) { @@ -205,48 +165,9 @@ func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(`
Setup frontend is not built. Run npm install && npm run build in web/setup.
` + index + `