This commit is contained in:
parent
29cfebd620
commit
0b10489728
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -33,40 +33,66 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert
|
||||
v-if="databaseError"
|
||||
:title="databaseError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="database-error"
|
||||
/>
|
||||
|
||||
<div v-if="dbSettings.type === 'mysql' || dbSettings.type === 'postgres'" class="mysql-fields">
|
||||
<div class="database-hint">{{ dbSettings.type === 'mysql' ? lang.mysql_create_desc : lang.postgres_create_desc }}</div>
|
||||
<div class="database-hint">
|
||||
{{ dbSettings.type === 'mysql' ? lang.mysql_create_desc : lang.postgres_create_desc }}
|
||||
</div>
|
||||
|
||||
|
||||
<el-form-item :label="lang.db_user">
|
||||
<el-input v-model="dbSettings.user" :placeholder="dbSettings.type === 'mysql' ? lang.db_user_ph : lang.pg_user_ph"
|
||||
<el-input v-model="dbSettings.user" @input="clearDatabaseError"
|
||||
:disabled="databaseLoading"
|
||||
:placeholder="dbSettings.type === 'mysql' ? lang.db_user_ph : lang.pg_user_ph"
|
||||
autocomplete="username"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<div class="database-inline-fields">
|
||||
<el-form-item :label="lang.db_host" class="database-host-field">
|
||||
<el-input v-model="dbSettings.host" :placeholder="lang.db_host_ph"></el-input>
|
||||
<el-input v-model="dbSettings.host" @input="clearDatabaseError"
|
||||
:disabled="databaseLoading"
|
||||
:placeholder="lang.db_host_ph"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="lang.db_port" class="database-port-field">
|
||||
<el-input v-model="dbSettings.port" :placeholder="lang.db_port_ph"
|
||||
<el-input v-model="dbSettings.port" @input="clearDatabaseError"
|
||||
:disabled="databaseLoading"
|
||||
:placeholder="lang.db_port_ph"
|
||||
inputmode="numeric"></el-input>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item :label="lang.db_name">
|
||||
<el-input v-model="dbSettings.name" :placeholder="lang.db_name_ph"></el-input>
|
||||
<el-input v-model="dbSettings.name" @input="clearDatabaseError"
|
||||
:disabled="databaseLoading"
|
||||
:placeholder="lang.db_name_ph"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="lang.db_password">
|
||||
<el-input v-model="dbSettings.password" type="password"
|
||||
<el-input v-model="dbSettings.password" @input="clearDatabaseError"
|
||||
:disabled="databaseLoading"
|
||||
type="password"
|
||||
:placeholder="dbSettings.type === 'mysql' ? lang.db_password_ph : lang.pg_password_ph" autocomplete="new-password"
|
||||
show-password></el-input>
|
||||
<div class="database-field-tip">{{ lang.db_password_desc }}</div>
|
||||
</el-form-item>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="database-retry-tip">{{ lang.db_init_retry_tip }}</div>
|
||||
|
||||
<el-form-item :label="lang.sqlite_db_path" v-if="dbSettings.type === 'sqlite'">
|
||||
<el-input v-model="dbSettings.dsn" placeholder="请输入 SQLite 数据文件路径"></el-input>
|
||||
<el-input v-model="dbSettings.dsn" @input="clearDatabaseError"
|
||||
:disabled="databaseLoading"
|
||||
placeholder="请输入 SQLite 数据文件路径"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
@ -308,7 +334,8 @@
|
||||
<el-button v-if="active > 0" id="previous" @click="previous">
|
||||
{{ lang.previous }}
|
||||
</el-button>
|
||||
<el-button :loading="databaseLoading" :element-loading-text="waitDesc"
|
||||
<el-button :loading="databaseLoading" :disabled="databaseLoading"
|
||||
:element-loading-text="waitDesc"
|
||||
v-loading.fullscreen.lock="fullscreenLoading" id="next"
|
||||
@click="next">
|
||||
{{ 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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user