merge: add pre-release install/update selection

This commit is contained in:
Sora39831 2026-04-02 21:22:55 +08:00
commit 9cfb747666
8 changed files with 261 additions and 56 deletions

View file

@ -871,21 +871,60 @@ config_after_install() {
${xui_folder}/x-ui migrate ${xui_folder}/x-ui migrate
} }
get_releases() {
local releases_json
releases_json=$(curl -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases")
if [[ -z "$releases_json" ]]; then
echo -e "${yellow}正在尝试通过 IPv4 获取版本...${plain}"
releases_json=$(curl -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases")
if [[ -z "$releases_json" ]]; then
echo -e "${red}获取 x-ui 版本失败,可能是 GitHub API 限制,请稍后重试${plain}"
exit 1
fi
fi
# Parse first non-prerelease tag_name
latest_stable=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":false' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
# Parse first prerelease tag_name
latest_prerelease=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":true' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ -z "$latest_stable" && -z "$latest_prerelease" ]]; then
echo -e "${red}获取 x-ui 版本失败${plain}"
exit 1
fi
}
select_version() {
if [[ -n "$latest_stable" && -n "$latest_prerelease" ]]; then
echo ""
echo -e "${green}请选择要安装的版本:${plain}"
echo -e "${green}1)${plain} 最新稳定版: ${latest_stable}"
echo -e "${green}2)${plain} 最新预发布版: ${latest_prerelease}"
read -rp "请输入选择 [1-2]: " version_choice
while [[ "$version_choice" != "1" && "$version_choice" != "2" ]]; do
read -rp "无效输入,请重新输入 [1-2]: " version_choice
done
if [[ "$version_choice" == "1" ]]; then
tag_version="$latest_stable"
else
tag_version="$latest_prerelease"
fi
elif [[ -n "$latest_stable" ]]; then
tag_version="$latest_stable"
else
tag_version="$latest_prerelease"
fi
}
install_x-ui() { install_x-ui() {
cd ${xui_folder%/x-ui}/ cd ${xui_folder%/x-ui}/
# 下载资源 # 下载资源
if [ $# == 0 ]; then if [ $# == 0 ]; then
tag_version=$(curl -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') get_releases
if [[ ! -n "$tag_version" ]]; then select_version
echo -e "${yellow}正在尝试通过 IPv4 获取版本...${plain}" echo -e "获取到 x-ui 版本:${tag_version},开始安装..."
tag_version=$(curl -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ ! -n "$tag_version" ]]; then
echo -e "${red}获取 x-ui 版本失败,可能是 GitHub API 限制,请稍后重试${plain}"
exit 1
fi
fi
echo -e "获取到 x-ui 最新版本:${tag_version},开始安装..."
curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/Sora39831/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/Sora39831/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
if [[ $? -ne 0 ]]; then if [[ $? -ne 0 ]]; then
echo -e "${red}下载 x-ui 失败,请确保服务器可以访问 GitHub${plain}" echo -e "${red}下载 x-ui 失败,请确保服务器可以访问 GitHub${plain}"

View file

@ -745,6 +745,47 @@ config_after_update() {
fi fi
} }
get_releases() {
local releases_json
releases_json=$(${curl_bin} -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases" 2>/dev/null)
if [[ -z "$releases_json" ]]; then
echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
releases_json=$(${curl_bin} -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases" 2>/dev/null)
if [[ -z "$releases_json" ]]; then
_fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later"
fi
fi
latest_stable=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":false' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
latest_prerelease=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":true' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ -z "$latest_stable" && -z "$latest_prerelease" ]]; then
_fail "ERROR: Failed to fetch x-ui version"
fi
}
select_version() {
if [[ -n "$latest_stable" && -n "$latest_prerelease" ]]; then
echo ""
echo -e "${green}Which version do you want to update to?${plain}"
echo -e "${green}1)${plain} Latest Stable: ${latest_stable}"
echo -e "${green}2)${plain} Latest Pre-release: ${latest_prerelease}"
read -rp "Please enter your choice [1-2]: " version_choice
while [[ "$version_choice" != "1" && "$version_choice" != "2" ]]; do
read -rp "Invalid input, please re-enter [1-2]: " version_choice
done
if [[ "$version_choice" == "1" ]]; then
tag_version="$latest_stable"
else
tag_version="$latest_prerelease"
fi
elif [[ -n "$latest_stable" ]]; then
tag_version="$latest_stable"
else
tag_version="$latest_prerelease"
fi
}
update_x-ui() { update_x-ui() {
cd ${xui_folder%/x-ui}/ cd ${xui_folder%/x-ui}/
@ -757,15 +798,9 @@ update_x-ui() {
echo -e "${green}Downloading new x-ui version...${plain}" echo -e "${green}Downloading new x-ui version...${plain}"
tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') get_releases
if [[ ! -n "$tag_version" ]]; then select_version
echo -e "${yellow}Trying to fetch version with IPv4...${plain}" echo -e "Got x-ui version: ${tag_version}, beginning the installation..."
tag_version=$(${curl_bin} -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ ! -n "$tag_version" ]]; then
_fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later"
fi
fi
echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/Sora39831/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2>/dev/null ${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/Sora39831/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2>/dev/null
if [[ $? -ne 0 ]]; then if [[ $? -ne 0 ]]; then
echo -e "${yellow}Trying to fetch version with IPv4...${plain}" echo -e "${yellow}Trying to fetch version with IPv4...${plain}"

View file

@ -44,6 +44,7 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.POST("/login", a.login) g.POST("/login", a.login)
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable) g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
g.POST("/getTurnstileSiteKey", a.getTurnstileSiteKey)
} }
// index handles the root route, redirecting logged-in users to the panel or showing the login page. // index handles the root route, redirecting logged-in users to the panel or showing the login page.
@ -131,3 +132,11 @@ func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
jsonObj(c, status, nil) jsonObj(c, status, nil)
} }
} }
// getTurnstileSiteKey returns the Cloudflare Turnstile site key for the registration form.
func (a *IndexController) getTurnstileSiteKey(c *gin.Context) {
siteKey, err := a.settingService.GetTurnstileSiteKey()
if err == nil {
jsonObj(c, siteKey, nil)
}
}

View file

@ -103,7 +103,9 @@ type AllSetting struct {
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"` LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"` LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"` LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
// JSON subscription routing rules
// Registration settings
TurnstileSiteKey string `json:"turnstileSiteKey" form:"turnstileSiteKey"`
} }
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values. // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.

View file

@ -59,39 +59,83 @@
</a-row> </a-row>
<a-row type="flex" justify="center"> <a-row type="flex" justify="center">
<a-col span="24"> <a-col span="24">
<a-form @submit.prevent="login"> <a-tabs :active-key="activeTab" @change="switchTab" centered>
<a-space direction="vertical" size="middle"> <a-tab-pane key="login" tab='{{ i18n "pages.login.loginTab" }}'>
<a-form-item> <a-form @submit.prevent="doLogin">
<a-input autocomplete="username" name="username" v-model.trim="user.username" <a-space direction="vertical" size="middle">
placeholder='{{ i18n "username" }}' autofocus required> <a-form-item>
<a-icon slot="prefix" type="user" class="fs-1rem"></a-icon> <a-input autocomplete="username" name="username" v-model.trim="user.username"
</a-input> placeholder='{{ i18n "username" }}' autofocus required>
</a-form-item> <a-icon slot="prefix" type="user" class="fs-1rem"></a-icon>
<a-form-item> </a-input>
<a-input-password autocomplete="current-password" name="password" v-model.trim="user.password" </a-form-item>
placeholder='{{ i18n "password" }}' required> <a-form-item>
<a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon> <a-input-password autocomplete="current-password" name="password" v-model.trim="user.password"
</a-input-password> placeholder='{{ i18n "password" }}' required>
</a-form-item> <a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
<a-form-item v-if="twoFactorEnable"> </a-input-password>
<a-input autocomplete="one-time-code" name="twoFactorCode" v-model.trim="user.twoFactorCode" </a-form-item>
placeholder='{{ i18n "twoFactorCode" }}' required> <a-form-item v-if="twoFactorEnable">
<a-icon slot="prefix" type="key" class="fs-1rem"></a-icon> <a-input autocomplete="one-time-code" name="twoFactorCode" v-model.trim="user.twoFactorCode"
</a-input> placeholder='{{ i18n "twoFactorCode" }}' required>
</a-form-item> <a-icon slot="prefix" type="key" class="fs-1rem"></a-icon>
<a-form-item> </a-input>
<a-row justify="center" class="centered"> </a-form-item>
<div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem" <a-form-item>
:style="loadingStates.spinning ? 'width: 52px' : 'display: inline-block'"> <a-row justify="center" class="centered">
<a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning" <div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem"
:icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit"> :style="loadingStates.spinning ? 'width: 52px' : 'display: inline-block'">
[[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]] <a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning"
</a-button> :icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit">
</div> [[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]]
</a-row> </a-button>
</a-form-item> </div>
</a-space> </a-row>
</a-form> </a-form-item>
</a-space>
</a-form>
</a-tab-pane>
<a-tab-pane key="register" tab='{{ i18n "pages.login.registerTab" }}'>
<a-form @submit.prevent="doRegister">
<a-space direction="vertical" size="middle">
<a-form-item>
<a-input autocomplete="username" name="reg-username" v-model.trim="regUser.username"
placeholder='{{ i18n "username" }}' required>
<a-icon slot="prefix" type="user" class="fs-1rem"></a-icon>
</a-input>
</a-form-item>
<a-form-item>
<a-input-password autocomplete="new-password" name="reg-password" v-model.trim="regUser.password"
placeholder='{{ i18n "password" }}' required>
<a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
</a-input-password>
</a-form-item>
<a-form-item>
<a-input-password autocomplete="new-password" name="reg-confirm-password" v-model.trim="regUser.confirmPassword"
placeholder='{{ i18n "pages.login.confirmPassword" }}' required>
<a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
</a-input-password>
</a-form-item>
<a-form-item>
<div class="cf-turnstile-wrapper">
<div class="cf-turnstile" :data-sitekey="turnstileSiteKey" data-callback="onTurnstileCallback"></div>
</div>
</a-form-item>
<a-form-item>
<a-row justify="center" class="centered">
<div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem"
:style="loadingStates.registerSpinning ? 'width: 52px' : 'display: inline-block'">
<a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.registerSpinning"
:icon="loadingStates.registerSpinning ? 'poweroff' : undefined" html-type="submit">
[[ loadingStates.registerSpinning ? '' : '{{ i18n "pages.login.registerTab" }}' ]]
</a-button>
</div>
</a-row>
</a-form-item>
</a-space>
</a-form>
</a-tab-pane>
</a-tabs>
</a-col> </a-col>
</a-row> </a-row>
</template> </template>
@ -103,23 +147,39 @@
{{template "page/body_scripts" .}} {{template "page/body_scripts" .}}
{{template "component/aThemeSwitch" .}} {{template "component/aThemeSwitch" .}}
<script> <script>
var turnstileToken = '';
function onTurnstileCallback(token) {
turnstileToken = token;
}
const app = new Vue({ const app = new Vue({
delimiters: ['[[', ']]'], delimiters: ['[[', ']]'],
el: '#app', el: '#app',
data: { data: {
themeSwitcher, themeSwitcher,
loadingStates: { fetched: false, spinning: false }, loadingStates: { fetched: false, spinning: false, registerSpinning: false },
activeTab: 'login',
user: { username: "", password: "", twoFactorCode: "" }, user: { username: "", password: "", twoFactorCode: "" },
regUser: { username: "", password: "", confirmPassword: "" },
twoFactorEnable: false, twoFactorEnable: false,
lang: "", lang: "",
animationStarted: false animationStarted: false,
turnstileSiteKey: '',
}, },
async mounted() { async mounted() {
this.lang = LanguageManager.getLanguage(); this.lang = LanguageManager.getLanguage();
this.twoFactorEnable = await this.getTwoFactorEnable(); this.twoFactorEnable = await this.getTwoFactorEnable();
this.turnstileSiteKey = await this.getTurnstileSiteKey();
if (this.turnstileSiteKey) {
this.loadTurnstileScript();
}
}, },
methods: { methods: {
async login() { switchTab(key) {
this.activeTab = key;
},
async doLogin() {
this.loadingStates.spinning = true; this.loadingStates.spinning = true;
const msg = await HttpUtil.post('/login', this.user); const msg = await HttpUtil.post('/login', this.user);
if (msg.success) { if (msg.success) {
@ -127,6 +187,47 @@
} }
this.loadingStates.spinning = false; this.loadingStates.spinning = false;
}, },
async doRegister() {
if (this.regUser.password !== this.regUser.confirmPassword) {
this.$message.error('{{ i18n "pages.login.passwordMismatch" }}');
return;
}
if (!turnstileToken) {
this.$message.error('{{ i18n "pages.login.turnstileRequired" }}');
return;
}
this.loadingStates.registerSpinning = true;
const msg = await HttpUtil.post('/register', {
username: this.regUser.username,
password: this.regUser.password,
turnstileToken: turnstileToken,
});
if (msg.success) {
this.$message.success('{{ i18n "pages.login.toasts.successRegister" }}');
this.activeTab = 'login';
this.user.username = this.regUser.username;
this.regUser = { username: "", password: "", confirmPassword: "" };
turnstileToken = '';
if (window.turnstile) {
turnstile.reset('.cf-turnstile');
}
}
this.loadingStates.registerSpinning = false;
},
loadTurnstileScript() {
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
document.head.appendChild(script);
},
async getTurnstileSiteKey() {
const msg = await HttpUtil.post('/getTurnstileSiteKey');
if (msg.success) {
return msg.obj;
}
return '';
},
async getTwoFactorEnable() { async getTwoFactorEnable() {
const msg = await HttpUtil.post('/getTwoFactorEnable'); const msg = await HttpUtil.post('/getTwoFactorEnable');
if (msg.success) { if (msg.success) {

View file

@ -104,6 +104,9 @@ var defaultValueMap = map[string]string{
"ldapDefaultTotalGB": "0", "ldapDefaultTotalGB": "0",
"ldapDefaultExpiryDays": "0", "ldapDefaultExpiryDays": "0",
"ldapDefaultLimitIP": "0", "ldapDefaultLimitIP": "0",
// Registration settings
"turnstileSiteKey": "",
} }
// loadSettings reads the JSON settings file into a map. // loadSettings reads the JSON settings file into a map.
@ -746,6 +749,10 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
return s.getInt("ldapDefaultLimitIP") return s.getInt("ldapDefaultLimitIP")
} }
func (s *SettingService) GetTurnstileSiteKey() (string, error) {
return s.getString("turnstileSiteKey")
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := allSetting.CheckValid(); err != nil { if err := allSetting.CheckValid(); err != nil {
return err return err

View file

@ -101,6 +101,11 @@
"hello" = "Hello" "hello" = "Hello"
"title" = "Welcome" "title" = "Welcome"
"loginAgain" = "Your session has expired, please log in again" "loginAgain" = "Your session has expired, please log in again"
"loginTab" = "Login"
"registerTab" = "Register"
"confirmPassword" = "Confirm Password"
"passwordMismatch" = "Passwords do not match"
"turnstileRequired" = "Please complete the verification"
[pages.login.toasts] [pages.login.toasts]
"invalidFormData" = "The Input data format is invalid." "invalidFormData" = "The Input data format is invalid."
@ -108,6 +113,7 @@
"emptyPassword" = "Password is required" "emptyPassword" = "Password is required"
"wrongUsernameOrPassword" = "Invalid username or password or two-factor code." "wrongUsernameOrPassword" = "Invalid username or password or two-factor code."
"successLogin" = " You have successfully logged into your account." "successLogin" = " You have successfully logged into your account."
"successRegister" = "Registration successful, please log in."
[pages.index] [pages.index]
"title" = "Overview" "title" = "Overview"

View file

@ -101,6 +101,11 @@
"hello" = "你好" "hello" = "你好"
"title" = "欢迎" "title" = "欢迎"
"loginAgain" = "登录时效已过,请重新登录" "loginAgain" = "登录时效已过,请重新登录"
"loginTab" = "登录"
"registerTab" = "注册"
"confirmPassword" = "确认密码"
"passwordMismatch" = "两次输入的密码不一致"
"turnstileRequired" = "请完成验证"
[pages.login.toasts] [pages.login.toasts]
"invalidFormData" = "数据格式错误" "invalidFormData" = "数据格式错误"
@ -108,6 +113,7 @@
"emptyPassword" = "请输入密码" "emptyPassword" = "请输入密码"
"wrongUsernameOrPassword" = "用户名、密码或双重验证码无效。" "wrongUsernameOrPassword" = "用户名、密码或双重验证码无效。"
"successLogin" = "您已成功登录您的账户。" "successLogin" = "您已成功登录您的账户。"
"successRegister" = "注册成功,请登录。"
[pages.index] [pages.index]
"title" = "系统状态" "title" = "系统状态"