Smail/frontend/src/views/Setup/Setup.js
smail 8b72f56ace
Some checks are pending
Security Checks / security (push) Waiting to run
移除部署域名和初始化账号默认值
2026-10-01 13:52:13 +08:00

797 lines
21 KiB
JavaScript

import './Setup.js'
import {computed, onUnmounted, reactive, ref} from 'vue'
import {ElMessage} from 'element-plus'
import lang from '../../i18n/i18n';
import axios from 'axios'
import {Plus} from '@element-plus/icons-vue'
import {http} from "@/utils/axios";
import {buildFinalURL as buildSetupFinalURL, probeService} from './SetupNavigation.js'
let setupBootstrapPromise = null
const ensureSetupSession = () => {
if (!import.meta.env.DEV) {
return Promise.resolve()
}
if (!setupBootstrapPromise) {
setupBootstrapPromise = http.get("/__smail_setup_bootstrap").catch((error) => {
setupBootstrapPromise = null
if (error?.response?.status === 500 || !error?.response) {
throw new Error(lang.setup_service_unavailable)
}
throw error
})
}
return setupBootstrapPromise
}
const setupRequest = (payload) => ensureSetupSession().then(() => http.post("/api/v1/setup", payload)).then((response) => {
if (!response || typeof response.errorNo !== "number") {
throw new Error(lang.setup_failed)
}
return response
})
const getSetupErrorMessage = (error, fallback = lang.setup_failed) => {
const message = typeof error?.message === "string" ? error.message.trim() : ""
if (message && message !== "Network Error" && !/^timeout of \d+ms exceeded$/i.test(message)) {
return message
}
return fallback
}
const showSetupError = (error, fallback = lang.setup_failed) => {
const message = getSetupErrorMessage(error, fallback)
ElMessage.error(message)
return message
}
const assertSetupSuccess = (response, fallback = lang.setup_failed) => {
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || fallback)
}
return response
}
export function usePage() {
const waitDesc = ref(lang.wait_desc);
const adminSettings = reactive({
"account": "",
"password": "",
"password2": "",
"hadSeted": false
})
const dbSettings = reactive({
"type": "sqlite",
"dsn": "./data/smail.db",
"mode": "fields",
"user": "",
"host": "127.0.0.1",
"port": "3306",
"password": "",
"name": "smail"
})
const domainSettings = reactive({
"web_domain": "",
"smtp_domain": "",
"public_https_port": "",
"multi_domain": []
})
const sslSettings = reactive({
"type": "0",
"challenge": "",
"key_path": "./data/ssl/private.key",
"crt_path": "./data/ssl/public.crt",
"paramsList": [],
})
const active = ref(0)
const fullscreenLoading = ref(false)
const databaseLoading = ref(false)
const stepLoading = 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)
const dnsChallengeNotice = ref("")
const dnsInfos = ref({})
const dnsMailIPv4 = ref('')
const dnsAppliedIPv4 = ref('')
const dnsLoading = ref(false)
const dnsError = ref('')
const dnsInputPending = computed(() => dnsMailIPv4.value.trim() !== dnsAppliedIPv4.value)
const hasUnavailableDNS = computed(() => Object.values(dnsInfos.value).some((records) =>
records.some((record) => record.unavailableReason || !record.value)
))
const hasDNSIPv6 = computed(() => Object.values(dnsInfos.value).some((records) =>
records.some((record) => record.type === 'AAAA')
))
const port = ref(80)
const sslRuntime = ref({})
const proxyHTTPS = computed(() => sslRuntime.value.tlsMode === 'proxy')
const addDomain = () => {
domainSettings.multi_domain.push([])
}
const setPassword = () => {
if (adminSettings.hadSeted) {
return getDomainConfig().then((response) => {
if (response) {
active.value++
}
})
}
const passwordSize = new TextEncoder().encode(adminSettings.password).length
if (!adminSettings.account.trim()) {
ElMessage.error(lang.err_account_required)
return
}
if (!adminSettings.password) {
ElMessage.error(lang.err_required_pwd)
return
}
if (passwordSize < 8 || passwordSize > 12) {
ElMessage.error(lang.err_admin_pwd_length)
return
}
if (adminSettings.password !== adminSettings.password2) {
ElMessage.error(lang.err_pwd_diff)
return
}
return setupRequest({
"action": "set",
"step": "password",
"account": adminSettings.account,
"password": adminSettings.password
}).then((res) => {
assertSetupSuccess(res)
return getDomainConfig().then((response) => {
if (response) {
active.value++
}
})
}).catch((error) => {
showSetupError(error)
})
}
const getPassword = () => setupRequest({"action": "get", "step": "password"}).then((res) => {
assertSetupSuccess(res, lang.db_init_failed)
adminSettings.hadSeted = res.data != ""
if (adminSettings.hadSeted) {
adminSettings.account = res.data
adminSettings.password = "*******"
adminSettings.password2 = "*******"
}
return res
})
const getDbConfig = () => {
return setupRequest({"action": "get", "step": "database"}).then((res) => {
assertSetupSuccess(res, lang.db_request_failed)
dbSettings.type = res.data.db_type;
dbSettings.mode = res.data.db_mode || "dsn";
dbSettings.dsn = res.data.db_dsn || "";
dbSettings.user = res.data.db_user || "";
dbSettings.host = res.data.db_host || "127.0.0.1";
dbSettings.port = res.data.db_port || (dbSettings.type === "postgres" ? "5432" : "3306");
dbSettings.password = "";
dbSettings.name = res.data.db_name || "smail";
return res
}).catch((error) => {
databaseError.value = getDatabaseErrorMessage(error)
ElMessage.error(databaseError.value)
return null
})
}
const getDomainConfig = () => {
return setupRequest({"action": "get", "step": "domain"}).then((res) => {
assertSetupSuccess(res)
domainSettings.web_domain = res.data.web_domain;
domainSettings.smtp_domain = res.data.smtp_domain;
domainSettings.public_https_port = String(res.data.public_https_port || "")
domainSettings.multi_domain = Array.isArray(res.data.domains) ? res.data.domains : []
return res
}).catch((error) => {
showSetupError(error)
return null
})
}
const setDbConfig = () => {
if (databaseLoading.value) {
return
}
databaseError.value = ""
if (dbSettings.type === "sqlite" && !dbSettings.dsn) {
dbSettings.dsn = "./data/smail.db";
}
const usesStructuredFields = dbSettings.type === "mysql" || dbSettings.type === "postgres"
if (usesStructuredFields) {
if (!dbSettings.user.trim()) {
ElMessage.error(lang.db_user_required)
return
}
if (!dbSettings.host.trim()) {
ElMessage.error(lang.db_host_required)
return
}
if (!dbSettings.port.trim()) {
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
}
} else if (!dbSettings.dsn.trim()) {
ElMessage.error(lang.err_db_dsn_empty)
return
}
databaseLoading.value = true
const payload = {
"action": "set",
"step": "database",
"db_type": dbSettings.type,
"db_dsn": dbSettings.dsn,
"db_mode": usesStructuredFields ? "fields" : "dsn",
"db_user": dbSettings.user,
"db_host": dbSettings.host,
"db_port": dbSettings.port,
"db_password": dbSettings.password,
"db_name": dbSettings.name
}
return setupRequest(payload).then((res) => {
assertSetupSuccess(res, lang.db_init_failed)
return 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") {
dbSettings.mode = "fields"
dbSettings.host = "127.0.0.1"
dbSettings.port = type === "postgres" ? "5432" : "3306"
dbSettings.name = "smail"
} else {
dbSettings.mode = "dsn"
dbSettings.dsn = "./data/smail.db"
dbSettings.host = "127.0.0.1"
dbSettings.port = ""
dbSettings.name = ""
}
}
const getDNSConfig = async () => {
if (dnsLoading.value) return null
dnsLoading.value = true
dnsError.value = ''
const mailIPv4 = dnsMailIPv4.value.trim()
try {
const res = await setupRequest({action: 'get', step: 'dns', mail_ipv4: mailIPv4})
assertSetupSuccess(res)
dnsInfos.value = res.data
dnsAppliedIPv4.value = mailIPv4
return res
} catch (error) {
dnsInfos.value = {}
dnsError.value = showSetupError(error)
return null
} finally {
dnsLoading.value = false
}
}
const detectDNSConfig = () => {
dnsMailIPv4.value = ''
return getDNSConfig()
}
const copyDNSRecord = (record) => {
if (dnsLoading.value || dnsInputPending.value || record.unavailableReason || !record.value) return
return copyDNSValue(record.value)
}
const copyDNSValue = async (value) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value)
} else {
const input = document.createElement('textarea')
input.value = value
input.setAttribute('readonly', '')
input.style.position = 'fixed'
input.style.opacity = '0'
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
}
ElMessage.success(lang.dns_copy_success)
} catch (error) {
ElMessage.error(lang.dns_copy_failed)
}
}
const clearDatabaseError = () => {
databaseError.value = ""
}
const formatDNSRecordTTL = (ttl) => {
if (ttl >= 3600 && ttl % 3600 === 0) {
return `${ttl / 3600}${lang.dns_ttl_hour}`
}
if (ttl >= 60 && ttl % 60 === 0) {
return `${ttl / 60}${lang.dns_ttl_minute}`
}
return `${ttl}${lang.dns_ttl_second}`
}
const getSSLConfig = () => {
return setupRequest({"action": "get", "step": "ssl"}).then((res) => {
assertSetupSuccess(res)
sslSettings.type = res.data.tlsMode === 'proxy' ? '1' : res.data.type
if (sslSettings.type === "2") {
sslSettings.type = "0"
sslSettings.challenge = "dns"
}
if (sslSettings.type === "0") {
sslSettings.challenge = sslSettings.challenge || "http"
}
sslRuntime.value = res.data || {}
port.value = res.data?.port || res.data?.httpPort || 80
return res
}).catch((error) => {
showSetupError(error)
return null
})
}
const setSSLConfig = () => {
fullscreenLoading.value = true;
let sslType = proxyHTTPS.value ? '1' : sslSettings.type;
if (sslType === "0" && sslSettings.challenge === "dns") {
sslType = "2"
}
return setupRequest({
"action": "set",
"step": "ssl",
"ssl_type": sslType,
"key_path": sslSettings.key_path,
"crt_path": sslSettings.crt_path
}).then((res) => {
assertSetupSuccess(res)
if (sslType === "2") {
fullscreenLoading.value = false
dnsChecking.value = true
dnsChallengeNotice.value = ""
return getSSLDNSParams()
}
if (sslType === "1") {
window.location.href = buildFinalURL(sslRuntime.value)
return res
}
checkStatus()
return res
}).catch((error) => {
fullscreenLoading.value = false
showSetupError(error)
return null
})
}
const buildFinalURL = (sslData) => buildSetupFinalURL(sslData, domainSettings.web_domain)
let statusTimer = null
let dnsParamsTimer = null
let setupViewUnmounted = false
let statusCheckRunning = false
let statusCheckStartedAt = 0
const statusCheckTimeout = 10 * 60 * 1000
const dnsVerificationTimeout = 30 * 60 * 1000
const dnsChallengeTimerDelay = 30 * 60 * 1000
const stopStatusCheck = () => {
if (statusTimer !== null) {
window.clearTimeout(statusTimer)
statusTimer = null
}
statusCheckRunning = false
statusCheckStartedAt = 0
}
const stopDNSParamsPolling = () => {
if (dnsParamsTimer !== null) {
window.clearTimeout(dnsParamsTimer)
dnsParamsTimer = null
}
}
let dnsChallengeTimer = null
const stopDNSChallengeTimer = () => {
if (dnsChallengeTimer !== null) {
window.clearTimeout(dnsChallengeTimer)
dnsChallengeTimer = null
}
}
onUnmounted(() => {
setupViewUnmounted = true
stopStatusCheck()
stopDNSParamsPolling()
stopDNSChallengeTimer()
})
const navigateToFormalService = (sslData) => {
stopStatusCheck()
stopDNSChallengeTimer()
fullscreenLoading.value = false
const target = buildFinalURL(sslData)
if (target && target !== window.location.href) {
window.location.href = target
}
}
const scheduleStatusCheck = () => {
if (statusTimer !== null) {
window.clearTimeout(statusTimer)
}
statusTimer = window.setTimeout(() => {
statusTimer = null
checkStatus()
}, 1000)
}
const checkStatus = async () => {
if (statusCheckRunning) {
return
}
if (!statusCheckStartedAt) {
statusCheckStartedAt = Date.now()
}
const currentStatusCheckTimeout = dnsChecking.value && sslSettings.challenge === "dns"
? dnsVerificationTimeout
: statusCheckTimeout
if (Date.now() - statusCheckStartedAt > currentStatusCheckTimeout) {
stopStatusCheck()
fullscreenLoading.value = false
if (dnsChecking.value && sslSettings.challenge === "dns") {
refreshDNSChallenge()
} else {
ElMessage.error(lang.setup_failed)
}
return
}
statusCheckRunning = true
let sslData = sslRuntime.value || {}
try {
const response = await setupRequest({"action": "get", "step": "ssl"})
if (response.errorNo === 0) {
sslData = response.data || sslData
sslRuntime.value = sslData
if (sslData.ready) {
navigateToFormalService(sslData)
return
}
}
} catch (error) {
// 服务切换期间失败属于预期状态。
} finally {
statusCheckRunning = false
}
if (await probeService(sslData, domainSettings.web_domain)) {
navigateToFormalService(sslData)
return
}
scheduleStatusCheck()
}
const setDomainConfig = () => {
const publicHTTPSPort = String(domainSettings.public_https_port || "").trim()
if (publicHTTPSPort && (!/^[1-9]\d{0,4}$/.test(publicHTTPSPort) || Number(publicHTTPSPort) > 65535)) {
ElMessage.error("公网 HTTPS 端口必须是 1 到 65535 的整数")
return
}
return setupRequest({
"action": "set",
"step": "domain",
"web_domain": domainSettings.web_domain,
"public_https_port": publicHTTPSPort,
"smtp_domain": domainSettings.smtp_domain,
"multi_domain": domainSettings.multi_domain.join(",")
}).then((res) => {
assertSetupSuccess(res)
return getDNSConfig().then((response) => {
if (response) {
active.value++
}
})
}).catch((error) => {
showSetupError(error)
})
}
const getSSLDNSParams = () => {
return setupRequest({"action": "getParams", "step": "ssl"}).then((res) => {
assertSetupSuccess(res)
sslSettings.paramsList = Array.isArray(res.data) ? res.data : []
if (!setupViewUnmounted && dnsChecking.value && sslSettings.paramsList.length === 0) {
stopDNSParamsPolling()
dnsParamsTimer = window.setTimeout(() => {
dnsParamsTimer = null
getSSLDNSParams()
}, 1000)
} else if (sslSettings.paramsList.length > 0 || !dnsChecking.value) {
stopDNSParamsPolling()
if (sslSettings.paramsList.length > 0 && dnsChecking.value) {
scheduleDNSChallengeRefresh()
}
}
return res
}).catch((error) => {
stopDNSParamsPolling()
showSetupError(error)
return null
})
}
const scheduleDNSChallengeRefresh = () => {
stopDNSChallengeTimer()
if (setupViewUnmounted || !dnsChecking.value) {
return
}
dnsChallengeTimer = window.setTimeout(() => {
dnsChallengeTimer = null
refreshDNSChallenge()
}, dnsChallengeTimerDelay)
}
const getDNSRefreshNotice = () => `${lang.ssl_dns_ready}(记录已刷新,请使用最新记录。)`
const refreshDNSChallenge = () => {
if (setupViewUnmounted || !dnsChecking.value || fullscreenLoading.value) {
return
}
stopStatusCheck()
stopDNSChallengeTimer()
stopDNSParamsPolling()
fullscreenLoading.value = true
return setupRequest({
"action": "set",
"step": "ssl",
"ssl_type": "2",
"key_path": sslSettings.key_path,
"crt_path": sslSettings.crt_path
}).then((res) => {
assertSetupSuccess(res)
sslSettings.paramsList = []
dnsChallengeNotice.value = getDNSRefreshNotice()
return getSSLDNSParams()
}).then((res) => {
ElMessage.warning(getDNSRefreshNotice())
return res
}).catch((error) => {
showSetupError(error)
return null
}).finally(() => {
fullscreenLoading.value = false
})
}
const resetDNSChallenge = () => {
stopStatusCheck()
stopDNSChallengeTimer()
stopDNSParamsPolling()
dnsChecking.value = false
dnsChallengeNotice.value = ""
sslSettings.paramsList = []
fullscreenLoading.value = false
}
const handleSSLTypeChange = () => {
if (proxyHTTPS.value) {
sslSettings.type = '1'
}
if (dnsChecking.value) {
resetDNSChallenge()
}
if (sslSettings.type === "0") {
sslSettings.challenge = ""
}
}
const handleSSLChallengeChange = (challenge) => {
if (fullscreenLoading.value || proxyHTTPS.value) {
return
}
if (challenge === "dns") {
dnsChallengeNotice.value = ""
return setSSLConfig()
}
if (dnsChecking.value) {
resetDNSChallenge()
}
}
const runStep = (action) => {
if (stepLoading.value) {
return
}
stepLoading.value = true
return Promise.resolve()
.then(action)
.finally(() => {
stepLoading.value = false
})
}
const next = () => {
if (databaseLoading.value || stepLoading.value || fullscreenLoading.value) {
return
}
switch (active.value) {
case 0:
runStep(() => getDbConfig().then((response) => {
if (response) {
active.value++
}
}))
break
case 1:
setDbConfig()
break
case 2:
runStep(setPassword)
break
case 3:
runStep(setDomainConfig)
break
case 4:
runStep(() => getSSLConfig().then((response) => {
if (response) {
active.value++
}
}))
break
case 5:
if (dnsChecking.value) {
stopDNSChallengeTimer()
fullscreenLoading.value = true
waitDesc.value = lang.dns_challenge_wait
checkStatus()
} else {
runStep(setSSLConfig)
}
break
}
}
const previous = () => {
if (active.value > 0) {
if (active.value === 5) {
stopStatusCheck()
stopDNSChallengeTimer()
stopDNSParamsPolling()
dnsChecking.value = false
dnsChallengeNotice.value = ""
sslSettings.paramsList = []
fullscreenLoading.value = false
}
active.value--
}
}
return {
reactive,
ref,
ElMessage,
lang,
axios,
Plus,
http,
waitDesc,
adminSettings,
dbSettings,
databaseLoading,
stepLoading,
databaseError,
domainSettings,
sslSettings,
active,
fullscreenLoading,
dnsChecking,
dnsChallengeNotice,
port,
proxyHTTPS,
dnsInfos,
dnsMailIPv4,
dnsAppliedIPv4,
dnsLoading,
dnsError,
dnsInputPending,
hasUnavailableDNS,
detectDNSConfig,
hasDNSIPv6,
addDomain,
setPassword,
getPassword,
getDbConfig,
getDomainConfig,
setDbConfig,
handleDbTypeChange,
handleSSLTypeChange,
clearDatabaseError,
getDNSConfig,
formatDNSRecordTTL,
copyDNSRecord,
copyDNSValue,
getSSLConfig,
setSSLConfig,
checkStatus,
setDomainConfig,
getSSLDNSParams,
handleSSLChallengeChange,
next,
previous
}
}