feat(panel): add sponsor slots fed from sponsors.sanaei.dev

Monthly sponsor placements need to change without cutting a panel
release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors
repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in
four slots: an overview banner, a rotating sidebar card (max three), the
login page and a new Sponsors page that also lists open placements.

An entry shows only while enable is not false and until is in the
future; links must be https and logos are png/webp/jpg by name only.
The list is cached for an hour and the last good copy survives upstream
failures; logos are proxied through /sponsors/logo/:name with failures
cached, so CSP stays 'self' and admin browsers never reach a third
party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads
a sibling ../sponsors/3X checkout so edits can be previewed before push.
This commit is contained in:
MHSanaei
2026-09-26 03:31:51 +02:00
parent 89e200ead4
commit fd7b3559bc
47 changed files with 2323 additions and 10 deletions
+26
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
@@ -28,6 +29,7 @@ type IndexController struct {
settingService service.SettingService
userService panel.UserService
panelService panel.PanelService
tgbot tgbot.Tgbot
}
@@ -42,12 +44,36 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index)
g.GET("/csrf-token", a.csrfToken)
g.GET("/sponsors", a.sponsors)
g.GET("/sponsors/logo/:name", a.sponsorLogo)
g.POST("/login", middleware.CSRFMiddleware(), a.login)
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
}
// sponsors is public so the login page can render its slot; failures stay silent.
func (a *IndexController) sponsors(c *gin.Context) {
list, err := a.panelService.GetSponsors()
if err != nil {
logger.Debug("sponsors fetch failed:", err)
c.JSON(http.StatusOK, entity.Msg{Success: false})
return
}
jsonObj(c, list, nil)
}
func (a *IndexController) sponsorLogo(c *gin.Context) {
data, contentType, err := a.panelService.GetSponsorLogo(c.Param("name"))
if err != nil {
logger.Debug("sponsor logo failed:", err)
c.Status(http.StatusNotFound)
return
}
c.Header("Cache-Control", "public, max-age=3600")
c.Data(http.StatusOK, contentType, data)
}
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
func (a *IndexController) index(c *gin.Context) {
if session.IsLogin(c) {