Compare commits

..

1 commit

Author SHA1 Message Date
javadtgh
ea849d6305
Merge 4c7249c451 into 02bff4db6c 2025-09-24 00:49:51 +07:00
12 changed files with 71 additions and 134 deletions

View file

@ -1,9 +1,7 @@
name: Release 3X-UI for Docker
permissions:
contents: read
packages: write
on:
workflow_dispatch:
push:
@ -15,48 +13,48 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
submodules: true
- uses: actions/checkout@v5
with:
submodules: true
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
hsanaeii/3x-ui
ghcr.io/mhsanaei/3x-ui
tags: |
type=ref,event=branch
type=ref,event=tag
type=pep440,pattern={{version}}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
hsanaeii/3x-ui
ghcr.io/mhsanaei/3x-ui
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
install: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
install: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6,linux/386
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64, linux/arm64/v8, linux/arm/v7, linux/arm/v6, linux/386
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View file

@ -1 +1 @@
2.8.4
2.8.3

2
go.mod
View file

@ -96,7 +96,7 @@ require (
golang.org/x/tools v0.37.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
lukechampine.com/blake3 v1.4.1 // indirect

2
go.sum
View file

@ -236,8 +236,6 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 h1:/OQuEa4YWtDt7uQWHd3q3sUMb+QOLQUg1xa8CEsRv5w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=

View file

@ -98,14 +98,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
}
// Set base_path based on LinksPath for template rendering
// Ensure LinksPath ends with "/" for proper asset URL generation
basePath := LinksPath
if basePath != "/" && !strings.HasSuffix(basePath, "/") {
basePath += "/"
}
logger.Debug("sub: Setting base_path to:", basePath)
engine.Use(func(c *gin.Context) {
c.Set("base_path", basePath)
c.Set("base_path", LinksPath)
})
Encrypt, err := s.settingService.GetSubEncrypt()
@ -185,48 +179,22 @@ func (s *Server) initRouter() (*gin.Engine, error) {
linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets"
}
// Mount assets in multiple paths to handle different URL patterns
var assetsFS http.FileSystem
if _, err := os.Stat("web/assets"); err == nil {
assetsFS = http.FS(os.DirFS("web/assets"))
engine.StaticFS("/assets", http.FS(os.DirFS("web/assets")))
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, http.FS(os.DirFS("web/assets")))
}
} else {
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
assetsFS = http.FS(subFS)
engine.StaticFS("/assets", http.FS(subFS))
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, http.FS(subFS))
}
} else {
logger.Error("sub: failed to mount embedded assets:", err)
}
}
if assetsFS != nil {
engine.StaticFS("/assets", assetsFS)
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, assetsFS)
}
// Add middleware to handle dynamic asset paths with subid
if LinksPath != "/" {
engine.Use(func(c *gin.Context) {
path := c.Request.URL.Path
// Check if this is an asset request with subid pattern: /sub/path/{subid}/assets/...
pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
// Extract the asset path after /assets/
assetsIndex := strings.Index(path, "/assets/")
if assetsIndex != -1 {
assetPath := path[assetsIndex+8:] // +8 to skip "/assets/"
if assetPath != "" {
// Serve the asset file
c.FileFromFS(assetPath, assetsFS)
c.Abort()
return
}
}
}
c.Next()
})
}
}
g := engine.Group("/")
s.sub = NewSUBController(

View file

@ -87,20 +87,7 @@ func (a *SUBController) subs(c *gin.Context) {
if !a.jsonEnabled {
subJsonURL = ""
}
// Get base_path from context (set by middleware)
basePath, exists := c.Get("base_path")
if !exists {
basePath = "/"
}
// Add subId to base_path for asset URLs
basePathStr := basePath.(string)
if basePathStr == "/" {
basePathStr = "/" + subId + "/"
} else {
// Remove trailing slash if exists, add subId, then add trailing slash
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
}
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr)
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL)
c.HTML(200, "subpage.html", gin.H{
"title": "subscription.title",
"cur_ver": config.GetVersion(),

View file

@ -1169,7 +1169,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
// BuildPageData parses header and prepares the template view model.
// BuildPageData constructs page data for rendering the subscription information page.
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string, basePath string) PageData {
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string) PageData {
download := common.FormatTraffic(traffic.Down)
upload := common.FormatTraffic(traffic.Up)
total := "∞"
@ -1188,7 +1188,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
return PageData{
Host: hostHeader,
BasePath: basePath,
BasePath: "/", // kept as "/"; templates now use context base_path injected from router
SId: subId,
Download: download,
Upload: upload,

View file

@ -1,10 +1,7 @@
package controller
import (
"net/http"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin"
)
@ -24,21 +21,11 @@ func NewAPIController(g *gin.RouterGroup) *APIController {
return a
}
// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
// to hide the existence of API endpoints from unauthorized users
func (a *APIController) checkAPIAuth(c *gin.Context) {
if !session.IsLogin(c) {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Next()
}
// initRouter sets up the API routes for inbounds, server, and other endpoints.
func (a *APIController) initRouter(g *gin.RouterGroup) {
// Main API group
api := g.Group("/panel/api")
api.Use(a.checkAPIAuth)
api.Use(a.checkLogin)
// Inbounds API
inbounds := api.Group("/inbounds")

View file

@ -39,9 +39,8 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index)
g.GET("/logout", a.logout)
g.POST("/login", a.login)
g.GET("/logout", a.logout)
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
}

View file

@ -8,6 +8,8 @@ import (
type XUIController struct {
BaseController
inboundController *InboundController
serverController *ServerController
settingController *SettingController
xraySettingController *XraySettingController
}
@ -30,6 +32,8 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
a.inboundController = NewInboundController(g)
a.serverController = NewServerController(g)
a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g)
}

View file

@ -4,7 +4,7 @@
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' login-app'">
<transition name="list" appear>
<a-layout-content class="under min-h-0">
<a-layout-content class="under min-h-0">
<div class="waves-header">
<div class="waves-inner-header"></div>
<svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
@ -20,7 +20,7 @@
</g>
</svg>
</div>
<a-row type="flex" justify="center" align="middle" class="h-100 overflow-y-auto overflow-x-hidden">
<a-row type="flex" justify="center" align="middle" class="h-100 overflow-y-auto overflow-x-hidden">
<a-col :xs="22" :sm="12" :md="10" :lg="8" :xl="6" :xxl="5" id="login" class="my-3rem">
<template v-if="!loadingStates.fetched">
<div class="text-center">
@ -35,8 +35,8 @@
<a-space direction="vertical" :size="10">
<a-theme-switch-login></a-theme-switch-login>
<span>{{ i18n "pages.settings.language" }}</span>
<a-select ref="selectLang" class="w-100" v-model="lang" @change="LanguageManager.setLanguage(lang)"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select ref="selectLang" class="w-100" v-model="lang"
@change="LanguageManager.setLanguage(lang)" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="l.value" label="English" v-for="l in LanguageManager.supportedLanguages">
<span role="img" aria-label="l.name" v-text="l.icon"></span>
&nbsp;&nbsp;<span v-text="l.name"></span>
@ -68,7 +68,7 @@
</a-input>
</a-form-item>
<a-form-item>
<a-input-password autocomplete="current-password" name="password" v-model.trim="user.password"
<a-input-password autocomplete="password" name="password" v-model.trim="user.password"
placeholder='{{ i18n "password" }}' required>
<a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
</a-input-password>
@ -81,8 +81,7 @@
</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.spinning ? 'width: 52px' : 'display: inline-block'">
<div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem" :style="loadingStates.spinning ? 'width: 52px' : 'display: inline-block'">
<a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning"
:icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit">
[[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]]

View file

@ -95,9 +95,10 @@ type Server struct {
httpServer *http.Server
listener net.Listener
index *controller.IndexController
panel *controller.XUIController
api *controller.APIController
index *controller.IndexController
server *controller.ServerController
panel *controller.XUIController
api *controller.APIController
xrayService service.XrayService
settingService service.SettingService
@ -263,14 +264,10 @@ func (s *Server) initRouter() (*gin.Engine, error) {
g := engine.Group(basePath)
s.index = controller.NewIndexController(g)
s.server = controller.NewMultiServerController(g)
s.panel = controller.NewXUIController(g)
s.api = controller.NewAPIController(g)
// Add a catch-all route to handle undefined paths and return 404
engine.NoRoute(func(c *gin.Context) {
c.AbortWithStatus(http.StatusNotFound)
})
return engine, nil
}