diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0d08e261 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "github-actions" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 39ddf2e0..0dd4847d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -15,13 +15,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: submodules: true - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | hsanaeii/3x-ui @@ -32,28 +32,28 @@ jobs: type=semver,pattern={{version}} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 with: install: true - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b8d6902..ed9417c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,11 +2,9 @@ name: Release 3X-UI on: workflow_dispatch: - release: - types: [published] push: branches: - - main + - '**' tags: - "v*.*.*" paths: @@ -20,9 +18,48 @@ on: - 'x-ui.service.debian' - 'x-ui.service.arch' - 'x-ui.service.rhel' + pull_request: jobs: + analyze: + name: Analyze Go code + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-formatted:" + echo "$unformatted" + exit 1 + fi + + - name: Run go vet + run: go vet ./... + + - name: Run staticcheck + uses: dominikh/staticcheck-action@v1 + with: + version: "latest" + install-go: false + + - name: Run tests + run: go test -race -shuffle=on ./... + build: + needs: analyze permissions: contents: write strategy: @@ -38,7 +75,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 @@ -133,19 +170,17 @@ jobs: run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui - name: Upload files to Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: x-ui-linux-${{ matrix.platform }} path: ./x-ui-linux-${{ matrix.platform }}.tar.gz - name: Upload files to GH release uses: svenstaro/upload-release-action@v2 - if: | - (github.event_name == 'release' && github.event.action == 'published') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') with: repo_token: ${{ secrets.GITHUB_TOKEN }} - tag: ${{ github.ref }} + tag: ${{ github.ref_name }} file: x-ui-linux-${{ matrix.platform }}.tar.gz asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz overwrite: true @@ -156,6 +191,7 @@ jobs: # ================================= build-windows: name: Build for Windows + needs: analyze permissions: contents: write strategy: @@ -165,7 +201,7 @@ jobs: runs-on: windows-latest steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 @@ -230,19 +266,17 @@ jobs: Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip" - name: Upload files to Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: x-ui-windows-amd64 path: ./x-ui-windows-amd64.zip - name: Upload files to GH release uses: svenstaro/upload-release-action@v2 - if: | - (github.event_name == 'release' && github.event.action == 'published') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') with: repo_token: ${{ secrets.GITHUB_TOKEN }} - tag: ${{ github.ref }} + tag: ${{ github.ref_name }} file: x-ui-windows-amd64.zip asset_name: x-ui-windows-amd64.zip overwrite: true diff --git a/web/controller/index.go b/web/controller/index.go index 605f874f..dd58e5e5 100644 --- a/web/controller/index.go +++ b/web/controller/index.go @@ -1,10 +1,10 @@ package controller import ( + "fmt" "net/http" "text/template" "time" - "fmt" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/web/service" @@ -79,12 +79,12 @@ func (a *IndexController) login(c *gin.Context) { if user == nil { logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c)) - - notifyPass := safePass - + + notifyPass := safePass + if checkErr != nil && checkErr.Error() == "invalid 2fa code" { translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed") - notifyPass = fmt.Sprintf("*** (%s)", translatedError) + notifyPass = fmt.Sprintf("*** (%s)", translatedError) } a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0) diff --git a/web/service/inbound.go b/web/service/inbound.go index 101c79d9..8a3a4ae2 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -2032,7 +2032,6 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl return nil, err } if t != nil && client != nil { - t.Enable = client.Enable t.UUID = client.ID t.SubId = client.SubID return t, nil diff --git a/web/service/tgbot.go b/web/service/tgbot.go index 6a49f1d3..1649f2ed 100644 --- a/web/service/tgbot.go +++ b/web/service/tgbot.go @@ -1926,6 +1926,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } else { t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) + t.sendClientIndividualLinks(chatId, client_Email) + t.sendClientQRLinks(chatId, client_Email) } case "add_client_submit_enable": client_Enable = true @@ -1936,6 +1938,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } else { t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) + t.sendClientIndividualLinks(chatId, client_Email) + t.sendClientQRLinks(chatId, client_Email) } case "reset_all_traffics_cancel": t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) @@ -3302,6 +3306,27 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) { } } +// getCommonClientButtons returns the shared inline keyboard rows for client configuration +func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton { + return [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"), + ), + } +} + // addClient handles the process of adding a new client to an inbound. func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) { inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) @@ -3312,91 +3337,40 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) { protocol := inbound.Protocol + var protocolRows [][]telego.InlineKeyboardButton switch protocol { case model.VMESS, model.VLESS: - inlineKeyboard := tu.InlineKeyboard( + protocolRows = [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"), ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"), - ), - ) - if len(messageID) > 0 { - t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard) - } else { - t.SendMsgToTgbot(chatId, msg, inlineKeyboard) } case model.Trojan: - inlineKeyboard := tu.InlineKeyboard( + protocolRows = [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"), ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"), - tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"), - ), - ) - if len(messageID) > 0 { - t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard) - } else { - t.SendMsgToTgbot(chatId, msg, inlineKeyboard) } case model.Shadowsocks: - inlineKeyboard := tu.InlineKeyboard( + protocolRows = [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"), ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"), - tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"), - ), - ) - - if len(messageID) > 0 { - t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard) - } else { - t.SendMsgToTgbot(chatId, msg, inlineKeyboard) } } + commonRows := t.getCommonClientButtons() + inlineKeyboard := tu.InlineKeyboard(append(protocolRows, commonRows...)...) + + if len(messageID) > 0 { + t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard) + } else { + t.SendMsgToTgbot(chatId, msg, inlineKeyboard) + } + } // searchInbound searches for inbounds by remark and sends the results. diff --git a/web/service/user.go b/web/service/user.go index 0a2a3f3e..6fcf17e7 100644 --- a/web/service/user.go +++ b/web/service/user.go @@ -95,7 +95,7 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode } if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode { - return nil, errors.New("invalid 2fa code") + return nil, errors.New("invalid 2fa code") } } diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml index 8a403a1c..0425db96 100644 --- a/web/translation/translate.ru_RU.toml +++ b/web/translation/translate.ru_RU.toml @@ -149,7 +149,7 @@ "geofileUpdateDialogDesc" = "Это обновит файл #filename#." "geofilesUpdateDialogDesc" = "Это обновит все геофайлы." "geofilesUpdateAll" = "Обновить все" -"geofileUpdatePopover" = "Геофайл успешно обновлён" +"geofileUpdatePopover" = "Геофайлы успешно обновлены" "dontRefresh" = "Установка в процессе. Не обновляйте страницу" "logs" = "Журнал" "config" = "Конфигурация" diff --git a/x-ui.sh b/x-ui.sh index 2e555b25..e9e2d831 100644 --- a/x-ui.sh +++ b/x-ui.sh @@ -317,12 +317,12 @@ check_config() { start >/dev/null 2>&1 else LOGE "IP certificate setup failed." - echo -e "${yellow}You can try again via option 18 (SSL Certificate Management).${plain}" + echo -e "${yellow}You can try again via option 19 (SSL Certificate Management).${plain}" start >/dev/null 2>&1 fi else echo -e "${yellow}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}" - echo -e "${yellow}For security, please configure SSL certificate using option 18 (SSL Certificate Management)${plain}" + echo -e "${yellow}For security, please configure SSL certificate using option 19 (SSL Certificate Management)${plain}" fi fi }