> = {
+ dashboard: 1,
+ login: 1,
+ sidebar: 3,
+};
+
+export interface PlacementStatus {
+ count: number;
+ capacity?: number;
+ takenUntil?: string;
+}
+
+// When full, a place frees up once enough bookings end to drop below capacity.
+export function placementStatus(sponsors: Sponsor[], slot: SponsorSlot): PlacementStatus {
+ const booked = sponsorsForSlot(sponsors, slot);
+ const capacity = SLOT_CAPACITY[slot];
+ if (!capacity || booked.length < capacity) return { count: booked.length, capacity };
+ const ends = booked.map((s) => s.until).sort((a, b) => Date.parse(a) - Date.parse(b));
+ return { count: booked.length, capacity, takenUntil: ends[booked.length - capacity] };
+}
diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts
index de9663585..a6150e0c1 100644
--- a/frontend/src/pages/api-docs/endpoints.ts
+++ b/frontend/src/pages/api-docs/endpoints.ts
@@ -236,6 +236,13 @@ export const sections: readonly Section[] = [
'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
response: '{\n "success": true,\n "obj": "csrf-token-string"\n}',
},
+ {
+ method: 'GET',
+ path: '/sponsors',
+ summary:
+ 'Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.',
+ responseSchema: 'SponsorList',
+ },
{
method: 'POST',
path: '/getTwoFactorEnable',
diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx
index c53ffda27..9c434a3ba 100644
--- a/frontend/src/pages/index/IndexPage.tsx
+++ b/frontend/src/pages/index/IndexPage.tsx
@@ -22,6 +22,7 @@ import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
+import SponsorSlot from '@/components/sponsor/SponsorSlot';
import { setMessageInstance } from '@/utils/messageBus';
import OverviewActionBar from './OverviewActionBar';
import VitalTile from './VitalTile';
@@ -213,6 +214,8 @@ export default function IndexPage() {
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
+
+
{health && (
diff --git a/frontend/src/pages/login/LoginPage.css b/frontend/src/pages/login/LoginPage.css
index 2a72af26a..ecbaad847 100644
--- a/frontend/src/pages/login/LoginPage.css
+++ b/frontend/src/pages/login/LoginPage.css
@@ -411,3 +411,7 @@
.submit-row {
margin-bottom: 0;
}
+
+.login-sponsor {
+ margin-top: 20px;
+}
diff --git a/frontend/src/pages/login/LoginPage.tsx b/frontend/src/pages/login/LoginPage.tsx
index 59f8360aa..ac5510ad3 100644
--- a/frontend/src/pages/login/LoginPage.tsx
+++ b/frontend/src/pages/login/LoginPage.tsx
@@ -26,6 +26,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { HttpUtil, LanguageManager } from '@/utils';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { setMessageInstance } from '@/utils/messageBus';
+import SponsorSlot from '@/components/sponsor/SponsorSlot';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
import './LoginPage.css';
@@ -247,6 +248,7 @@ export default function LoginPage() {
+
)}
diff --git a/frontend/src/pages/sponsors/SponsorsPage.css b/frontend/src/pages/sponsors/SponsorsPage.css
new file mode 100644
index 000000000..81d26b5a8
--- /dev/null
+++ b/frontend/src/pages/sponsors/SponsorsPage.css
@@ -0,0 +1,142 @@
+.sponsors-page .ant-layout,
+.sponsors-page .ant-layout-content,
+.sponsors-page .content-shell {
+ background: transparent;
+}
+
+.sponsors-page .content-area {
+ padding: 24px;
+}
+
+.sponsors-inner {
+ max-width: 1200px;
+}
+
+.sponsors-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 24px;
+}
+
+.sponsors-page .sponsors-title {
+ margin: 0 0 4px;
+}
+
+.sponsors-title-icon {
+ color: #faad14;
+}
+
+.sponsors-page .sponsors-section-title {
+ margin: 32px 0 12px;
+ color: var(--ant-color-text-secondary);
+ font-weight: 600;
+ letter-spacing: 0.3px;
+}
+
+.sponsors-yourbrand {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ height: 100%;
+ min-height: 170px;
+ padding: 16px;
+ border: 1.5px dashed var(--ant-color-border);
+ border-radius: var(--ant-border-radius-lg, 8px);
+ background: transparent;
+}
+
+.sponsors-yourbrand.is-large {
+ align-items: center;
+ min-height: 0;
+ padding: 40px 24px;
+ text-align: center;
+ background: var(--bg-card, transparent);
+}
+
+.sponsors-yourbrand-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 56px;
+ height: 56px;
+ border-radius: 8px;
+ font-size: 22px;
+ color: var(--ant-color-primary);
+ background: var(--ant-color-primary-bg);
+}
+
+.sponsors-yourbrand-title {
+ font-weight: 600;
+ font-size: 15px;
+ color: var(--ant-color-text);
+}
+
+.sponsors-yourbrand-text {
+ max-width: 420px;
+ margin-bottom: 4px;
+ font-size: 13px;
+ color: var(--ant-color-text-secondary);
+}
+
+.sponsors-yourbrand:not(.is-large) .ant-btn {
+ margin-top: auto;
+}
+
+.sponsors-placement {
+ display: flex;
+ gap: 12px;
+ height: 100%;
+ padding: 14px 16px;
+ border: 1px solid var(--ant-color-border-secondary);
+ border-radius: var(--ant-border-radius-lg, 8px);
+ background: var(--bg-card, transparent);
+}
+
+.sponsors-placement-icon {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 8px;
+ font-size: 17px;
+ color: var(--ant-color-primary);
+ background: var(--ant-color-primary-bg);
+}
+
+.sponsors-placement-title {
+ font-weight: 600;
+ color: var(--ant-color-text);
+}
+
+.sponsors-placement-body {
+ min-width: 0;
+}
+
+.sponsors-placement-status {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 8px;
+}
+
+.sponsors-placement-status .ant-tag {
+ margin: 0;
+}
+
+.sponsors-placement-desc {
+ font-size: 13px;
+ color: var(--ant-color-text-secondary);
+}
+
+@media (max-width: 768px) {
+ .sponsors-page .content-area {
+ padding: 12px;
+ padding-top: 64px;
+ }
+}
diff --git a/frontend/src/pages/sponsors/SponsorsPage.tsx b/frontend/src/pages/sponsors/SponsorsPage.tsx
new file mode 100644
index 000000000..bbf0a67cb
--- /dev/null
+++ b/frontend/src/pages/sponsors/SponsorsPage.tsx
@@ -0,0 +1,176 @@
+import { useMemo } from 'react';
+import type { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd';
+import {
+ CrownOutlined,
+ DashboardOutlined,
+ LoginOutlined,
+ MenuUnfoldOutlined,
+ PlusOutlined,
+} from '@ant-design/icons';
+
+import { useTheme } from '@/hooks/useTheme';
+import AppSidebar from '@/layouts/AppSidebar';
+import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
+import SponsorCard from '@/components/sponsor/SponsorCard';
+import { IntlUtil } from '@/utils';
+import { useDatepicker } from '@/hooks/useDatepicker';
+import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors';
+import './SponsorsPage.css';
+
+function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
+ const { t } = useTranslation();
+ if (!contact) return null;
+ return (
+ }
+ href={contact}
+ target="_blank"
+ rel="noopener noreferrer"
+ block={block}
+ >
+ {t('pages.sponsors.become')}
+
+ );
+}
+
+function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
{t('pages.sponsors.yourBrand')}
+
{t('pages.sponsors.yourBrandText')}
+
+
+ );
+}
+
+export default function SponsorsPage() {
+ const { t } = useTranslation();
+ const { isDark, isUltra, antdThemeConfig } = useTheme();
+ const { data, fetched } = useSponsorsQuery();
+ const sponsors = sponsorsForSlot(data.sponsors, 'page');
+
+ const { datepicker } = useDatepicker();
+ const placements: { slot: SponsorSlot; icon: ReactNode; title: string; desc: string }[] = [
+ {
+ slot: 'dashboard',
+ icon: ,
+ title: t('pages.sponsors.placementDashboard'),
+ desc: t('pages.sponsors.placementDashboardDesc'),
+ },
+ {
+ slot: 'sidebar',
+ icon: ,
+ title: t('pages.sponsors.placementSidebar'),
+ desc: t('pages.sponsors.placementSidebarDesc'),
+ },
+ {
+ slot: 'login',
+ icon: ,
+ title: t('pages.sponsors.placementLogin'),
+ desc: t('pages.sponsors.placementLoginDesc'),
+ },
+ {
+ slot: 'page',
+ icon: ,
+ title: t('pages.sponsors.placementPage'),
+ desc: t('pages.sponsors.placementPageDesc'),
+ },
+ ];
+
+ const pageClass = useMemo(() => {
+ const classes = ['sponsors-page'];
+ if (isDark) classes.push('is-dark');
+ if (isUltra) classes.push('is-ultra');
+ return classes.join(' ');
+ }, [isDark, isUltra]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t('pages.sponsors.title')}
+
+ {t('pages.sponsors.intro')}
+
+
+
+
+
+ {sponsors.length === 0 ? (
+ fetched &&
+ ) : (
+
+ {sponsors.map((sponsor) => (
+
+
+
+ ))}
+ {data.contact && (
+
+
+
+ )}
+
+ )}
+
+
+
+ {t('pages.sponsors.placements')}
+
+
+ {placements.map((p) => {
+ const status = placementStatus(data.sponsors, p.slot);
+ return (
+
+
+
{p.icon}
+
+
{p.title}
+
{p.desc}
+
+ {status.takenUntil ? (
+
+ {t('pages.sponsors.takenUntil', {
+ date: IntlUtil.formatDate(status.takenUntil, datepicker),
+ })}
+
+ ) : (
+ {t('pages.sponsors.available')}
+ )}
+ {status.count > 0 && !status.takenUntil && (
+
+ {t('pages.sponsors.activeCount', {
+ count:
+ status.capacity && status.capacity > 1
+ ? `${status.count}/${status.capacity}`
+ : status.count,
+ })}
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx
index beb9a4e1e..e5927ce4e 100644
--- a/frontend/src/routes.tsx
+++ b/frontend/src/routes.tsx
@@ -13,6 +13,7 @@ const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
+const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage'));
function withSuspense(node: React.ReactNode) {
return (
@@ -51,6 +52,7 @@ const routes: RouteObject[] = [
{ path: 'outbound', element: withSuspense() },
{ path: 'routing', element: withSuspense() },
{ path: 'api-docs', element: withSuspense() },
+ { path: 'sponsors', element: withSuspense() },
],
},
];
diff --git a/frontend/src/styles/page-shell.css b/frontend/src/styles/page-shell.css
index 2ebf2b026..1125543ea 100644
--- a/frontend/src/styles/page-shell.css
+++ b/frontend/src/styles/page-shell.css
@@ -5,7 +5,8 @@
.settings-page,
.nodes-page,
.groups-page,
-.api-docs-page {
+.api-docs-page,
+.sponsors-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
@@ -19,7 +20,8 @@
.settings-page.is-dark,
.nodes-page.is-dark,
.groups-page.is-dark,
-.api-docs-page.is-dark {
+.api-docs-page.is-dark,
+.sponsors-page.is-dark {
--bg-page: #1a1b1f;
--bg-card: #23252b;
}
@@ -31,7 +33,8 @@
.settings-page.is-dark.is-ultra,
.nodes-page.is-dark.is-ultra,
.groups-page.is-dark.is-ultra,
-.api-docs-page.is-dark.is-ultra {
+.api-docs-page.is-dark.is-ultra,
+.sponsors-page.is-dark.is-ultra {
--bg-page: #000;
--bg-card: #101013;
}
diff --git a/frontend/src/test/sponsors.test.tsx b/frontend/src/test/sponsors.test.tsx
new file mode 100644
index 000000000..218491101
--- /dev/null
+++ b/frontend/src/test/sponsors.test.tsx
@@ -0,0 +1,98 @@
+import { afterEach, describe, expect, test } from 'vitest';
+
+import {
+ SPONSOR_DISMISS_MS,
+ dismissSponsor,
+ isSponsorDismissed,
+ pickLocale,
+ placementStatus,
+} from '@/lib/sponsors';
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe('pickLocale', () => {
+ const map = { en: 'Hello', fa: 'سلام', 'zh-TW': '哈囉' };
+
+ test.each([
+ ['fa-IR', 'سلام'],
+ ['zh-TW', '哈囉'],
+ ['zh-CN', 'Hello'],
+ ['ru-RU', 'Hello'],
+ ])('%s resolves to %s', (lang, want) => {
+ expect(pickLocale(map, lang)).toBe(want);
+ });
+
+ test('missing map yields empty string', () => {
+ expect(pickLocale(undefined, 'en-US')).toBe('');
+ });
+});
+
+describe('sponsor dismissal', () => {
+ const t0 = 1_800_000_000_000;
+
+ test('hides only the dismissed slot until the window elapses', () => {
+ dismissSponsor('acme', 'sidebar', t0);
+ expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
+ expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
+ expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS)).toBe(false);
+ });
+
+ test('prunes expired entries on the next dismiss', () => {
+ dismissSponsor('old', 'login', t0);
+ dismissSponsor('new', 'login', t0 + SPONSOR_DISMISS_MS);
+ const stored = JSON.parse(localStorage.getItem('xui.sponsor.dismissed') || '{}');
+ expect(Object.keys(stored)).toEqual(['new:login']);
+ });
+
+ test('corrupt storage is treated as nothing dismissed', () => {
+ localStorage.setItem('xui.sponsor.dismissed', 'not json');
+ expect(isSponsorDismissed('acme', 'sidebar', t0)).toBe(false);
+ });
+});
+
+describe('placementStatus', () => {
+ const sp = (id: string, slots: string[], until: string) => ({
+ id,
+ name: id,
+ slots,
+ until,
+ title: {},
+ text: {},
+ link: 'https://x.example/',
+ });
+
+ test('exclusive slot is taken until the latest booking ends', () => {
+ const sponsors = [
+ sp('a', ['dashboard'], '2026-11-01T00:00:00Z'),
+ sp('b', ['dashboard'], '2026-12-15T00:00:00Z'),
+ ];
+ expect(placementStatus(sponsors, 'dashboard')).toEqual({
+ count: 2,
+ capacity: 1,
+ takenUntil: '2026-12-15T00:00:00Z',
+ });
+ });
+
+ test('sidebar is full at three and frees up when the earliest ends', () => {
+ const sponsors = [
+ sp('a', ['sidebar'], '2026-12-01T00:00:00Z'),
+ sp('b', ['sidebar'], '2026-10-20T00:00:00Z'),
+ sp('c', ['sidebar'], '2026-11-10T00:00:00Z'),
+ ];
+ expect(placementStatus(sponsors, 'sidebar')).toEqual({
+ count: 3,
+ capacity: 3,
+ takenUntil: '2026-10-20T00:00:00Z',
+ });
+ expect(placementStatus(sponsors.slice(0, 2), 'sidebar')).toEqual({ count: 2, capacity: 3 });
+ });
+
+ test('uncapped page slot reports only a count', () => {
+ expect(placementStatus([sp('a', ['page'], '2026-12-01T00:00:00Z')], 'page')).toEqual({
+ count: 1,
+ capacity: undefined,
+ });
+ });
+});
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 304dc6bc4..d91e52342 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -286,7 +286,7 @@ export default defineConfig({
port: 5173,
strictPort: true,
proxy: {
- '^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
+ '^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|sponsors|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
'^/$': makeBackendProxy(BACKEND_TARGET),
'^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
'^/(?:[^/]+/)?ws$': {
diff --git a/internal/web/controller/index.go b/internal/web/controller/index.go
index a5ad9afc9..faf13bcca 100644
--- a/internal/web/controller/index.go
+++ b/internal/web/controller/index.go
@@ -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) {
diff --git a/internal/web/routes_contract_test.go b/internal/web/routes_contract_test.go
index dd7329985..bcf7bec99 100644
--- a/internal/web/routes_contract_test.go
+++ b/internal/web/routes_contract_test.go
@@ -23,16 +23,17 @@ and an entry for a removed route documents an endpoint that 404s. This test
constructs the real router and diffs it against the registry both ways.
Scope: everything under /panel/api/ plus the session-auth surface the
-registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
-/ws). SPA page routes are UI, not API, and stay out; registry paths that
-start with "/{" describe the standalone subscription server, which this
-engine does not serve.
+registry also documents (/login, /logout, /csrf-token, /sponsors,
+/getTwoFactorEnable, /ws). SPA page routes are UI, not API, and stay out;
+registry paths that start with "/{" describe the standalone subscription
+server, which this engine does not serve.
*/
var contractExtraRoutes = map[string]bool{
"POST /login": true,
"POST /logout": true,
"GET /csrf-token": true,
+ "GET /sponsors": true,
"POST /getTwoFactorEnable": true,
"GET /ws": true,
}
diff --git a/internal/web/service/panel/sponsor.go b/internal/web/service/panel/sponsor.go
new file mode 100644
index 000000000..282d42f48
--- /dev/null
+++ b/internal/web/service/panel/sponsor.go
@@ -0,0 +1,246 @@
+package panel
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/config"
+ "github.com/mhsanaei/3x-ui/v3/internal/logger"
+ "github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+// Sponsor is one paid placement published in the repo's sponsors.json.
+type Sponsor struct {
+ ID string `json:"id" example:"acme-2026-10"`
+ Name string `json:"name" example:"Acme VPS"`
+ Enable *bool `json:"enable,omitempty" example:"true"`
+ Slots []string `json:"slots"`
+ Until time.Time `json:"until" example:"2026-11-01T00:00:00Z"`
+ Logo string `json:"logo,omitempty" example:"/sponsors/logo/acme.png"`
+ Title map[string]string `json:"title"`
+ Text map[string]string `json:"text"`
+ Link string `json:"link" example:"https://acme.example/?utm_source=3x-ui"`
+}
+
+// SponsorList is the active sponsor set plus the contact link for new sponsors.
+type SponsorList struct {
+ Contact string `json:"contact,omitempty" example:"https://t.me/example"`
+ Sponsors []Sponsor `json:"sponsors"`
+}
+
+const (
+ sponsorsTTL = time.Hour
+ sponsorsErrTTL = 10 * time.Minute
+ maxSponsorsBytes = 256 << 10
+ maxLogoBytes = 256 << 10
+ maxSidebarSlots = 3
+ localSponsorsDir = "../sponsors/3X"
+ sponsorLogoPath = "/sponsors/logo/"
+)
+
+// ErrSponsorLogoUnknown rejects logo names not used by an active sponsor.
+var ErrSponsorLogoUnknown = errors.New("unknown sponsor logo")
+
+type sponsorLogo struct {
+ data []byte
+ contentType string
+ err error
+ retryAt time.Time
+}
+
+var (
+ sponsorsURL = "https://sponsors.sanaei.dev/3X/sponsors.json"
+ sponsorLogoBase = "https://sponsors.sanaei.dev/3X/logos/"
+ sponsorNow = time.Now
+
+ // The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
+ sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
+ sponsorSlots = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true}
+
+ sponsorsMu sync.Mutex
+ sponsorsRaw *SponsorList
+ sponsorsErr error
+ sponsorsRetryAt time.Time
+
+ logosMu sync.Mutex
+ logos = map[string]sponsorLogo{}
+)
+
+// GetSponsors returns the currently active sponsors. The remote file is cached,
+// but expiry is re-checked on every call so a slot ends exactly at Until.
+func (s *PanelService) GetSponsors() (*SponsorList, error) {
+ raw, err := cachedSponsors()
+ if err != nil {
+ return nil, err
+ }
+ return activeSponsors(raw, sponsorNow()), nil
+}
+
+func cachedSponsors() (*SponsorList, error) {
+ sponsorsMu.Lock()
+ defer sponsorsMu.Unlock()
+ now := sponsorNow()
+ if !config.IsDebug() && now.Before(sponsorsRetryAt) {
+ return sponsorsRaw, sponsorsErr
+ }
+ list, err := fetchSponsors()
+ switch {
+ case err == nil:
+ sponsorsRaw, sponsorsErr, sponsorsRetryAt = list, nil, now.Add(sponsorsTTL)
+ case sponsorsRaw != nil:
+ // An upstream blip keeps the last good list, so paid slots do not blink out.
+ logger.Debug("sponsors refresh failed, keeping last list:", err)
+ sponsorsRetryAt = now.Add(sponsorsErrTTL)
+ default:
+ sponsorsErr, sponsorsRetryAt = err, now.Add(sponsorsErrTTL)
+ }
+ return sponsorsRaw, sponsorsErr
+}
+
+// GetSponsorLogo returns the image bytes for a logo of a currently active sponsor.
+func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) {
+ sponsors, err := s.GetSponsors()
+ if err != nil {
+ return nil, "", err
+ }
+ if !slices.ContainsFunc(sponsors.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) {
+ return nil, "", ErrSponsorLogoUnknown
+ }
+ logosMu.Lock()
+ defer logosMu.Unlock()
+ now := sponsorNow()
+ l, ok := logos[name]
+ if ok && !config.IsDebug() && now.Before(l.retryAt) {
+ return l.data, l.contentType, l.err
+ }
+ // Failures are cached too: this route is public and each miss is an outbound fetch.
+ data, contentType, err := fetchSponsorLogo(name)
+ switch {
+ case err == nil:
+ l = sponsorLogo{data: data, contentType: contentType, retryAt: now.Add(sponsorsTTL)}
+ case l.data != nil:
+ l.retryAt = now.Add(sponsorsErrTTL)
+ default:
+ l = sponsorLogo{err: err, retryAt: now.Add(sponsorsErrTTL)}
+ }
+ logos[name] = l
+ return l.data, l.contentType, l.err
+}
+
+func fetchSponsorLogo(name string) ([]byte, string, error) {
+ data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes)
+ if err != nil {
+ return nil, "", err
+ }
+ contentType := http.DetectContentType(data)
+ switch contentType {
+ case "image/png", "image/webp", "image/jpeg":
+ return data, contentType, nil
+ default:
+ return nil, "", fmt.Errorf("sponsor logo %s has content type %s", name, contentType)
+ }
+}
+
+// readSponsorSource reads a sibling checkout of MHSanaei/sponsors under XUI_DEBUG so
+// sponsor edits can be previewed locally before they are pushed.
+func readSponsorSource(url, localPath string, limit int) ([]byte, error) {
+ if !config.IsDebug() {
+ return fetchLimited(url, limit)
+ }
+ data, err := os.ReadFile(localPath)
+ if err != nil {
+ return nil, err
+ }
+ if len(data) > limit {
+ return nil, fmt.Errorf("%s exceeds %d bytes", localPath, limit)
+ }
+ return data, nil
+}
+
+func fetchLimited(url string, limit int) ([]byte, error) {
+ client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("fetch %s returned status %d", url, resp.StatusCode)
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, int64(limit)+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(body) > limit {
+ return nil, fmt.Errorf("%s exceeds %d bytes", url, limit)
+ }
+ return body, nil
+}
+
+func fetchSponsors() (*SponsorList, error) {
+ body, err := readSponsorSource(sponsorsURL, filepath.Join(localSponsorsDir, "sponsors.json"), maxSponsorsBytes)
+ if err != nil {
+ return nil, err
+ }
+ var list SponsorList
+ if err := json.Unmarshal(body, &list); err != nil {
+ return nil, err
+ }
+ return &list, nil
+}
+
+func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
+ out := &SponsorList{Sponsors: []Sponsor{}}
+ if raw == nil {
+ return out
+ }
+ if strings.HasPrefix(raw.Contact, "https://") {
+ out.Contact = raw.Contact
+ }
+ sidebarTaken := 0
+ for _, sp := range raw.Sponsors {
+ // A missing enable counts as on, so a forgotten field never hides a paid slot.
+ disabled := sp.Enable != nil && !*sp.Enable
+ if disabled || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
+ continue
+ }
+ // A bad logo name drops only the logo; the paid slot still renders with its initial.
+ if sponsorLogoRe.MatchString(sp.Logo) {
+ sp.Logo = sponsorLogoPath + sp.Logo
+ } else {
+ sp.Logo = ""
+ }
+ slots := make([]string, 0, len(sp.Slots))
+ for _, slot := range sp.Slots {
+ // The sidebar rotates, so capping it keeps each paid card on screen long enough.
+ if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
+ continue
+ }
+ slots = append(slots, slot)
+ }
+ if len(slots) == 0 {
+ continue
+ }
+ if slices.Contains(slots, "sidebar") {
+ sidebarTaken++
+ }
+ sp.Slots = slots
+ out.Sponsors = append(out.Sponsors, sp)
+ }
+ return out
+}
diff --git a/internal/web/service/panel/sponsor_test.go b/internal/web/service/panel/sponsor_test.go
new file mode 100644
index 000000000..e213664ad
--- /dev/null
+++ b/internal/web/service/panel/sponsor_test.go
@@ -0,0 +1,334 @@
+package panel
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/config"
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
+)
+
+var sponsorTestNow = time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)
+
+func validSponsor() Sponsor {
+ return Sponsor{
+ ID: "acme",
+ Name: "Acme",
+ Slots: []string{"dashboard", "sidebar"},
+ Until: sponsorTestNow.Add(24 * time.Hour),
+ Logo: "acme.png",
+ Link: "https://acme.example/",
+ }
+}
+
+func TestActiveSponsorsFilters(t *testing.T) {
+ cases := []struct {
+ name string
+ mutate func(*Sponsor)
+ kept bool
+ }{
+ {"valid", func(*Sponsor) {}, true},
+ {"expired", func(s *Sponsor) { s.Until = sponsorTestNow }, false},
+ {"enable false with future until", func(s *Sponsor) { s.Enable = new(false) }, false},
+ {"enable true without until", func(s *Sponsor) { s.Enable, s.Until = new(true), time.Time{} }, false},
+ {"enable true with future until", func(s *Sponsor) { s.Enable = new(true) }, true},
+ {"missing id", func(s *Sponsor) { s.ID = "" }, false},
+ {"http link", func(s *Sponsor) { s.Link = "http://acme.example/" }, false},
+ {"javascript link", func(s *Sponsor) { s.Link = "javascript:alert(1)" }, false},
+ {"only unknown slots", func(s *Sponsor) { s.Slots = []string{"subpage"} }, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ sp := validSponsor()
+ tc.mutate(&sp)
+ got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow)
+ if kept := len(got.Sponsors) == 1; kept != tc.kept {
+ t.Fatalf("kept = %v, want %v", kept, tc.kept)
+ }
+ })
+ }
+}
+
+func TestActiveSponsorsCapsSidebarAtThree(t *testing.T) {
+ raw := &SponsorList{}
+ for _, id := range []string{"expired", "a", "b", "c", "d", "e"} {
+ sp := validSponsor()
+ sp.ID = id
+ sp.Slots = []string{"sidebar", "page"}
+ if id == "expired" {
+ sp.Until = sponsorTestNow
+ }
+ if id == "e" {
+ sp.Slots = []string{"sidebar"}
+ }
+ raw.Sponsors = append(raw.Sponsors, sp)
+ }
+ got := activeSponsors(raw, sponsorTestNow)
+ want := map[string][]string{
+ "a": {"sidebar", "page"}, "b": {"sidebar", "page"}, "c": {"sidebar", "page"}, "d": {"page"},
+ }
+ if len(got.Sponsors) != len(want) {
+ t.Fatalf("got %d sponsors, want %d (e has only sidebar and must drop)", len(got.Sponsors), len(want))
+ }
+ for _, sp := range got.Sponsors {
+ if !slices.Equal(sp.Slots, want[sp.ID]) {
+ t.Errorf("%s slots = %v, want %v", sp.ID, sp.Slots, want[sp.ID])
+ }
+ }
+}
+
+func TestActiveSponsorsLogoName(t *testing.T) {
+ cases := []struct{ logo, want string }{
+ {"acme.png", "/sponsors/logo/acme.png"},
+ {"VPS.png", "/sponsors/logo/VPS.png"},
+ {"../x.png", ""},
+ {"https://evil.example/x.png", ""},
+ {"..png", ""},
+ {"logo.svg", ""},
+ {"", ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.logo, func(t *testing.T) {
+ sp := validSponsor()
+ sp.Logo = tc.logo
+ got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow)
+ if len(got.Sponsors) != 1 {
+ t.Fatalf("sponsor dropped for logo %q; want it kept", tc.logo)
+ }
+ if got.Sponsors[0].Logo != tc.want {
+ t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, tc.want)
+ }
+ })
+ }
+}
+
+func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
+ sp := validSponsor()
+ sp.Slots = []string{"subpage", "login"}
+ got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
+ if len(got.Sponsors) != 1 {
+ t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
+ }
+ if want := "/sponsors/logo/acme.png"; got.Sponsors[0].Logo != want {
+ t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, want)
+ }
+ if s := got.Sponsors[0].Slots; len(s) != 1 || s[0] != "login" {
+ t.Errorf("slots = %v, want [login]", s)
+ }
+ if got.Contact != "" {
+ t.Errorf("contact = %q, want empty for non-https", got.Contact)
+ }
+}
+
+func setupSponsorServer(t *testing.T, body string) *atomic.Int32 {
+ t.Helper()
+ t.Setenv("XUI_DB_FOLDER", t.TempDir())
+ if err := database.InitDB(config.GetDBPath()); err != nil {
+ t.Fatalf("init db: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ var hits atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ isLogo := strings.HasPrefix(r.URL.Path, "/media/")
+ if (isLogo && failSponsorLogos.Load()) || (!isLogo && failSponsorList.Load()) {
+ w.WriteHeader(http.StatusBadGateway)
+ return
+ }
+ switch r.URL.Path {
+ case "/media/acme.png":
+ _, _ = w.Write(pngMagic)
+ case "/media/fake.png":
+ _, _ = w.Write([]byte("