From 0b10489728486373738ce39d290ca550d26295fe Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 30 Aug 2026 19:40:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E5=A4=B1=E8=B4=A5=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fe/src/i18n/i18n.js | 2 + fe/src/views/Setup/Setup.css | 11 ++++ fe/src/views/Setup/Setup.js | 73 ++++++++++++++++------- fe/src/views/Setup/Setup.vue | 45 +++++++++++--- server/controllers/setup.go | 3 +- server/services/setup/db.go | 57 +++++++++++++++++- server/services/setup/db_database_test.go | 16 +++++ 7 files changed, 175 insertions(+), 32 deletions(-) diff --git a/fe/src/i18n/i18n.js b/fe/src/i18n/i18n.js index 03224de..c5a5d24 100644 --- a/fe/src/i18n/i18n.js +++ b/fe/src/i18n/i18n.js @@ -89,6 +89,8 @@ const lang = { "db_name_required": "请输入数据库名", "db_init_success": "数据库连接成功,数据库和数据表已初始化", "db_init_failed": "数据库初始化失败,请检查连接信息和数据库权限", + "db_init_retry_tip": "连接失败不会跳转登录页,请修正信息后重新点击下一步。", + "db_request_failed": "数据库初始化请求失败,请检查 Smail 服务状态后重试", "pg_user_ph": "请输入 PostgreSQL 用户名", "pg_password_ph": "请输入 PostgreSQL 密码", "pg_dsn": "PostgreSQL DSN", diff --git a/fe/src/views/Setup/Setup.css b/fe/src/views/Setup/Setup.css index 4274c8b..461437d 100644 --- a/fe/src/views/Setup/Setup.css +++ b/fe/src/views/Setup/Setup.css @@ -116,6 +116,17 @@ line-height: 1.7; } +.database-error { + margin: 0 0 18px; +} + +.database-retry-tip { + margin: -4px 0 8px 120px; + color: var(--pm-text-secondary); + font-size: 12px; + line-height: 1.5; +} + .database-inline-fields { display: grid; grid-template-columns: minmax(0, 1fr) 180px; diff --git a/fe/src/views/Setup/Setup.js b/fe/src/views/Setup/Setup.js index 19061d7..e6b20a3 100644 --- a/fe/src/views/Setup/Setup.js +++ b/fe/src/views/Setup/Setup.js @@ -52,6 +52,16 @@ export function usePage() { const fullscreenLoading = ref(false) const databaseLoading = ref(false) + + const databaseError = ref("") + + const getDatabaseErrorMessage = (error) => { + const message = typeof error?.message === "string" ? error.message.trim() : "" + if (message && !/^network error$/i.test(message) && !/^timeout of \d+ms exceeded$/i.test(message) && !/^request failed with status code \d+$/i.test(message)) { + return message + } + return lang.db_request_failed + } const dnsChecking = ref(false) @@ -109,22 +119,19 @@ export function usePage() { }) } - const getPassword = () => { - http.post("/api/v1/setup", {"action": "get", "step": "password"}).then((res) => { - if (res.errorNo !== 0) { - ElMessage.error(res.errorMsg) - } else { - adminSettings.hadSeted = res.data !== "" - if (adminSettings.hadSeted) { - adminSettings.account = res.data - adminSettings.password = "*******" - adminSettings.password2 = "*******" - } - - } - }) - } - + const getPassword = () => http.post("/api/v1/setup", {"action": "get", "step": "password"}).then((res) => { + if (res.errorNo !== 0) { + throw new Error(res.errorMsg || lang.db_init_failed) + } + + adminSettings.hadSeted = res.data != "" + if (adminSettings.hadSeted) { + adminSettings.account = res.data + adminSettings.password = "*******" + adminSettings.password2 = "*******" + } + }) + const getDbConfig = () => { http.post("/api/v1/setup", {"action": "get", "step": "database"}).then((res) => { if (res.errorNo !== 0) { @@ -155,6 +162,11 @@ export function usePage() { } const setDbConfig = () => { + if (databaseLoading.value) { + return + } + databaseError.value = "" + if (dbSettings.type === "sqlite" && !dbSettings.dsn) { dbSettings.dsn = "./config/smail.db"; } @@ -173,6 +185,10 @@ export function usePage() { ElMessage.error(lang.db_port_required) return } + if (!/^\d+$/.test(dbSettings.port.trim()) || Number(dbSettings.port) < 1 || Number(dbSettings.port) > 65535) { + ElMessage.error("数据库端口必须是 1 到 65535 之间的数字") + return + } if (!dbSettings.name.trim()) { ElMessage.error(lang.db_name_required) return @@ -198,20 +214,27 @@ export function usePage() { http.post("/api/v1/setup", payload).then((res) => { if (res.errorNo !== 0) { - ElMessage.error(res.errorMsg || lang.db_init_failed) + databaseError.value = res.errorMsg || lang.db_init_failed + ElMessage.error(databaseError.value) return } - ElMessage.success(lang.db_init_success) - active.value++; - getPassword(); - }).catch(() => { - ElMessage.error(lang.db_init_failed) + getPassword().then(() => { + ElMessage.success(lang.db_init_success) + active.value++ + }).catch((error) => { + databaseError.value = getDatabaseErrorMessage(error) + ElMessage.error(databaseError.value) + }) + }).catch((error) => { + databaseError.value = getDatabaseErrorMessage(error) + ElMessage.error(databaseError.value) }).finally(() => { databaseLoading.value = false }) } const handleDbTypeChange = (type) => { + databaseError.value = "" dbSettings.dsn = "" dbSettings.password = "" if (type === "mysql" || type === "postgres") { @@ -259,6 +282,10 @@ export function usePage() { } } + const clearDatabaseError = () => { + databaseError.value = "" + } + const formatDNSRecordTTL = (ttl) => { if (ttl >= 3600 && ttl % 3600 === 0) { return `${ttl / 3600}${lang.dns_ttl_hour}` @@ -413,6 +440,7 @@ export function usePage() { adminSettings, dbSettings, databaseLoading, + databaseError, domainSettings, sslSettings, active, @@ -428,6 +456,7 @@ export function usePage() { getDomainConfig, setDbConfig, handleDbTypeChange, + clearDatabaseError, getDNSConfig, formatDNSRecordTTL, copyDNSValue, diff --git a/fe/src/views/Setup/Setup.vue b/fe/src/views/Setup/Setup.vue index ae5e6cf..37a5767 100644 --- a/fe/src/views/Setup/Setup.vue +++ b/fe/src/views/Setup/Setup.vue @@ -33,40 +33,66 @@ + +
-
{{ dbSettings.type === 'mysql' ? lang.mysql_create_desc : lang.postgres_create_desc }}
+
+ {{ dbSettings.type === 'mysql' ? lang.mysql_create_desc : lang.postgres_create_desc }} +
+ -
- + -
- + -
{{ lang.db_password_desc }}
+
+
{{ lang.db_init_retry_tip }}
- + @@ -308,7 +334,8 @@ {{ lang.previous }} - {{ dnsChecking ? lang.ssl_verify : lang.next }} @@ -326,6 +353,7 @@ const { lang, dbSettings, databaseLoading, + databaseError, adminSettings, domainSettings, addDomain, @@ -338,6 +366,7 @@ const { next, previous, handleDbTypeChange, + clearDatabaseError, formatDNSRecordTTL, copyDNSValue, fullscreenLoading diff --git a/server/controllers/setup.go b/server/controllers/setup.go index 5166b96..1ef17fe 100644 --- a/server/controllers/setup.go +++ b/server/controllers/setup.go @@ -46,7 +46,8 @@ type sslResponse struct { } func writeSetupError(ctx *context.Context, w http.ResponseWriter, err error) { - log.WithContext(ctx).WithError(err).Error("setup request failed") + // 保留底层错误原因,便于定位数据库驱动、网络和权限问题;响应仍只返回安全的业务提示。 + log.WithContext(ctx).WithError(err).Errorf("setup request failed: %v", err) message := setup.DatabaseSetupErrorMessage(err) if message == "" { message = "\u521d\u59cb\u5316\u64cd\u4f5c\u5931\u8d25" diff --git a/server/services/setup/db.go b/server/services/setup/db.go index a379e64..7633184 100644 --- a/server/services/setup/db.go +++ b/server/services/setup/db.go @@ -212,6 +212,10 @@ func SetDatabaseSettingsWithOptions(ctx *context.Context, settings DatabaseSetti return errors.New("dbtype error") } + // 兼容未携带模式字段的旧调用:只要提交了结构化数据库字段,就按字段模式处理。 + if strings.TrimSpace(settings.Mode) == "" && (dbType == config.DBTypeMySQL || dbType == config.DBTypePostgres) { + settings.Mode = "fields" + } dbDSN := strings.TrimSpace(settings.DSN) if settings.Mode == "fields" { switch dbType { @@ -278,11 +282,17 @@ func buildPostgresDSN(settings DatabaseSettings, previousDSN string) (string, er if user == "" { return "", errors.New("PostgreSQL 用户不能为空") } + if !validDatabaseField(user, 128) { + return "", errors.New("PostgreSQL 用户格式无效") + } host := strings.TrimSpace(settings.Host) if host == "" { host = "127.0.0.1" } host = normalizeDatabaseHost(host) + if !validDatabaseHost(host) { + return "", errors.New("PostgreSQL 主机地址格式无效") + } portText := strings.TrimSpace(settings.Port) if portText == "" { portText = "5432" @@ -392,11 +402,17 @@ func buildMySQLDSN(settings DatabaseSettings, previousDSN string) (string, error if user == "" { return "", errors.New("MySQL 用户不能为空") } + if !validDatabaseField(user, 128) { + return "", errors.New("MySQL 用户格式无效") + } host := strings.TrimSpace(settings.Host) if host == "" { host = "127.0.0.1" } host = normalizeMySQLHost(host) + if !validDatabaseHost(host) { + return "", errors.New("MySQL 主机地址格式无效") + } portText := strings.TrimSpace(settings.Port) if portText == "" { portText = "3306" @@ -424,7 +440,8 @@ func buildMySQLDSN(settings DatabaseSettings, previousDSN string) (string, error dsnConfig.Addr = net.JoinHostPort(host, strconv.Itoa(port)) dsnConfig.DBName = databaseName dsnConfig.ParseTime = true - dsnConfig.Loc = time.Local + // 使用跨平台稳定的时区,避免 Windows 交叉编译后的 CST 在 Linux 上无法解析。 + dsnConfig.Loc = time.UTC dsnConfig.Params = map[string]string{"charset": "utf8mb4"} return dsnConfig.FormatDSN(), nil } @@ -460,6 +477,44 @@ func normalizeMySQLHost(host string) string { return host } +func validDatabaseField(value string, maxLength int) bool { + if value == "" || len(value) > maxLength { + return false + } + for _, char := range value { + if char == '\r' || char == '\n' || char == '\t' || char == ' ' { + return false + } + } + return true +} + +func validDatabaseHost(host string) bool { + if host == "" || len(host) > 253 { + return false + } + if strings.ContainsAny(host, "\r\n\t /\\?@#") { + return false + } + if net.ParseIP(host) != nil { + return true + } + if strings.Contains(host, ":") { + return false + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return false + } + for _, char := range label { + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' { + return false + } + } + } + return true +} + func validMySQLIdentifier(value string) bool { if len(value) == 0 || len(value) > 64 { return false diff --git a/server/services/setup/db_database_test.go b/server/services/setup/db_database_test.go index 21f43d9..0146326 100644 --- a/server/services/setup/db_database_test.go +++ b/server/services/setup/db_database_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/go-sql-driver/mysql" "github.com/lib/pq" @@ -75,6 +76,9 @@ func TestBuildMySQLDSNUsesStructuredFields(t *testing.T) { if parsed.DBName != "smail_1" || !parsed.ParseTime { t.Fatalf("unexpected database options: name=%q parseTime=%v", parsed.DBName, parsed.ParseTime) } + if parsed.Loc != time.UTC { + t.Fatalf("unexpected database location: %v", parsed.Loc) + } if !strings.Contains(dsn, "charset=utf8mb4") { t.Fatalf("charset option is missing from DSN: %q", dsn) } @@ -210,6 +214,7 @@ func TestBuildMySQLDSNRejectsInvalidInput(t *testing.T) { {User: "root", Port: "0", Name: "smail"}, {User: "root", Port: "65536", Name: "smail"}, {User: "root", Port: "3306", Name: "smail-test"}, + {User: "root", Host: "mysql server", Port: "3306", Name: "smail"}, {Port: "3306", Name: "smail"}, } for _, settings := range tests { @@ -219,6 +224,17 @@ func TestBuildMySQLDSNRejectsInvalidInput(t *testing.T) { } } +func TestBuildMySQLDSNAcceptsDatabaseServiceHost(t *testing.T) { + if _, err := buildMySQLDSN(DatabaseSettings{ + User: "root", + Host: "mysql", + Port: "3306", + Name: "smail", + }, ""); err != nil { + t.Fatalf("database service host was rejected: %v", err) + } +} + func TestEnsureMySQLDatabaseSkipsCreationWhenDatabaseExists(t *testing.T) { target := &fakeSetupDB{} openCalls := 0