更新 update 门户站点界面和后台功能
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-27 18:09:11 +08:00
parent 2513eb2903
commit 962a2f2143
56 changed files with 4564 additions and 714 deletions
@@ -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
}
@@ -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) {
@@ -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
}
@@ -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
@@ -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
}
@@ -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 {
@@ -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
@@ -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"`
+195 -184
View File
@@ -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)`,
@@ -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
}
@@ -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)
@@ -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()
@@ -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])
}
}
@@ -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) {
@@ -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"
@@ -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: "<p>这是一封来自 unified-management 的测试通知。</p><p>时间:" + htmlEscape(now) + "</p>",
}, 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 := `<h2>YMhut Box 反馈工单</h2><table cellpadding="8" cellspacing="0" border="1" style="border-collapse:collapse">`
for _, row := range rows {
html += `<tr><th align="left">` + htmlEscape(row[0]) + "</th><td>" + strings.ReplaceAll(htmlEscape(row[1]), "\n", "<br>") + "</td></tr>"
}
html += "</table>"
html += `<h3>正文</h3><p style="white-space:pre-wrap">` + htmlEscape(record.Body) + "</p>"
html += `<h3>反馈包摘要</h3><pre style="white-space:pre-wrap">` + htmlEscape(record.SummaryText) + "</pre>"
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, "&", "&amp;")
value = strings.ReplaceAll(value, "<", "&lt;")
value = strings.ReplaceAll(value, ">", "&gt;")
value = strings.ReplaceAll(value, `"`, "&quot;")
return strings.ReplaceAll(value, "'", "&#39;")
}
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 ""
}
@@ -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"
@@ -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)
@@ -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")
@@ -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 {
@@ -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()
@@ -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
}
@@ -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")
}
@@ -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)
@@ -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
}
@@ -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))
}
@@ -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),
})
}
@@ -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 加载失败",
@@ -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"),
+15 -94
View File
@@ -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(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>YMhut Setup</title></head><body><main><h1>YMhut Setup</h1><p>Setup frontend is not built. Run npm install && npm run build in web/setup.</p><p>` + index + `</p></main></body></html>`))
}
func buildMySQLDSN(input setupMySQLConfig) (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 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 maskedDatabaseTarget(base string, cfg config.DatabaseConfig) string {
if strings.EqualFold(cfg.Provider, "mysql") {
return maskDSN(cfg.MySQLDSN)
return config.MaskDSN(cfg.MySQLDSN)
}
return relativeToBase(base, cfg.SQLitePath)
}