mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-09 22:55:00 +03:00
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:
@@ -22,6 +22,12 @@ _openapi:
|
|||||||
this — the middleware short-circuits CSRF for authenticated API
|
this — the middleware short-circuits CSRF for authenticated API
|
||||||
requests.
|
requests.
|
||||||
url: '#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'
|
url: '#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'
|
||||||
|
- depth: 2
|
||||||
|
title: 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.
|
||||||
|
url: '#public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Returns whether 2FA is enabled on the panel — used by the login page to
|
title: Returns whether 2FA is enabled on the panel — used by the login page to
|
||||||
decide whether to show the OTP field.
|
decide whether to show the OTP field.
|
||||||
@@ -39,6 +45,11 @@ _openapi:
|
|||||||
this — the middleware short-circuits CSRF for authenticated API
|
this — the middleware short-circuits CSRF for authenticated API
|
||||||
requests.
|
requests.
|
||||||
id: 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
|
id: 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
|
||||||
|
- content: 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.
|
||||||
|
id: public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots
|
||||||
- content: Returns whether 2FA is enabled on the panel — used by the login page to
|
- content: Returns whether 2FA is enabled on the panel — used by the login page to
|
||||||
decide whether to show the OTP field.
|
decide whether to show the OTP field.
|
||||||
id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field
|
id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field
|
||||||
@@ -54,7 +65,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4135,6 +4135,84 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Sponsor": {
|
||||||
|
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||||
|
"properties": {
|
||||||
|
"enable": {
|
||||||
|
"example": true,
|
||||||
|
"nullable": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"example": "acme-2026-10",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"example": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"example": "/sponsors/logo/acme.png",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"example": "Acme VPS",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"until": {
|
||||||
|
"example": "2026-11-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"link",
|
||||||
|
"name",
|
||||||
|
"slots",
|
||||||
|
"text",
|
||||||
|
"title",
|
||||||
|
"until"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SponsorList": {
|
||||||
|
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
|
||||||
|
"properties": {
|
||||||
|
"contact": {
|
||||||
|
"example": "https://t.me/example",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Sponsor"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"sponsors"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SubBalancer": {
|
"SubBalancer": {
|
||||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4587,6 +4665,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/sponsors": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
],
|
||||||
|
"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.",
|
||||||
|
"operationId": "get_sponsors",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/SponsorList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"contact": "https://t.me/example",
|
||||||
|
"sponsors": [
|
||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"id": "acme-2026-10",
|
||||||
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
"name": "Acme VPS",
|
||||||
|
"slots": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"text": {},
|
||||||
|
"title": {},
|
||||||
|
"until": "2026-11-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/getTwoFactorEnable": {
|
"/getTwoFactorEnable": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -4135,6 +4135,84 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Sponsor": {
|
||||||
|
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||||
|
"properties": {
|
||||||
|
"enable": {
|
||||||
|
"example": true,
|
||||||
|
"nullable": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"example": "acme-2026-10",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"example": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"example": "/sponsors/logo/acme.png",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"example": "Acme VPS",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"until": {
|
||||||
|
"example": "2026-11-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"link",
|
||||||
|
"name",
|
||||||
|
"slots",
|
||||||
|
"text",
|
||||||
|
"title",
|
||||||
|
"until"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SponsorList": {
|
||||||
|
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
|
||||||
|
"properties": {
|
||||||
|
"contact": {
|
||||||
|
"example": "https://t.me/example",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Sponsor"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"sponsors"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SubBalancer": {
|
"SubBalancer": {
|
||||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4587,6 +4665,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/sponsors": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
],
|
||||||
|
"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.",
|
||||||
|
"operationId": "get_sponsors",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/SponsorList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"contact": "https://t.me/example",
|
||||||
|
"sponsors": [
|
||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"id": "acme-2026-10",
|
||||||
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
"name": "Acme VPS",
|
||||||
|
"slots": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"text": {},
|
||||||
|
"title": {},
|
||||||
|
"until": "2026-11-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/getTwoFactorEnable": {
|
"/getTwoFactorEnable": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -196,6 +196,10 @@ export async function httpRequest(
|
|||||||
return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
|
return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function withBasePath(path: string): string {
|
||||||
|
return basePathPrefix + path;
|
||||||
|
}
|
||||||
|
|
||||||
export function setupHttp(): void {
|
export function setupHttp(): void {
|
||||||
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
|
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
|
||||||
if (!basePath) {
|
if (!basePath) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import type { SponsorList } from '@/generated/types';
|
||||||
|
|
||||||
|
const EMPTY: SponsorList = { sponsors: [] };
|
||||||
|
|
||||||
|
async function fetchSponsors(): Promise<SponsorList> {
|
||||||
|
const msg = await HttpUtil.get<SponsorList>('/sponsors', undefined, { silent: true });
|
||||||
|
if (!msg?.success || !msg.obj) return EMPTY;
|
||||||
|
return { contact: msg.obj.contact, sponsors: msg.obj.sponsors ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSponsorsQuery() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: keys.sponsors(),
|
||||||
|
queryFn: fetchSponsors,
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
return { data: query.data ?? EMPTY, fetched: query.isFetched };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const keys = {
|
export const keys = {
|
||||||
|
sponsors: () => ['sponsors'] as const,
|
||||||
server: {
|
server: {
|
||||||
status: () => ['server', 'status'] as const,
|
status: () => ['server', 'status'] as const,
|
||||||
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
|
CrownOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
DiscordOutlined,
|
DiscordOutlined,
|
||||||
@@ -418,6 +419,12 @@ export default function CommandPalette() {
|
|||||||
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
||||||
icon: <ApiOutlined />,
|
icon: <ApiOutlined />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/sponsors',
|
||||||
|
title: t('menu.sponsors'),
|
||||||
|
keywords: ['sponsors', 'sponsor', 'partners'],
|
||||||
|
icon: <CrownOutlined />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pages
|
pages
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
.sponsor-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
|
border-radius: var(--ant-border-radius-lg, 8px);
|
||||||
|
background: var(--bg-card, var(--ant-color-fill-quaternary));
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card:hover {
|
||||||
|
border-color: var(--ant-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-main:hover,
|
||||||
|
.sponsor-main:focus-visible {
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-logo {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--ant-color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-logo-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ant-color-primary);
|
||||||
|
background: var(--ant-color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid var(--ant-color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-title {
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-visit {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ant-color-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: 6px 6px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-close:hover,
|
||||||
|
.sponsor-close:focus-visible {
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
background: var(--ant-color-fill-tertiary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir='rtl'] .sponsor-close {
|
||||||
|
margin: 6px 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-main {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-title {
|
||||||
|
display: -webkit-box;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: normal;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-text {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-close {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin: 4px 4px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-logo {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-visit {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-icon {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px;
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-icon .sponsor-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.sponsor-card-banner .sponsor-main {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-banner .sponsor-visit {
|
||||||
|
flex-basis: 100%;
|
||||||
|
padding-inline-start: 52px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card {
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
|
|
||||||
|
import type { Sponsor } from '@/generated/types';
|
||||||
|
import SponsorCard from './SponsorCard';
|
||||||
|
|
||||||
|
const sponsor: Sponsor = {
|
||||||
|
id: 'acme-2026-10',
|
||||||
|
name: 'Acme VPS',
|
||||||
|
slots: ['dashboard', 'sidebar', 'page', 'login'],
|
||||||
|
until: '2099-01-01T00:00:00Z',
|
||||||
|
title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
|
||||||
|
text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
|
||||||
|
link: 'https://acme.example/?utm_source=3x-ui',
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Sponsor/SponsorCard',
|
||||||
|
component: SponsorCard,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Paid sponsor placement fed by the project sponsors.json. Always labelled as a sponsor; links open in a new tab with rel="sponsored".',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
|
||||||
|
variant: {
|
||||||
|
description: 'banner (dashboard), compact (sidebar/login) or card (Sponsors page).',
|
||||||
|
},
|
||||||
|
iconOnly: { description: 'Logo-only rendering for the collapsed sidebar rail.' },
|
||||||
|
onClose: { description: 'When set, shows a close button (temporary dismiss).' },
|
||||||
|
},
|
||||||
|
args: { sponsor },
|
||||||
|
} satisfies Meta<typeof SponsorCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Banner: Story = {
|
||||||
|
args: { variant: 'banner', onClose: () => {} },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByText('Sponsor')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByRole('link')).toHaveAttribute('rel', 'noopener noreferrer sponsored');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Compact: Story = {
|
||||||
|
args: { variant: 'compact', onClose: () => {} },
|
||||||
|
render: (args) => (
|
||||||
|
<div style={{ width: 204 }}>
|
||||||
|
<SponsorCard {...args} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Card: Story = {
|
||||||
|
args: { variant: 'card' },
|
||||||
|
render: (args) => (
|
||||||
|
<div style={{ width: 320 }}>
|
||||||
|
<SponsorCard {...args} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IconOnly: Story = {
|
||||||
|
args: { iconOnly: true },
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { CloseOutlined, ExportOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { withBasePath } from '@/api/http-init';
|
||||||
|
import type { Sponsor } from '@/generated/types';
|
||||||
|
import { pickLocale } from '@/lib/sponsors';
|
||||||
|
import './SponsorCard.css';
|
||||||
|
|
||||||
|
export type SponsorCardVariant = 'banner' | 'compact' | 'card';
|
||||||
|
|
||||||
|
export interface SponsorCardProps {
|
||||||
|
sponsor: Sponsor;
|
||||||
|
variant?: SponsorCardVariant;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
|
||||||
|
const [failedSrc, setFailedSrc] = useState('');
|
||||||
|
if (sponsor.logo && failedSrc !== sponsor.logo) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className="sponsor-logo"
|
||||||
|
src={withBasePath(sponsor.logo)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setFailedSrc(sponsor.logo ?? '')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="sponsor-logo sponsor-logo-fallback" aria-hidden="true">
|
||||||
|
{sponsor.name.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SponsorCard({
|
||||||
|
sponsor,
|
||||||
|
variant = 'banner',
|
||||||
|
iconOnly = false,
|
||||||
|
onClose,
|
||||||
|
}: SponsorCardProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const lang = i18n.resolvedLanguage || i18n.language || 'en';
|
||||||
|
const title = pickLocale(sponsor.title, lang) || sponsor.name;
|
||||||
|
const text = pickLocale(sponsor.text, lang);
|
||||||
|
const tag = t('pages.sponsors.tag');
|
||||||
|
|
||||||
|
if (iconOnly) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="sponsor-card sponsor-card-icon"
|
||||||
|
href={sponsor.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer sponsored"
|
||||||
|
title={`${tag} · ${title}`}
|
||||||
|
aria-label={`${tag}: ${title}`}
|
||||||
|
>
|
||||||
|
<SponsorLogo sponsor={sponsor} />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`sponsor-card sponsor-card-${variant}`}>
|
||||||
|
<a
|
||||||
|
className="sponsor-main"
|
||||||
|
href={sponsor.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer sponsored"
|
||||||
|
>
|
||||||
|
<SponsorLogo sponsor={sponsor} />
|
||||||
|
<span className="sponsor-body" dir="auto">
|
||||||
|
<span className="sponsor-head">
|
||||||
|
<span className="sponsor-tag">{tag}</span>
|
||||||
|
<span className="sponsor-title">{title}</span>
|
||||||
|
</span>
|
||||||
|
{text && <span className="sponsor-text">{text}</span>}
|
||||||
|
</span>
|
||||||
|
{variant !== 'compact' && (
|
||||||
|
<span className="sponsor-visit">
|
||||||
|
{t('pages.sponsors.visit')} <ExportOutlined />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sponsor-close"
|
||||||
|
aria-label={t('close')}
|
||||||
|
title={t('close')}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
||||||
|
import {
|
||||||
|
dismissSponsor,
|
||||||
|
isSponsorDismissed,
|
||||||
|
sponsorsForSlot,
|
||||||
|
type SponsorSlot as Slot,
|
||||||
|
} from '@/lib/sponsors';
|
||||||
|
import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
|
||||||
|
|
||||||
|
const ROTATE_MS = 30_000;
|
||||||
|
|
||||||
|
interface SponsorSlotProps {
|
||||||
|
slot: Slot;
|
||||||
|
variant?: SponsorCardVariant;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
rotate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SponsorSlot({
|
||||||
|
slot,
|
||||||
|
variant = 'banner',
|
||||||
|
iconOnly,
|
||||||
|
rotate,
|
||||||
|
className,
|
||||||
|
}: SponsorSlotProps) {
|
||||||
|
const { data } = useSponsorsQuery();
|
||||||
|
const [, setDismissTick] = useState(0);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
// Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
|
||||||
|
const visible = sponsorsForSlot(data.sponsors, slot).filter(
|
||||||
|
(s) => !isSponsorDismissed(s.id, slot),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rotate || visible.length < 2) return;
|
||||||
|
const timer = window.setInterval(() => setIndex((i) => i + 1), ROTATE_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [rotate, visible.length]);
|
||||||
|
|
||||||
|
if (visible.length === 0) return null;
|
||||||
|
const sponsor = visible[(rotate ? index : 0) % visible.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<SponsorCard
|
||||||
|
sponsor={sponsor}
|
||||||
|
variant={variant}
|
||||||
|
iconOnly={iconOnly}
|
||||||
|
onClose={() => {
|
||||||
|
dismissSponsor(sponsor.id, slot);
|
||||||
|
setDismissTick((n) => n + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1019,6 +1019,37 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"key": "",
|
"key": "",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
|
"Sponsor": {
|
||||||
|
"enable": true,
|
||||||
|
"id": "acme-2026-10",
|
||||||
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
"name": "Acme VPS",
|
||||||
|
"slots": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"text": {},
|
||||||
|
"title": {},
|
||||||
|
"until": "2026-11-01T00:00:00Z"
|
||||||
|
},
|
||||||
|
"SponsorList": {
|
||||||
|
"contact": "https://t.me/example",
|
||||||
|
"sponsors": [
|
||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"id": "acme-2026-10",
|
||||||
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
"name": "Acme VPS",
|
||||||
|
"slots": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"text": {},
|
||||||
|
"title": {},
|
||||||
|
"until": "2026-11-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"SubBalancer": {
|
"SubBalancer": {
|
||||||
"createdAt": 1710000000000,
|
"createdAt": 1710000000000,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -4109,6 +4109,84 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Sponsor": {
|
||||||
|
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||||
|
"properties": {
|
||||||
|
"enable": {
|
||||||
|
"example": true,
|
||||||
|
"nullable": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"example": "acme-2026-10",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"example": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"example": "/sponsors/logo/acme.png",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"example": "Acme VPS",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"until": {
|
||||||
|
"example": "2026-11-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"link",
|
||||||
|
"name",
|
||||||
|
"slots",
|
||||||
|
"text",
|
||||||
|
"title",
|
||||||
|
"until"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SponsorList": {
|
||||||
|
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
|
||||||
|
"properties": {
|
||||||
|
"contact": {
|
||||||
|
"example": "https://t.me/example",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Sponsor"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"sponsors"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SubBalancer": {
|
"SubBalancer": {
|
||||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -939,6 +939,23 @@ export interface Setting {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Sponsor {
|
||||||
|
enable?: boolean | null;
|
||||||
|
id: string;
|
||||||
|
link: string;
|
||||||
|
logo?: string;
|
||||||
|
name: string;
|
||||||
|
slots: string[];
|
||||||
|
text: Record<string, string>;
|
||||||
|
title: Record<string, string>;
|
||||||
|
until: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SponsorList {
|
||||||
|
contact?: string;
|
||||||
|
sponsors: Sponsor[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubBalancer {
|
export interface SubBalancer {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -998,6 +998,25 @@ export const SettingSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type Setting = z.infer<typeof SettingSchema>;
|
export type Setting = z.infer<typeof SettingSchema>;
|
||||||
|
|
||||||
|
export const SponsorSchema = z.object({
|
||||||
|
enable: z.boolean().nullable().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
link: z.string(),
|
||||||
|
logo: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
slots: z.array(z.string()),
|
||||||
|
text: z.record(z.string(), z.string()),
|
||||||
|
title: z.record(z.string(), z.string()),
|
||||||
|
until: z.string(),
|
||||||
|
});
|
||||||
|
export type Sponsor = z.infer<typeof SponsorSchema>;
|
||||||
|
|
||||||
|
export const SponsorListSchema = z.object({
|
||||||
|
contact: z.string().optional(),
|
||||||
|
sponsors: z.array(z.lazy(() => SponsorSchema)),
|
||||||
|
});
|
||||||
|
export type SponsorList = z.infer<typeof SponsorListSchema>;
|
||||||
|
|
||||||
export const SubBalancerSchema = z.object({
|
export const SubBalancerSchema = z.object({
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const TITLE_KEYS: Record<string, string> = {
|
|||||||
'/outbound': 'menu.outbounds',
|
'/outbound': 'menu.outbounds',
|
||||||
'/routing': 'menu.routing',
|
'/routing': 'menu.routing',
|
||||||
'/api-docs': 'menu.apiDocs',
|
'/api-docs': 'menu.apiDocs',
|
||||||
|
'/sponsors': 'menu.sponsors',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePageTitle() {
|
export function usePageTitle() {
|
||||||
|
|||||||
@@ -247,6 +247,10 @@
|
|||||||
padding: 8px 8px 12px;
|
padding: 8px 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sider-sponsor {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-pin {
|
.sidebar-pin {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
|
CrownOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
DiscordOutlined,
|
DiscordOutlined,
|
||||||
@@ -43,6 +44,7 @@ import { formatPanelVersion } from '@/lib/panel-version';
|
|||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
||||||
|
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||||
import './AppSidebar.css';
|
import './AppSidebar.css';
|
||||||
|
|
||||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||||
@@ -68,6 +70,7 @@ type IconName =
|
|||||||
| 'cluster'
|
| 'cluster'
|
||||||
| 'hosts'
|
| 'hosts'
|
||||||
| 'logout'
|
| 'logout'
|
||||||
|
| 'sponsors'
|
||||||
| 'apidocs'
|
| 'apidocs'
|
||||||
| 'outbound'
|
| 'outbound'
|
||||||
| 'routing';
|
| 'routing';
|
||||||
@@ -82,6 +85,7 @@ const iconByName: Record<IconName, ComponentType> = {
|
|||||||
cluster: ClusterOutlined,
|
cluster: ClusterOutlined,
|
||||||
hosts: GlobalOutlined,
|
hosts: GlobalOutlined,
|
||||||
logout: LogoutOutlined,
|
logout: LogoutOutlined,
|
||||||
|
sponsors: CrownOutlined,
|
||||||
apidocs: ApiOutlined,
|
apidocs: ApiOutlined,
|
||||||
outbound: ExportOutlined,
|
outbound: ExportOutlined,
|
||||||
routing: SwapOutlined,
|
routing: SwapOutlined,
|
||||||
@@ -232,6 +236,7 @@ export default function AppSidebar() {
|
|||||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||||
|
{ key: '/sponsors', icon: 'sponsors', title: t('menu.sponsors') },
|
||||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||||
],
|
],
|
||||||
[t],
|
[t],
|
||||||
@@ -447,6 +452,13 @@ export default function AppSidebar() {
|
|||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
/>
|
/>
|
||||||
<div className="sider-footer">
|
<div className="sider-footer">
|
||||||
|
<SponsorSlot
|
||||||
|
slot="sidebar"
|
||||||
|
variant="compact"
|
||||||
|
iconOnly={railCollapsed}
|
||||||
|
rotate
|
||||||
|
className="sider-sponsor"
|
||||||
|
/>
|
||||||
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
</Layout.Sider>
|
</Layout.Sider>
|
||||||
@@ -532,6 +544,7 @@ export default function AppSidebar() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="drawer-footer">
|
<div className="drawer-footer">
|
||||||
|
<SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
|
||||||
<VersionBadge version={panelVersion} />
|
<VersionBadge version={panelVersion} />
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Sponsor } from '@/generated/types';
|
||||||
|
|
||||||
|
export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
|
||||||
|
|
||||||
|
const DISMISS_KEY = 'xui.sponsor.dismissed';
|
||||||
|
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function pickLocale(map: Record<string, string> | undefined, lang: string): string {
|
||||||
|
if (!map) return '';
|
||||||
|
const short = lang.split('-')[0].toLowerCase();
|
||||||
|
return map[lang] || map[short] || map.en || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sponsorsForSlot(sponsors: Sponsor[], slot: SponsorSlot): Sponsor[] {
|
||||||
|
return sponsors.filter((s) => s.slots.includes(slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDismissed(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(localStorage.getItem(DISMISS_KEY) || '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as Record<string, number>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSponsorDismissed(id: string, slot: SponsorSlot, now = Date.now()): boolean {
|
||||||
|
const at = readDismissed()[`${id}:${slot}`];
|
||||||
|
return typeof at === 'number' && now - at < SPONSOR_DISMISS_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now()) {
|
||||||
|
const next = Object.fromEntries(
|
||||||
|
Object.entries(readDismissed()).filter(([, at]) => now - at < SPONSOR_DISMISS_MS),
|
||||||
|
);
|
||||||
|
next[`${id}:${slot}`] = now;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the backend: dashboard/login show one sponsor, the sidebar rotates up to three.
|
||||||
|
export const SLOT_CAPACITY: Partial<Record<SponsorSlot, number>> = {
|
||||||
|
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] };
|
||||||
|
}
|
||||||
@@ -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.',
|
'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}',
|
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',
|
method: 'POST',
|
||||||
path: '/getTwoFactorEnable',
|
path: '/getTwoFactorEnable',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
|||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
import { LazyMount } from '@/components/utility';
|
import { LazyMount } from '@/components/utility';
|
||||||
|
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import OverviewActionBar from './OverviewActionBar';
|
import OverviewActionBar from './OverviewActionBar';
|
||||||
import VitalTile from './VitalTile';
|
import VitalTile from './VitalTile';
|
||||||
@@ -213,6 +214,8 @@ export default function IndexPage() {
|
|||||||
onOpenVersionSwitch={() => setVersionOpen(true)}
|
onOpenVersionSwitch={() => setVersionOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SponsorSlot slot="dashboard" />
|
||||||
|
|
||||||
{health && (
|
{health && (
|
||||||
<div className="ov-health" style={{ color: health.color }}>
|
<div className="ov-health" style={{ color: health.color }}>
|
||||||
<span className="ov-health-mark" />
|
<span className="ov-health-mark" />
|
||||||
|
|||||||
@@ -411,3 +411,7 @@
|
|||||||
.submit-row {
|
.submit-row {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-sponsor {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
|||||||
import { HttpUtil, LanguageManager } from '@/utils';
|
import { HttpUtil, LanguageManager } from '@/utils';
|
||||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
|
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
|
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
|
||||||
import './LoginPage.css';
|
import './LoginPage.css';
|
||||||
@@ -247,6 +248,7 @@ export default function LoginPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
<SponsorSlot slot="login" variant="compact" className="login-sponsor" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CrownOutlined />}
|
||||||
|
href={contact}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
block={block}
|
||||||
|
>
|
||||||
|
{t('pages.sponsors.become')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
|
||||||
|
<span className="sponsors-yourbrand-icon">
|
||||||
|
<PlusOutlined />
|
||||||
|
</span>
|
||||||
|
<div className="sponsors-yourbrand-title">{t('pages.sponsors.yourBrand')}</div>
|
||||||
|
<div className="sponsors-yourbrand-text">{t('pages.sponsors.yourBrandText')}</div>
|
||||||
|
<BecomeButton contact={contact} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: <DashboardOutlined />,
|
||||||
|
title: t('pages.sponsors.placementDashboard'),
|
||||||
|
desc: t('pages.sponsors.placementDashboardDesc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: 'sidebar',
|
||||||
|
icon: <MenuUnfoldOutlined />,
|
||||||
|
title: t('pages.sponsors.placementSidebar'),
|
||||||
|
desc: t('pages.sponsors.placementSidebarDesc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: 'login',
|
||||||
|
icon: <LoginOutlined />,
|
||||||
|
title: t('pages.sponsors.placementLogin'),
|
||||||
|
desc: t('pages.sponsors.placementLoginDesc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: 'page',
|
||||||
|
icon: <CrownOutlined />,
|
||||||
|
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 (
|
||||||
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
<Layout className={pageClass}>
|
||||||
|
<AppSidebar />
|
||||||
|
<Layout className="content-shell">
|
||||||
|
<Layout.Content className="content-area">
|
||||||
|
<div className="sponsors-inner">
|
||||||
|
<div className="sponsors-header">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={3} className="sponsors-title">
|
||||||
|
<CrownOutlined className="sponsors-title-icon" /> {t('pages.sponsors.title')}
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">{t('pages.sponsors.intro')}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<BecomeButton contact={data.contact} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin spinning={!fetched} delay={200}>
|
||||||
|
{sponsors.length === 0 ? (
|
||||||
|
fetched && <YourBrandCard contact={data.contact} large />
|
||||||
|
) : (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{sponsors.map((sponsor) => (
|
||||||
|
<Col key={sponsor.id} xs={24} sm={12} xl={8}>
|
||||||
|
<SponsorCard sponsor={sponsor} variant="card" />
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
{data.contact && (
|
||||||
|
<Col xs={24} sm={12} xl={8}>
|
||||||
|
<YourBrandCard contact={data.contact} />
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<Typography.Title level={5} className="sponsors-section-title">
|
||||||
|
{t('pages.sponsors.placements')}
|
||||||
|
</Typography.Title>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{placements.map((p) => {
|
||||||
|
const status = placementStatus(data.sponsors, p.slot);
|
||||||
|
return (
|
||||||
|
<Col key={p.slot} xs={24} sm={12} xl={6}>
|
||||||
|
<div className="sponsors-placement">
|
||||||
|
<span className="sponsors-placement-icon">{p.icon}</span>
|
||||||
|
<div className="sponsors-placement-body">
|
||||||
|
<div className="sponsors-placement-title">{p.title}</div>
|
||||||
|
<div className="sponsors-placement-desc">{p.desc}</div>
|
||||||
|
<div className="sponsors-placement-status">
|
||||||
|
{status.takenUntil ? (
|
||||||
|
<Tag color="orange">
|
||||||
|
{t('pages.sponsors.takenUntil', {
|
||||||
|
date: IntlUtil.formatDate(status.takenUntil, datepicker),
|
||||||
|
})}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="green">{t('pages.sponsors.available')}</Tag>
|
||||||
|
)}
|
||||||
|
{status.count > 0 && !status.takenUntil && (
|
||||||
|
<Tag>
|
||||||
|
{t('pages.sponsors.activeCount', {
|
||||||
|
count:
|
||||||
|
status.capacity && status.capacity > 1
|
||||||
|
? `${status.count}/${status.capacity}`
|
||||||
|
: status.count,
|
||||||
|
})}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
|
|||||||
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
|
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
|
||||||
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
|
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
|
||||||
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
||||||
|
const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage'));
|
||||||
|
|
||||||
function withSuspense(node: React.ReactNode) {
|
function withSuspense(node: React.ReactNode) {
|
||||||
return (
|
return (
|
||||||
@@ -51,6 +52,7 @@ const routes: RouteObject[] = [
|
|||||||
{ path: 'outbound', element: withSuspense(<XrayPage />) },
|
{ path: 'outbound', element: withSuspense(<XrayPage />) },
|
||||||
{ path: 'routing', element: withSuspense(<XrayPage />) },
|
{ path: 'routing', element: withSuspense(<XrayPage />) },
|
||||||
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
|
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
|
||||||
|
{ path: 'sponsors', element: withSuspense(<SponsorsPage />) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
.settings-page,
|
.settings-page,
|
||||||
.nodes-page,
|
.nodes-page,
|
||||||
.groups-page,
|
.groups-page,
|
||||||
.api-docs-page {
|
.api-docs-page,
|
||||||
|
.sponsors-page {
|
||||||
--bg-page: #e6e8ec;
|
--bg-page: #e6e8ec;
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
.settings-page.is-dark,
|
.settings-page.is-dark,
|
||||||
.nodes-page.is-dark,
|
.nodes-page.is-dark,
|
||||||
.groups-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-page: #1a1b1f;
|
||||||
--bg-card: #23252b;
|
--bg-card: #23252b;
|
||||||
}
|
}
|
||||||
@@ -31,7 +33,8 @@
|
|||||||
.settings-page.is-dark.is-ultra,
|
.settings-page.is-dark.is-ultra,
|
||||||
.nodes-page.is-dark.is-ultra,
|
.nodes-page.is-dark.is-ultra,
|
||||||
.groups-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-page: #000;
|
||||||
--bg-card: #101013;
|
--bg-card: #101013;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -286,7 +286,7 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
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),
|
||||||
'^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
|
'^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
|
||||||
'^/(?:[^/]+/)?ws$': {
|
'^/(?:[^/]+/)?ws$': {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"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/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||||
@@ -28,6 +29,7 @@ type IndexController struct {
|
|||||||
|
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
userService panel.UserService
|
userService panel.UserService
|
||||||
|
panelService panel.PanelService
|
||||||
tgbot tgbot.Tgbot
|
tgbot tgbot.Tgbot
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,12 +44,36 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
|
|||||||
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||||
g.GET("/", a.index)
|
g.GET("/", a.index)
|
||||||
g.GET("/csrf-token", a.csrfToken)
|
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("/login", middleware.CSRFMiddleware(), a.login)
|
||||||
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
|
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
|
||||||
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
|
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.
|
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
|
||||||
func (a *IndexController) index(c *gin.Context) {
|
func (a *IndexController) index(c *gin.Context) {
|
||||||
if session.IsLogin(c) {
|
if session.IsLogin(c) {
|
||||||
|
|||||||
@@ -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.
|
constructs the real router and diffs it against the registry both ways.
|
||||||
|
|
||||||
Scope: everything under /panel/api/ plus the session-auth surface the
|
Scope: everything under /panel/api/ plus the session-auth surface the
|
||||||
registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
|
registry also documents (/login, /logout, /csrf-token, /sponsors,
|
||||||
/ws). SPA page routes are UI, not API, and stay out; registry paths that
|
/getTwoFactorEnable, /ws). SPA page routes are UI, not API, and stay out;
|
||||||
start with "/{" describe the standalone subscription server, which this
|
registry paths that start with "/{" describe the standalone subscription
|
||||||
engine does not serve.
|
server, which this engine does not serve.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
var contractExtraRoutes = map[string]bool{
|
var contractExtraRoutes = map[string]bool{
|
||||||
"POST /login": true,
|
"POST /login": true,
|
||||||
"POST /logout": true,
|
"POST /logout": true,
|
||||||
"GET /csrf-token": true,
|
"GET /csrf-token": true,
|
||||||
|
"GET /sponsors": true,
|
||||||
"POST /getTwoFactorEnable": true,
|
"POST /getTwoFactorEnable": true,
|
||||||
"GET /ws": true,
|
"GET /ws": true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("<svg onload=alert(1)>"))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
prevURL, prevBase, prevNow := sponsorsURL, sponsorLogoBase, sponsorNow
|
||||||
|
sponsorsURL, sponsorLogoBase = srv.URL+"/sponsors.json", srv.URL+"/media/"
|
||||||
|
resetSponsorCache()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sponsorsURL, sponsorLogoBase, sponsorNow = prevURL, prevBase, prevNow
|
||||||
|
failSponsorList.Store(false)
|
||||||
|
failSponsorLogos.Store(false)
|
||||||
|
resetSponsorCache()
|
||||||
|
})
|
||||||
|
return &hits
|
||||||
|
}
|
||||||
|
|
||||||
|
var failSponsorList, failSponsorLogos atomic.Bool
|
||||||
|
|
||||||
|
func TestGetSponsorsKeepsLastListWhenRefreshFails(t *testing.T) {
|
||||||
|
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
|
||||||
|
"until":"2099-01-01T00:00:00Z","link":"https://acme.example/"}]}`)
|
||||||
|
now := sponsorTestNow
|
||||||
|
sponsorNow = func() time.Time { return now }
|
||||||
|
svc := &PanelService{}
|
||||||
|
if got, err := svc.GetSponsors(); err != nil || len(got.Sponsors) != 1 {
|
||||||
|
t.Fatalf("first call = %+v, %v; want 1 sponsor", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
failSponsorList.Store(true)
|
||||||
|
now = now.Add(sponsorsTTL)
|
||||||
|
got, err := svc.GetSponsors()
|
||||||
|
if err != nil || len(got.Sponsors) != 1 || got.Sponsors[0].ID != "acme" {
|
||||||
|
t.Fatalf("after failed refresh = %+v, %v; want the last good sponsor kept", got, err)
|
||||||
|
}
|
||||||
|
now = now.Add(sponsorsErrTTL - time.Second)
|
||||||
|
if _, err := svc.GetSponsors(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := hits.Load(); n != 2 {
|
||||||
|
t.Fatalf("remote hits = %d, want 2 (failed refresh retried only after sponsorsErrTTL)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSponsorLogoCachesFailuresAndKeepsLastImage(t *testing.T) {
|
||||||
|
hits := setupSponsorServer(t, `{"sponsors":[
|
||||||
|
{"id":"acme","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/","logo":"acme.png"},
|
||||||
|
{"id":"fake","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://f.example/","logo":"fake.png"}]}`)
|
||||||
|
now := sponsorTestNow
|
||||||
|
sponsorNow = func() time.Time { return now }
|
||||||
|
svc := &PanelService{}
|
||||||
|
|
||||||
|
const wantErr = "sponsor logo fake.png has content type text/plain; charset=utf-8"
|
||||||
|
for range 2 {
|
||||||
|
if _, _, err := svc.GetSponsorLogo("fake.png"); err == nil || err.Error() != wantErr {
|
||||||
|
t.Fatalf("fake.png err = %v, want %q", err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := hits.Load(); n != 2 {
|
||||||
|
t.Fatalf("remote hits = %d, want 2 (list + one fake.png fetch; the failure must be cached)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := svc.GetSponsorLogo("acme.png"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
failSponsorLogos.Store(true)
|
||||||
|
now = now.Add(sponsorsTTL)
|
||||||
|
data, ctype, err := svc.GetSponsorLogo("acme.png")
|
||||||
|
if err != nil || ctype != "image/png" || string(data) != string(pngMagic) {
|
||||||
|
t.Fatalf("acme.png after failed refresh = %q, %q, %v; want the last good image", data, ctype, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetSponsorCache() {
|
||||||
|
sponsorsMu.Lock()
|
||||||
|
defer sponsorsMu.Unlock()
|
||||||
|
sponsorsRaw, sponsorsErr, sponsorsRetryAt = nil, nil, time.Time{}
|
||||||
|
logosMu.Lock()
|
||||||
|
defer logosMu.Unlock()
|
||||||
|
logos = map[string]sponsorLogo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pngMagic = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
|
||||||
|
|
||||||
|
func TestGetSponsorLogo(t *testing.T) {
|
||||||
|
hits := setupSponsorServer(t, `{"sponsors":[
|
||||||
|
{"id":"acme","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/","logo":"acme.png"},
|
||||||
|
{"id":"fake","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://f.example/","logo":"fake.png"},
|
||||||
|
{"id":"old","slots":["page"],"until":"2000-01-01T00:00:00Z","link":"https://o.example/","logo":"old.png"}]}`)
|
||||||
|
svc := &PanelService{}
|
||||||
|
|
||||||
|
data, ctype, err := svc.GetSponsorLogo("acme.png")
|
||||||
|
if err != nil || ctype != "image/png" || string(data) != string(pngMagic) {
|
||||||
|
t.Fatalf("acme.png = %q, %q, %v; want png bytes", data, ctype, err)
|
||||||
|
}
|
||||||
|
before := hits.Load()
|
||||||
|
if _, _, err := svc.GetSponsorLogo("acme.png"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits.Load() != before {
|
||||||
|
t.Fatalf("second logo fetch hit the remote; want cached")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"old.png", "other.png", "../sponsors.json"} {
|
||||||
|
if _, _, err := svc.GetSponsorLogo(name); !errors.Is(err, ErrSponsorLogoUnknown) {
|
||||||
|
t.Errorf("%s: err = %v, want ErrSponsorLogoUnknown", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _, err = svc.GetSponsorLogo("fake.png")
|
||||||
|
if want := "sponsor logo fake.png has content type text/plain; charset=utf-8"; err == nil || err.Error() != want {
|
||||||
|
t.Errorf("fake.png: err = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSponsorsCachesAndExpiresWhileCached(t *testing.T) {
|
||||||
|
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
|
||||||
|
"until":"2026-10-15T00:30:00Z","link":"https://acme.example/"}]}`)
|
||||||
|
now := sponsorTestNow
|
||||||
|
sponsorNow = func() time.Time { return now }
|
||||||
|
svc := &PanelService{}
|
||||||
|
|
||||||
|
got, err := svc.GetSponsors()
|
||||||
|
if err != nil || len(got.Sponsors) != 1 {
|
||||||
|
t.Fatalf("first call = %+v, %v; want 1 sponsor", got, err)
|
||||||
|
}
|
||||||
|
now = now.Add(45 * time.Minute)
|
||||||
|
got, err = svc.GetSponsors()
|
||||||
|
if err != nil || len(got.Sponsors) != 0 {
|
||||||
|
t.Fatalf("after until = %+v, %v; want 0 sponsors", got, err)
|
||||||
|
}
|
||||||
|
if n := hits.Load(); n != 1 {
|
||||||
|
t.Fatalf("remote hits = %d, want 1 (cached within TTL)", n)
|
||||||
|
}
|
||||||
|
now = now.Add(sponsorsTTL)
|
||||||
|
if _, err := svc.GetSponsors(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := hits.Load(); n != 2 {
|
||||||
|
t.Fatalf("remote hits after TTL = %d, want 2", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSponsorsRejectsOversizeBody(t *testing.T) {
|
||||||
|
setupSponsorServer(t, `{"contact":"`+strings.Repeat("a", maxSponsorsBytes)+`"}`)
|
||||||
|
_, err := (&PanelService{}).GetSponsors()
|
||||||
|
want := sponsorsURL + " exceeds 262144 bytes"
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Fatalf("err = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSponsorsDebugReadsLocalCheckout(t *testing.T) {
|
||||||
|
hits := setupSponsorServer(t, `{"sponsors":[]}`)
|
||||||
|
t.Setenv("XUI_DEBUG", "true")
|
||||||
|
root := t.TempDir()
|
||||||
|
local := filepath.Join(root, "sponsors", "3X")
|
||||||
|
if err := os.MkdirAll(local, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "3x-ui"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Chdir(filepath.Join(root, "3x-ui"))
|
||||||
|
write := func(id string) {
|
||||||
|
t.Helper()
|
||||||
|
body := `{"sponsors":[{"id":"` + id + `","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/"}]}`
|
||||||
|
if err := os.WriteFile(filepath.Join(local, "sponsors.json"), []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svc := &PanelService{}
|
||||||
|
for _, id := range []string{"first", "edited"} {
|
||||||
|
write(id)
|
||||||
|
got, err := svc.GetSponsors()
|
||||||
|
if err != nil || len(got.Sponsors) != 1 || got.Sponsors[0].ID != id {
|
||||||
|
t.Fatalf("GetSponsors() = %+v, %v; want local sponsor %q", got, err, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := hits.Load(); n != 0 {
|
||||||
|
t.Fatalf("remote hits = %d, want 0 in debug mode", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "الصادرات",
|
"outbounds": "الصادرات",
|
||||||
"apiDocs": "توثيق API",
|
"apiDocs": "توثيق API",
|
||||||
"donate": "تبرع",
|
"donate": "تبرع",
|
||||||
|
"sponsors": "الرعاة",
|
||||||
"hosts": "المضيفات",
|
"hosts": "المضيفات",
|
||||||
"docs": "التوثيق",
|
"docs": "التوثيق",
|
||||||
"openMenu": "فتح القائمة",
|
"openMenu": "فتح القائمة",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "وسم غير صالح",
|
"badTag": "وسم غير صالح",
|
||||||
"badVlessRoute": "أدخل رقماً واحداً بين 0 و65535"
|
"badVlessRoute": "أدخل رقماً واحداً بين 0 و65535"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "الرعاة",
|
||||||
|
"intro": "شركات تدعم تطوير 3X-UI. تُفتح روابط الرعاة في علامة تبويب جديدة.",
|
||||||
|
"become": "كن راعيًا",
|
||||||
|
"tag": "راعٍ",
|
||||||
|
"visit": "زيارة",
|
||||||
|
"yourBrand": "علامتك التجارية هنا",
|
||||||
|
"yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم.",
|
||||||
|
"placements": "المواضع المتاحة",
|
||||||
|
"placementDashboard": "لافتة لوحة المعلومات",
|
||||||
|
"placementDashboardDesc": "موضع مميز أعلى لوحة المعلومات، راعٍ واحد في كل مرة.",
|
||||||
|
"placementSidebar": "بطاقة الشريط الجانبي",
|
||||||
|
"placementSidebarDesc": "تظهر في كل صفحات اللوحة وتتناوب بين الرعاة.",
|
||||||
|
"placementLogin": "صفحة تسجيل الدخول",
|
||||||
|
"placementLoginDesc": "يراها المسؤول عند كل تسجيل دخول.",
|
||||||
|
"placementPage": "صفحة الرعاة",
|
||||||
|
"placementPageDesc": "مدرجة هنا مع الشعار والوصف والرابط.",
|
||||||
|
"available": "متاح",
|
||||||
|
"takenUntil": "محجوز حتى {date}",
|
||||||
|
"activeCount": "{count} نشط"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
"outbounds": "Outbounds",
|
"outbounds": "Outbounds",
|
||||||
"apiDocs": "API Docs",
|
"apiDocs": "API Docs",
|
||||||
"donate": "Donate",
|
"donate": "Donate",
|
||||||
|
"sponsors": "Sponsors",
|
||||||
"docs": "Documentation",
|
"docs": "Documentation",
|
||||||
"openMenu": "Open menu",
|
"openMenu": "Open menu",
|
||||||
"pinSidebar": "Pin sidebar",
|
"pinSidebar": "Pin sidebar",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
},
|
},
|
||||||
"defaultOutbound": "Default Outbound",
|
"defaultOutbound": "Default Outbound",
|
||||||
"defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list)."
|
"defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list)."
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "Sponsors",
|
||||||
|
"intro": "Companies that support 3X-UI development. Sponsored links open in a new tab.",
|
||||||
|
"become": "Become a sponsor",
|
||||||
|
"tag": "Sponsor",
|
||||||
|
"visit": "Visit",
|
||||||
|
"yourBrand": "Your brand here",
|
||||||
|
"yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day.",
|
||||||
|
"placements": "Available placements",
|
||||||
|
"placementDashboard": "Overview banner",
|
||||||
|
"placementDashboardDesc": "Premium spot at the top of the dashboard, one sponsor at a time.",
|
||||||
|
"placementSidebar": "Sidebar card",
|
||||||
|
"placementSidebarDesc": "Shown on every panel page, rotating between sponsors.",
|
||||||
|
"placementLogin": "Login page",
|
||||||
|
"placementLoginDesc": "Seen by the admin at every sign-in.",
|
||||||
|
"placementPage": "Sponsors page",
|
||||||
|
"placementPageDesc": "Listed here with logo, description and link.",
|
||||||
|
"available": "Available",
|
||||||
|
"takenUntil": "Taken until {date}",
|
||||||
|
"activeCount": "{count} active"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "Salidas",
|
"outbounds": "Salidas",
|
||||||
"apiDocs": "Documentación de la API",
|
"apiDocs": "Documentación de la API",
|
||||||
"donate": "Donar",
|
"donate": "Donar",
|
||||||
|
"sponsors": "Patrocinadores",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"docs": "Documentación",
|
"docs": "Documentación",
|
||||||
"openMenu": "Abrir menú",
|
"openMenu": "Abrir menú",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "Etiqueta no válida",
|
"badTag": "Etiqueta no válida",
|
||||||
"badVlessRoute": "Introduce un único número entre 0 y 65535"
|
"badVlessRoute": "Introduce un único número entre 0 y 65535"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "Patrocinadores",
|
||||||
|
"intro": "Empresas que apoyan el desarrollo de 3X-UI. Los enlaces patrocinados se abren en una pestaña nueva.",
|
||||||
|
"become": "Hazte patrocinador",
|
||||||
|
"tag": "Patrocinador",
|
||||||
|
"visit": "Visitar",
|
||||||
|
"yourBrand": "Tu marca aquí",
|
||||||
|
"yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día.",
|
||||||
|
"placements": "Espacios disponibles",
|
||||||
|
"placementDashboard": "Banner del panel",
|
||||||
|
"placementDashboardDesc": "Espacio premium en la parte superior del panel, un patrocinador a la vez.",
|
||||||
|
"placementSidebar": "Tarjeta lateral",
|
||||||
|
"placementSidebarDesc": "Se muestra en todas las páginas del panel, rotando entre patrocinadores.",
|
||||||
|
"placementLogin": "Página de inicio de sesión",
|
||||||
|
"placementLoginDesc": "La ve el administrador en cada inicio de sesión.",
|
||||||
|
"placementPage": "Página de patrocinadores",
|
||||||
|
"placementPageDesc": "Aparece aquí con logo, descripción y enlace.",
|
||||||
|
"available": "Disponible",
|
||||||
|
"takenUntil": "Ocupado hasta {date}",
|
||||||
|
"activeCount": "{count} activos"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "خروجیها",
|
"outbounds": "خروجیها",
|
||||||
"apiDocs": "مستندات API",
|
"apiDocs": "مستندات API",
|
||||||
"donate": "حمایت مالی",
|
"donate": "حمایت مالی",
|
||||||
|
"sponsors": "اسپانسرها",
|
||||||
"hosts": "میزبانها",
|
"hosts": "میزبانها",
|
||||||
"docs": "مستندات",
|
"docs": "مستندات",
|
||||||
"openMenu": "باز کردن منو",
|
"openMenu": "باز کردن منو",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "برچسب نامعتبر",
|
"badTag": "برچسب نامعتبر",
|
||||||
"badVlessRoute": "یک عدد بین 0 تا 65535 وارد کنید"
|
"badVlessRoute": "یک عدد بین 0 تا 65535 وارد کنید"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "اسپانسرها",
|
||||||
|
"intro": "شرکتهایی که از توسعهی 3X-UI حمایت میکنند. لینکهای اسپانسر در تب جدید باز میشوند.",
|
||||||
|
"become": "اسپانسر شوید",
|
||||||
|
"tag": "اسپانسر",
|
||||||
|
"visit": "مشاهده",
|
||||||
|
"yourBrand": "جای برند شما",
|
||||||
|
"yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز میکنند.",
|
||||||
|
"placements": "جایگاههای موجود",
|
||||||
|
"placementDashboard": "بنر داشبورد",
|
||||||
|
"placementDashboardDesc": "جایگاه ویژه بالای صفحهی داشبورد، هر بار فقط یک اسپانسر.",
|
||||||
|
"placementSidebar": "کارت سایدبار",
|
||||||
|
"placementSidebarDesc": "در همهی صفحات پنل نمایش داده میشود و بین اسپانسرها چرخش دارد.",
|
||||||
|
"placementLogin": "صفحهی ورود",
|
||||||
|
"placementLoginDesc": "ادمین در هر بار ورود آن را میبیند.",
|
||||||
|
"placementPage": "صفحهی اسپانسرها",
|
||||||
|
"placementPageDesc": "همراه با لوگو، توضیحات و لینک در همین صفحه.",
|
||||||
|
"available": "خالی",
|
||||||
|
"takenUntil": "پر شده تا {date}",
|
||||||
|
"activeCount": "{count} فعال"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "Outbound",
|
"outbounds": "Outbound",
|
||||||
"apiDocs": "Dokumentasi API",
|
"apiDocs": "Dokumentasi API",
|
||||||
"donate": "Donasi",
|
"donate": "Donasi",
|
||||||
|
"sponsors": "Sponsor",
|
||||||
"hosts": "Host",
|
"hosts": "Host",
|
||||||
"docs": "Dokumentasi",
|
"docs": "Dokumentasi",
|
||||||
"openMenu": "Buka menu",
|
"openMenu": "Buka menu",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "Tag tidak valid",
|
"badTag": "Tag tidak valid",
|
||||||
"badVlessRoute": "Masukkan satu angka antara 0 dan 65535"
|
"badVlessRoute": "Masukkan satu angka antara 0 dan 65535"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "Sponsor",
|
||||||
|
"intro": "Perusahaan yang mendukung pengembangan 3X-UI. Tautan sponsor dibuka di tab baru.",
|
||||||
|
"become": "Jadi sponsor",
|
||||||
|
"tag": "Sponsor",
|
||||||
|
"visit": "Kunjungi",
|
||||||
|
"yourBrand": "Merek Anda di sini",
|
||||||
|
"yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari.",
|
||||||
|
"placements": "Slot yang tersedia",
|
||||||
|
"placementDashboard": "Banner dasbor",
|
||||||
|
"placementDashboardDesc": "Tempat premium di atas dasbor, satu sponsor dalam satu waktu.",
|
||||||
|
"placementSidebar": "Kartu sidebar",
|
||||||
|
"placementSidebarDesc": "Tampil di setiap halaman panel, bergantian antar sponsor.",
|
||||||
|
"placementLogin": "Halaman login",
|
||||||
|
"placementLoginDesc": "Dilihat admin setiap kali masuk.",
|
||||||
|
"placementPage": "Halaman sponsor",
|
||||||
|
"placementPageDesc": "Tercantum di sini dengan logo, deskripsi, dan tautan.",
|
||||||
|
"available": "Tersedia",
|
||||||
|
"takenUntil": "Terisi hingga {date}",
|
||||||
|
"activeCount": "{count} aktif"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "アウトバウンド",
|
"outbounds": "アウトバウンド",
|
||||||
"apiDocs": "API ドキュメント",
|
"apiDocs": "API ドキュメント",
|
||||||
"donate": "寄付",
|
"donate": "寄付",
|
||||||
|
"sponsors": "スポンサー",
|
||||||
"hosts": "ホスト",
|
"hosts": "ホスト",
|
||||||
"docs": "ドキュメント",
|
"docs": "ドキュメント",
|
||||||
"openMenu": "メニューを開く",
|
"openMenu": "メニューを開く",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "無効なタグ",
|
"badTag": "無効なタグ",
|
||||||
"badVlessRoute": "0〜65535 の単一の数値を入力してください"
|
"badVlessRoute": "0〜65535 の単一の数値を入力してください"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "スポンサー",
|
||||||
|
"intro": "3X-UI の開発を支援している企業です。スポンサーリンクは新しいタブで開きます。",
|
||||||
|
"become": "スポンサーになる",
|
||||||
|
"tag": "スポンサー",
|
||||||
|
"visit": "訪問",
|
||||||
|
"yourBrand": "ここにあなたのブランドを",
|
||||||
|
"yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。",
|
||||||
|
"placements": "掲載枠",
|
||||||
|
"placementDashboard": "ダッシュボードバナー",
|
||||||
|
"placementDashboardDesc": "ダッシュボード上部のプレミアム枠。一度に 1 社のみ。",
|
||||||
|
"placementSidebar": "サイドバーカード",
|
||||||
|
"placementSidebarDesc": "パネルの全ページに表示され、スポンサー間でローテーションします。",
|
||||||
|
"placementLogin": "ログインページ",
|
||||||
|
"placementLoginDesc": "管理者がログインするたびに表示されます。",
|
||||||
|
"placementPage": "スポンサーページ",
|
||||||
|
"placementPageDesc": "ロゴ、説明、リンク付きでここに掲載されます。",
|
||||||
|
"available": "空きあり",
|
||||||
|
"takenUntil": "{date} まで予約済み",
|
||||||
|
"activeCount": "{count} 件掲載中"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "Saídas",
|
"outbounds": "Saídas",
|
||||||
"apiDocs": "Documentação da API",
|
"apiDocs": "Documentação da API",
|
||||||
"donate": "Doar",
|
"donate": "Doar",
|
||||||
|
"sponsors": "Patrocinadores",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"docs": "Documentação",
|
"docs": "Documentação",
|
||||||
"openMenu": "Abrir menu",
|
"openMenu": "Abrir menu",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "Tag inválida",
|
"badTag": "Tag inválida",
|
||||||
"badVlessRoute": "Insira um único número entre 0 e 65535"
|
"badVlessRoute": "Insira um único número entre 0 e 65535"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "Patrocinadores",
|
||||||
|
"intro": "Empresas que apoiam o desenvolvimento do 3X-UI. Links patrocinados abrem em uma nova aba.",
|
||||||
|
"become": "Seja um patrocinador",
|
||||||
|
"tag": "Patrocinador",
|
||||||
|
"visit": "Visitar",
|
||||||
|
"yourBrand": "Sua marca aqui",
|
||||||
|
"yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias.",
|
||||||
|
"placements": "Espaços disponíveis",
|
||||||
|
"placementDashboard": "Banner do painel",
|
||||||
|
"placementDashboardDesc": "Espaço premium no topo do painel, um patrocinador por vez.",
|
||||||
|
"placementSidebar": "Cartão lateral",
|
||||||
|
"placementSidebarDesc": "Exibido em todas as páginas do painel, alternando entre patrocinadores.",
|
||||||
|
"placementLogin": "Página de login",
|
||||||
|
"placementLoginDesc": "Visto pelo administrador a cada login.",
|
||||||
|
"placementPage": "Página de patrocinadores",
|
||||||
|
"placementPageDesc": "Listado aqui com logo, descrição e link.",
|
||||||
|
"available": "Disponível",
|
||||||
|
"takenUntil": "Ocupado até {date}",
|
||||||
|
"activeCount": "{count} ativos"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "Исходящие",
|
"outbounds": "Исходящие",
|
||||||
"apiDocs": "Документация API",
|
"apiDocs": "Документация API",
|
||||||
"donate": "Поддержать",
|
"donate": "Поддержать",
|
||||||
|
"sponsors": "Спонсоры",
|
||||||
"hosts": "Хосты",
|
"hosts": "Хосты",
|
||||||
"docs": "Документация",
|
"docs": "Документация",
|
||||||
"openMenu": "Открыть меню",
|
"openMenu": "Открыть меню",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "Недопустимый тег",
|
"badTag": "Недопустимый тег",
|
||||||
"badVlessRoute": "Введите одно число от 0 до 65535"
|
"badVlessRoute": "Введите одно число от 0 до 65535"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "Спонсоры",
|
||||||
|
"intro": "Компании, поддерживающие разработку 3X-UI. Спонсорские ссылки открываются в новой вкладке.",
|
||||||
|
"become": "Стать спонсором",
|
||||||
|
"tag": "Спонсор",
|
||||||
|
"visit": "Перейти",
|
||||||
|
"yourBrand": "Здесь может быть ваш бренд",
|
||||||
|
"yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI.",
|
||||||
|
"placements": "Доступные места",
|
||||||
|
"placementDashboard": "Баннер на главной",
|
||||||
|
"placementDashboardDesc": "Премиум-место вверху панели, один спонсор за раз.",
|
||||||
|
"placementSidebar": "Карточка в боковой панели",
|
||||||
|
"placementSidebarDesc": "Показывается на всех страницах панели, спонсоры чередуются.",
|
||||||
|
"placementLogin": "Страница входа",
|
||||||
|
"placementLoginDesc": "Администратор видит её при каждом входе.",
|
||||||
|
"placementPage": "Страница спонсоров",
|
||||||
|
"placementPageDesc": "Размещение здесь с логотипом, описанием и ссылкой.",
|
||||||
|
"available": "Свободно",
|
||||||
|
"takenUntil": "Занято до {date}",
|
||||||
|
"activeCount": "Активных: {count}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "Giden Bağlantılar",
|
"outbounds": "Giden Bağlantılar",
|
||||||
"apiDocs": "API Belgeleri",
|
"apiDocs": "API Belgeleri",
|
||||||
"donate": "Bağış Yap",
|
"donate": "Bağış Yap",
|
||||||
|
"sponsors": "Sponsorlar",
|
||||||
"hosts": "Host'lar",
|
"hosts": "Host'lar",
|
||||||
"docs": "Belgeler",
|
"docs": "Belgeler",
|
||||||
"openMenu": "Menüyü aç",
|
"openMenu": "Menüyü aç",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "Geçersiz etiket",
|
"badTag": "Geçersiz etiket",
|
||||||
"badVlessRoute": "0 ile 65535 arasında tek bir sayı girin"
|
"badVlessRoute": "0 ile 65535 arasında tek bir sayı girin"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "Sponsorlar",
|
||||||
|
"intro": "3X-UI geliştirmesini destekleyen şirketler. Sponsor bağlantıları yeni sekmede açılır.",
|
||||||
|
"become": "Sponsor ol",
|
||||||
|
"tag": "Sponsor",
|
||||||
|
"visit": "Ziyaret et",
|
||||||
|
"yourBrand": "Markanız burada",
|
||||||
|
"yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın.",
|
||||||
|
"placements": "Mevcut yerleşimler",
|
||||||
|
"placementDashboard": "Genel bakış afişi",
|
||||||
|
"placementDashboardDesc": "Panonun üstünde premium alan, aynı anda tek sponsor.",
|
||||||
|
"placementSidebar": "Kenar çubuğu kartı",
|
||||||
|
"placementSidebarDesc": "Tüm panel sayfalarında gösterilir, sponsorlar arasında döner.",
|
||||||
|
"placementLogin": "Giriş sayfası",
|
||||||
|
"placementLoginDesc": "Yönetici her girişte görür.",
|
||||||
|
"placementPage": "Sponsorlar sayfası",
|
||||||
|
"placementPageDesc": "Logo, açıklama ve bağlantıyla burada listelenir.",
|
||||||
|
"available": "Müsait",
|
||||||
|
"takenUntil": "{date} tarihine kadar dolu",
|
||||||
|
"activeCount": "{count} aktif"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "Вихідні",
|
"outbounds": "Вихідні",
|
||||||
"apiDocs": "Документація API",
|
"apiDocs": "Документація API",
|
||||||
"donate": "Підтримати",
|
"donate": "Підтримати",
|
||||||
|
"sponsors": "Спонсори",
|
||||||
"hosts": "Хости",
|
"hosts": "Хости",
|
||||||
"docs": "Документація",
|
"docs": "Документація",
|
||||||
"openMenu": "Відкрити меню",
|
"openMenu": "Відкрити меню",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "Недійсний тег",
|
"badTag": "Недійсний тег",
|
||||||
"badVlessRoute": "Введіть одне число від 0 до 65535"
|
"badVlessRoute": "Введіть одне число від 0 до 65535"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "Спонсори",
|
||||||
|
"intro": "Компанії, що підтримують розробку 3X-UI. Спонсорські посилання відкриваються в новій вкладці.",
|
||||||
|
"become": "Стати спонсором",
|
||||||
|
"tag": "Спонсор",
|
||||||
|
"visit": "Перейти",
|
||||||
|
"yourBrand": "Тут може бути ваш бренд",
|
||||||
|
"yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI.",
|
||||||
|
"placements": "Доступні місця",
|
||||||
|
"placementDashboard": "Банер на головній",
|
||||||
|
"placementDashboardDesc": "Преміум-місце вгорі панелі, один спонсор за раз.",
|
||||||
|
"placementSidebar": "Картка в бічній панелі",
|
||||||
|
"placementSidebarDesc": "Показується на всіх сторінках панелі, спонсори чергуються.",
|
||||||
|
"placementLogin": "Сторінка входу",
|
||||||
|
"placementLoginDesc": "Адміністратор бачить її під час кожного входу.",
|
||||||
|
"placementPage": "Сторінка спонсорів",
|
||||||
|
"placementPageDesc": "Розміщення тут із логотипом, описом і посиланням.",
|
||||||
|
"available": "Вільно",
|
||||||
|
"takenUntil": "Зайнято до {date}",
|
||||||
|
"activeCount": "Активних: {count}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "Outbound",
|
"outbounds": "Outbound",
|
||||||
"apiDocs": "Tài liệu API",
|
"apiDocs": "Tài liệu API",
|
||||||
"donate": "Quyên góp",
|
"donate": "Quyên góp",
|
||||||
|
"sponsors": "Nhà tài trợ",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"docs": "Tài liệu",
|
"docs": "Tài liệu",
|
||||||
"openMenu": "Mở menu",
|
"openMenu": "Mở menu",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "Tag không hợp lệ",
|
"badTag": "Tag không hợp lệ",
|
||||||
"badVlessRoute": "Nhập một số duy nhất từ 0 đến 65535"
|
"badVlessRoute": "Nhập một số duy nhất từ 0 đến 65535"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "Nhà tài trợ",
|
||||||
|
"intro": "Các công ty hỗ trợ phát triển 3X-UI. Liên kết tài trợ mở trong tab mới.",
|
||||||
|
"become": "Trở thành nhà tài trợ",
|
||||||
|
"tag": "Tài trợ",
|
||||||
|
"visit": "Truy cập",
|
||||||
|
"yourBrand": "Thương hiệu của bạn ở đây",
|
||||||
|
"yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày.",
|
||||||
|
"placements": "Vị trí hiện có",
|
||||||
|
"placementDashboard": "Banner tổng quan",
|
||||||
|
"placementDashboardDesc": "Vị trí cao cấp ở đầu bảng điều khiển, mỗi lần một nhà tài trợ.",
|
||||||
|
"placementSidebar": "Thẻ thanh bên",
|
||||||
|
"placementSidebarDesc": "Hiển thị trên mọi trang của panel, luân phiên giữa các nhà tài trợ.",
|
||||||
|
"placementLogin": "Trang đăng nhập",
|
||||||
|
"placementLoginDesc": "Quản trị viên thấy mỗi lần đăng nhập.",
|
||||||
|
"placementPage": "Trang nhà tài trợ",
|
||||||
|
"placementPageDesc": "Được liệt kê tại đây với logo, mô tả và liên kết.",
|
||||||
|
"available": "Còn trống",
|
||||||
|
"takenUntil": "Đã đặt đến {date}",
|
||||||
|
"activeCount": "{count} đang hoạt động"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "出站",
|
"outbounds": "出站",
|
||||||
"apiDocs": "API 文档",
|
"apiDocs": "API 文档",
|
||||||
"donate": "捐赠",
|
"donate": "捐赠",
|
||||||
|
"sponsors": "赞助商",
|
||||||
"hosts": "主机",
|
"hosts": "主机",
|
||||||
"docs": "文档",
|
"docs": "文档",
|
||||||
"openMenu": "打开菜单",
|
"openMenu": "打开菜单",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "无效的标签",
|
"badTag": "无效的标签",
|
||||||
"badVlessRoute": "请输入 0 到 65535 之间的单个数字"
|
"badVlessRoute": "请输入 0 到 65535 之间的单个数字"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "赞助商",
|
||||||
|
"intro": "支持 3X-UI 开发的公司。赞助链接将在新标签页中打开。",
|
||||||
|
"become": "成为赞助商",
|
||||||
|
"tag": "赞助",
|
||||||
|
"visit": "访问",
|
||||||
|
"yourBrand": "您的品牌展示位",
|
||||||
|
"yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。",
|
||||||
|
"placements": "可用展示位",
|
||||||
|
"placementDashboard": "概览横幅",
|
||||||
|
"placementDashboardDesc": "仪表盘顶部的高级位置,每次仅一家赞助商。",
|
||||||
|
"placementSidebar": "侧边栏卡片",
|
||||||
|
"placementSidebarDesc": "在面板所有页面显示,并在赞助商之间轮换。",
|
||||||
|
"placementLogin": "登录页",
|
||||||
|
"placementLoginDesc": "管理员每次登录时都能看到。",
|
||||||
|
"placementPage": "赞助商页面",
|
||||||
|
"placementPageDesc": "在此页面展示徽标、简介和链接。",
|
||||||
|
"available": "可预订",
|
||||||
|
"takenUntil": "已预订至 {date}",
|
||||||
|
"activeCount": "{count} 个进行中"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"outbounds": "出站",
|
"outbounds": "出站",
|
||||||
"apiDocs": "API 文件",
|
"apiDocs": "API 文件",
|
||||||
"donate": "捐贈",
|
"donate": "捐贈",
|
||||||
|
"sponsors": "贊助商",
|
||||||
"hosts": "主機",
|
"hosts": "主機",
|
||||||
"docs": "文件",
|
"docs": "文件",
|
||||||
"openMenu": "開啟選單",
|
"openMenu": "開啟選單",
|
||||||
@@ -2256,6 +2257,27 @@
|
|||||||
"badTag": "無效的標籤",
|
"badTag": "無效的標籤",
|
||||||
"badVlessRoute": "請輸入 0 到 65535 之間的單一數字"
|
"badVlessRoute": "請輸入 0 到 65535 之間的單一數字"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"title": "贊助商",
|
||||||
|
"intro": "支持 3X-UI 開發的公司。贊助連結將在新分頁中開啟。",
|
||||||
|
"become": "成為贊助商",
|
||||||
|
"tag": "贊助",
|
||||||
|
"visit": "造訪",
|
||||||
|
"yourBrand": "您的品牌展示位",
|
||||||
|
"yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。",
|
||||||
|
"placements": "可用展示位",
|
||||||
|
"placementDashboard": "總覽橫幅",
|
||||||
|
"placementDashboardDesc": "儀表板頂部的高級位置,每次僅一家贊助商。",
|
||||||
|
"placementSidebar": "側邊欄卡片",
|
||||||
|
"placementSidebarDesc": "在面板所有頁面顯示,並在贊助商之間輪換。",
|
||||||
|
"placementLogin": "登入頁",
|
||||||
|
"placementLoginDesc": "管理員每次登入時都能看到。",
|
||||||
|
"placementPage": "贊助商頁面",
|
||||||
|
"placementPageDesc": "在此頁面展示標誌、簡介與連結。",
|
||||||
|
"available": "可預訂",
|
||||||
|
"takenUntil": "已預訂至 {date}",
|
||||||
|
"activeCount": "{count} 個進行中"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func run(root, outDir string) error {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Path: resolveRel(root, "internal/web/service/panel"),
|
Path: resolveRel(root, "internal/web/service/panel"),
|
||||||
StructAllow: setOf("ApiTokenView", "PanelUpdateStatus"),
|
StructAllow: setOf("ApiTokenView", "PanelUpdateStatus", "Sponsor", "SponsorList"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Path: resolveRel(root, "internal/amneziawg"),
|
Path: resolveRel(root, "internal/amneziawg"),
|
||||||
|
|||||||
Reference in New Issue
Block a user