@@ -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"`
|
||||
|
||||
@@ -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, "&", "&")
|
||||
value = strings.ReplaceAll(value, "<", "<")
|
||||
value = strings.ReplaceAll(value, ">", ">")
|
||||
value = strings.ReplaceAll(value, `"`, """)
|
||||
return strings.ReplaceAll(value, "'", "'")
|
||||
}
|
||||
|
||||
func mimeAddress(address, name string) string {
|
||||
if name == "" {
|
||||
return address
|
||||
}
|
||||
return mime.BEncoding.Encode("UTF-8", name) + " <" + extractEmail(address) + ">"
|
||||
}
|
||||
|
||||
func extractEmail(value string) string {
|
||||
re := regexp.MustCompile(`<([^>]+)>`)
|
||||
if match := re.FindStringSubmatch(value); len(match) == 2 {
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func wrapBase64(data []byte) string {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
var builder strings.Builder
|
||||
for len(encoded) > 76 {
|
||||
builder.WriteString(encoded[:76])
|
||||
builder.WriteString("\r\n")
|
||||
encoded = encoded[76:]
|
||||
}
|
||||
builder.WriteString(encoded)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func randomish() string {
|
||||
return strings.ReplaceAll(fmt.Sprintf("%d", time.Now().UnixNano()), "-", "")
|
||||
}
|
||||
|
||||
func truncate(value string, max int) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= max {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user