From fd7b3559bc95d3722fe24c214c8b1d51cc4754a1 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 26 Sep 2026 03:31:51 +0200 Subject: [PATCH] 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. --- .../docs/en/reference/api/authentication.mdx | 13 +- docs/public/openapi.json | 131 +++++++ frontend/public/openapi.json | 131 +++++++ frontend/src/api/http-init.ts | 4 + frontend/src/api/queries/useSponsorsQuery.ts | 23 ++ frontend/src/api/queryKeys.ts | 1 + .../command-palette/CommandPalette.tsx | 7 + .../src/components/sponsor/SponsorCard.css | 216 +++++++++++ .../sponsor/SponsorCard.stories.tsx | 73 ++++ .../src/components/sponsor/SponsorCard.tsx | 101 ++++++ .../src/components/sponsor/SponsorSlot.tsx | 60 ++++ frontend/src/generated/examples.ts | 31 ++ frontend/src/generated/schemas.ts | 78 ++++ frontend/src/generated/types.ts | 17 + frontend/src/generated/zod.ts | 19 + frontend/src/hooks/usePageTitle.ts | 1 + frontend/src/layouts/AppSidebar.css | 4 + frontend/src/layouts/AppSidebar.tsx | 13 + frontend/src/lib/sponsors.ts | 62 ++++ frontend/src/pages/api-docs/endpoints.ts | 7 + frontend/src/pages/index/IndexPage.tsx | 3 + frontend/src/pages/login/LoginPage.css | 4 + frontend/src/pages/login/LoginPage.tsx | 2 + frontend/src/pages/sponsors/SponsorsPage.css | 142 ++++++++ frontend/src/pages/sponsors/SponsorsPage.tsx | 176 +++++++++ frontend/src/routes.tsx | 2 + frontend/src/styles/page-shell.css | 9 +- frontend/src/test/sponsors.test.tsx | 98 +++++ frontend/vite.config.js | 2 +- internal/web/controller/index.go | 26 ++ internal/web/routes_contract_test.go | 9 +- internal/web/service/panel/sponsor.go | 246 +++++++++++++ internal/web/service/panel/sponsor_test.go | 334 ++++++++++++++++++ internal/web/translation/ar-EG.json | 22 ++ internal/web/translation/en-US.json | 22 ++ internal/web/translation/es-ES.json | 22 ++ internal/web/translation/fa-IR.json | 22 ++ internal/web/translation/id-ID.json | 22 ++ internal/web/translation/ja-JP.json | 22 ++ internal/web/translation/pt-BR.json | 22 ++ internal/web/translation/ru-RU.json | 22 ++ internal/web/translation/tr-TR.json | 22 ++ internal/web/translation/uk-UA.json | 22 ++ internal/web/translation/vi-VN.json | 22 ++ internal/web/translation/zh-CN.json | 22 ++ internal/web/translation/zh-TW.json | 22 ++ tools/openapigen/main.go | 2 +- 47 files changed, 2323 insertions(+), 10 deletions(-) create mode 100644 frontend/src/api/queries/useSponsorsQuery.ts create mode 100644 frontend/src/components/sponsor/SponsorCard.css create mode 100644 frontend/src/components/sponsor/SponsorCard.stories.tsx create mode 100644 frontend/src/components/sponsor/SponsorCard.tsx create mode 100644 frontend/src/components/sponsor/SponsorSlot.tsx create mode 100644 frontend/src/lib/sponsors.ts create mode 100644 frontend/src/pages/sponsors/SponsorsPage.css create mode 100644 frontend/src/pages/sponsors/SponsorsPage.tsx create mode 100644 frontend/src/test/sponsors.test.tsx create mode 100644 internal/web/service/panel/sponsor.go create mode 100644 internal/web/service/panel/sponsor_test.go diff --git a/docs/content/docs/en/reference/api/authentication.mdx b/docs/content/docs/en/reference/api/authentication.mdx index c5adc438d..a7d2f467b 100644 --- a/docs/content/docs/en/reference/api/authentication.mdx +++ b/docs/content/docs/en/reference/api/authentication.mdx @@ -22,6 +22,12 @@ _openapi: 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 title: Returns whether 2FA is enabled on the panel — used by the login page to decide whether to show the OTP field. @@ -39,6 +45,11 @@ _openapi: 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 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 ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/public/openapi.json b/docs/public/openapi.json index aa6e75fd7..d35139a3b 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -4135,6 +4135,84 @@ ], "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": { "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.", "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": { "post": { "tags": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index aa6e75fd7..d35139a3b 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -4135,6 +4135,84 @@ ], "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": { "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.", "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": { "post": { "tags": [ diff --git a/frontend/src/api/http-init.ts b/frontend/src/api/http-init.ts index f5db3c1cf..d7860f517 100644 --- a/frontend/src/api/http-init.ts +++ b/frontend/src/api/http-init.ts @@ -196,6 +196,10 @@ export async function httpRequest( return { ok: true, status: res.status, statusText: res.statusText, data: parsed }; } +export function withBasePath(path: string): string { + return basePathPrefix + path; +} + export function setupHttp(): void { let basePath: string | null | undefined = window.X_UI_BASE_PATH; if (!basePath) { diff --git a/frontend/src/api/queries/useSponsorsQuery.ts b/frontend/src/api/queries/useSponsorsQuery.ts new file mode 100644 index 000000000..c5126ac13 --- /dev/null +++ b/frontend/src/api/queries/useSponsorsQuery.ts @@ -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 { + const msg = await HttpUtil.get('/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 }; +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 313fcff9d..dc34222fa 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -1,4 +1,5 @@ export const keys = { + sponsors: () => ['sponsors'] as const, server: { status: () => ['server', 'status'] as const, fail2banStatus: () => ['server', 'fail2banStatus'] as const, diff --git a/frontend/src/components/command-palette/CommandPalette.tsx b/frontend/src/components/command-palette/CommandPalette.tsx index eeb49fefa..321d639dd 100644 --- a/frontend/src/components/command-palette/CommandPalette.tsx +++ b/frontend/src/components/command-palette/CommandPalette.tsx @@ -13,6 +13,7 @@ import { ClusterOutlined, CodeOutlined, CopyOutlined, + CrownOutlined, DashboardOutlined, DatabaseOutlined, DiscordOutlined, @@ -418,6 +419,12 @@ export default function CommandPalette() { keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'], icon: , }, + { + path: '/sponsors', + title: t('menu.sponsors'), + keywords: ['sponsors', 'sponsor', 'partners'], + icon: , + }, ]; pages diff --git a/frontend/src/components/sponsor/SponsorCard.css b/frontend/src/components/sponsor/SponsorCard.css new file mode 100644 index 000000000..8b02753b0 --- /dev/null +++ b/frontend/src/components/sponsor/SponsorCard.css @@ -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); +} diff --git a/frontend/src/components/sponsor/SponsorCard.stories.tsx b/frontend/src/components/sponsor/SponsorCard.stories.tsx new file mode 100644 index 000000000..d12fdacab --- /dev/null +++ b/frontend/src/components/sponsor/SponsorCard.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +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) => ( +
+ +
+ ), +}; + +export const Card: Story = { + args: { variant: 'card' }, + render: (args) => ( +
+ +
+ ), +}; + +export const IconOnly: Story = { + args: { iconOnly: true }, +}; diff --git a/frontend/src/components/sponsor/SponsorCard.tsx b/frontend/src/components/sponsor/SponsorCard.tsx new file mode 100644 index 000000000..a74c4e2aa --- /dev/null +++ b/frontend/src/components/sponsor/SponsorCard.tsx @@ -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 ( + setFailedSrc(sponsor.logo ?? '')} + /> + ); + } + return ( + + ); +} + +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 ( + + + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/sponsor/SponsorSlot.tsx b/frontend/src/components/sponsor/SponsorSlot.tsx new file mode 100644 index 000000000..b8a284281 --- /dev/null +++ b/frontend/src/components/sponsor/SponsorSlot.tsx @@ -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 ( +
+ { + dismissSponsor(sponsor.id, slot); + setDismissTick((n) => n + 1); + }} + /> +
+ ); +} diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 552cc0d39..190ca1c07 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -1019,6 +1019,37 @@ export const EXAMPLES: Record = { "key": "", "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": { "createdAt": 1710000000000, "enabled": true, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index c04ea3d8f..ee8650062 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -4109,6 +4109,84 @@ export const SCHEMAS: Record = { ], "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": { "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.", "properties": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 54cca69ba..bb916a8b1 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -939,6 +939,23 @@ export interface Setting { value: string; } +export interface Sponsor { + enable?: boolean | null; + id: string; + link: string; + logo?: string; + name: string; + slots: string[]; + text: Record; + title: Record; + until: string; +} + +export interface SponsorList { + contact?: string; + sponsors: Sponsor[]; +} + export interface SubBalancer { createdAt: number; enabled: boolean; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 5c19e8e04..deb007369 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -998,6 +998,25 @@ export const SettingSchema = z.object({ }); export type Setting = z.infer; +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; + +export const SponsorListSchema = z.object({ + contact: z.string().optional(), + sponsors: z.array(z.lazy(() => SponsorSchema)), +}); +export type SponsorList = z.infer; + export const SubBalancerSchema = z.object({ createdAt: z.number().int(), enabled: z.boolean(), diff --git a/frontend/src/hooks/usePageTitle.ts b/frontend/src/hooks/usePageTitle.ts index c6f181635..4b2341efc 100644 --- a/frontend/src/hooks/usePageTitle.ts +++ b/frontend/src/hooks/usePageTitle.ts @@ -14,6 +14,7 @@ const TITLE_KEYS: Record = { '/outbound': 'menu.outbounds', '/routing': 'menu.routing', '/api-docs': 'menu.apiDocs', + '/sponsors': 'menu.sponsors', }; export function usePageTitle() { diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index ddd101628..6f47a0888 100644 --- a/frontend/src/layouts/AppSidebar.css +++ b/frontend/src/layouts/AppSidebar.css @@ -247,6 +247,10 @@ padding: 8px 8px 12px; } +.sider-sponsor { + margin-bottom: 6px; +} + .sidebar-pin { display: inline-flex; align-items: center; diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index a86dfe11c..8e3c713a6 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -11,6 +11,7 @@ import { CloudServerOutlined, ClusterOutlined, CodeOutlined, + CrownOutlined, DashboardOutlined, DatabaseOutlined, DiscordOutlined, @@ -43,6 +44,7 @@ import { formatPanelVersion } from '@/lib/panel-version'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { useAllSettings } from '@/api/queries/useAllSettings'; import { useCommandPalette } from '@/components/command-palette/useCommandPalette'; +import SponsorSlot from '@/components/sponsor/SponsorSlot'; import './AppSidebar.css'; const DONATE_URL = 'https://donate.sanaei.dev/'; @@ -68,6 +70,7 @@ type IconName = | 'cluster' | 'hosts' | 'logout' + | 'sponsors' | 'apidocs' | 'outbound' | 'routing'; @@ -82,6 +85,7 @@ const iconByName: Record = { cluster: ClusterOutlined, hosts: GlobalOutlined, logout: LogoutOutlined, + sponsors: CrownOutlined, apidocs: ApiOutlined, outbound: ExportOutlined, routing: SwapOutlined, @@ -232,6 +236,7 @@ export default function AppSidebar() { { key: '/settings', icon: 'setting', title: t('menu.settings') }, { key: '/xray', icon: 'tool', title: t('menu.xray') }, { 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') }, ], [t], @@ -447,6 +452,13 @@ export default function AppSidebar() { onClick={onMenuClick} />
+
@@ -532,6 +544,7 @@ export default function AppSidebar() { }} />
+
diff --git a/frontend/src/lib/sponsors.ts b/frontend/src/lib/sponsors.ts new file mode 100644 index 000000000..2bd2ef703 --- /dev/null +++ b/frontend/src/lib/sponsors.ts @@ -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 | 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 { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(DISMISS_KEY) || '{}'); + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } 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> = { + dashboard: 1, + login: 1, + sidebar: 3, +}; + +export interface PlacementStatus { + count: number; + capacity?: number; + takenUntil?: string; +} + +// When full, a place frees up once enough bookings end to drop below capacity. +export function placementStatus(sponsors: Sponsor[], slot: SponsorSlot): PlacementStatus { + const booked = sponsorsForSlot(sponsors, slot); + const capacity = SLOT_CAPACITY[slot]; + if (!capacity || booked.length < capacity) return { count: booked.length, capacity }; + const ends = booked.map((s) => s.until).sort((a, b) => Date.parse(a) - Date.parse(b)); + return { count: booked.length, capacity, takenUntil: ends[booked.length - capacity] }; +} diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index de9663585..a6150e0c1 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -236,6 +236,13 @@ export const sections: readonly Section[] = [ 'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.', response: '{\n "success": true,\n "obj": "csrf-token-string"\n}', }, + { + method: 'GET', + path: '/sponsors', + summary: + 'Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.', + responseSchema: 'SponsorList', + }, { method: 'POST', path: '/getTwoFactorEnable', diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx index c53ffda27..9c434a3ba 100644 --- a/frontend/src/pages/index/IndexPage.tsx +++ b/frontend/src/pages/index/IndexPage.tsx @@ -22,6 +22,7 @@ import { useStatusQuery } from '@/api/queries/useStatusQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import AppSidebar from '@/layouts/AppSidebar'; import { LazyMount } from '@/components/utility'; +import SponsorSlot from '@/components/sponsor/SponsorSlot'; import { setMessageInstance } from '@/utils/messageBus'; import OverviewActionBar from './OverviewActionBar'; import VitalTile from './VitalTile'; @@ -213,6 +214,8 @@ export default function IndexPage() { onOpenVersionSwitch={() => setVersionOpen(true)} /> + + {health && (
diff --git a/frontend/src/pages/login/LoginPage.css b/frontend/src/pages/login/LoginPage.css index 2a72af26a..ecbaad847 100644 --- a/frontend/src/pages/login/LoginPage.css +++ b/frontend/src/pages/login/LoginPage.css @@ -411,3 +411,7 @@ .submit-row { margin-bottom: 0; } + +.login-sponsor { + margin-top: 20px; +} diff --git a/frontend/src/pages/login/LoginPage.tsx b/frontend/src/pages/login/LoginPage.tsx index 59f8360aa..ac5510ad3 100644 --- a/frontend/src/pages/login/LoginPage.tsx +++ b/frontend/src/pages/login/LoginPage.tsx @@ -26,6 +26,7 @@ import { FormProvider, useForm } from 'react-hook-form'; import { HttpUtil, LanguageManager } from '@/utils'; import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { setMessageInstance } from '@/utils/messageBus'; +import SponsorSlot from '@/components/sponsor/SponsorSlot'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login'; import './LoginPage.css'; @@ -247,6 +248,7 @@ export default function LoginPage() { +
)} diff --git a/frontend/src/pages/sponsors/SponsorsPage.css b/frontend/src/pages/sponsors/SponsorsPage.css new file mode 100644 index 000000000..81d26b5a8 --- /dev/null +++ b/frontend/src/pages/sponsors/SponsorsPage.css @@ -0,0 +1,142 @@ +.sponsors-page .ant-layout, +.sponsors-page .ant-layout-content, +.sponsors-page .content-shell { + background: transparent; +} + +.sponsors-page .content-area { + padding: 24px; +} + +.sponsors-inner { + max-width: 1200px; +} + +.sponsors-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +.sponsors-page .sponsors-title { + margin: 0 0 4px; +} + +.sponsors-title-icon { + color: #faad14; +} + +.sponsors-page .sponsors-section-title { + margin: 32px 0 12px; + color: var(--ant-color-text-secondary); + font-weight: 600; + letter-spacing: 0.3px; +} + +.sponsors-yourbrand { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + height: 100%; + min-height: 170px; + padding: 16px; + border: 1.5px dashed var(--ant-color-border); + border-radius: var(--ant-border-radius-lg, 8px); + background: transparent; +} + +.sponsors-yourbrand.is-large { + align-items: center; + min-height: 0; + padding: 40px 24px; + text-align: center; + background: var(--bg-card, transparent); +} + +.sponsors-yourbrand-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 8px; + font-size: 22px; + color: var(--ant-color-primary); + background: var(--ant-color-primary-bg); +} + +.sponsors-yourbrand-title { + font-weight: 600; + font-size: 15px; + color: var(--ant-color-text); +} + +.sponsors-yourbrand-text { + max-width: 420px; + margin-bottom: 4px; + font-size: 13px; + color: var(--ant-color-text-secondary); +} + +.sponsors-yourbrand:not(.is-large) .ant-btn { + margin-top: auto; +} + +.sponsors-placement { + display: flex; + gap: 12px; + height: 100%; + padding: 14px 16px; + border: 1px solid var(--ant-color-border-secondary); + border-radius: var(--ant-border-radius-lg, 8px); + background: var(--bg-card, transparent); +} + +.sponsors-placement-icon { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + font-size: 17px; + color: var(--ant-color-primary); + background: var(--ant-color-primary-bg); +} + +.sponsors-placement-title { + font-weight: 600; + color: var(--ant-color-text); +} + +.sponsors-placement-body { + min-width: 0; +} + +.sponsors-placement-status { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} + +.sponsors-placement-status .ant-tag { + margin: 0; +} + +.sponsors-placement-desc { + font-size: 13px; + color: var(--ant-color-text-secondary); +} + +@media (max-width: 768px) { + .sponsors-page .content-area { + padding: 12px; + padding-top: 64px; + } +} diff --git a/frontend/src/pages/sponsors/SponsorsPage.tsx b/frontend/src/pages/sponsors/SponsorsPage.tsx new file mode 100644 index 000000000..bbf0a67cb --- /dev/null +++ b/frontend/src/pages/sponsors/SponsorsPage.tsx @@ -0,0 +1,176 @@ +import { useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd'; +import { + CrownOutlined, + DashboardOutlined, + LoginOutlined, + MenuUnfoldOutlined, + PlusOutlined, +} from '@ant-design/icons'; + +import { useTheme } from '@/hooks/useTheme'; +import AppSidebar from '@/layouts/AppSidebar'; +import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery'; +import SponsorCard from '@/components/sponsor/SponsorCard'; +import { IntlUtil } from '@/utils'; +import { useDatepicker } from '@/hooks/useDatepicker'; +import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors'; +import './SponsorsPage.css'; + +function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) { + const { t } = useTranslation(); + if (!contact) return null; + return ( + + ); +} + +function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) { + const { t } = useTranslation(); + return ( +
+ + + +
{t('pages.sponsors.yourBrand')}
+
{t('pages.sponsors.yourBrandText')}
+ +
+ ); +} + +export default function SponsorsPage() { + const { t } = useTranslation(); + const { isDark, isUltra, antdThemeConfig } = useTheme(); + const { data, fetched } = useSponsorsQuery(); + const sponsors = sponsorsForSlot(data.sponsors, 'page'); + + const { datepicker } = useDatepicker(); + const placements: { slot: SponsorSlot; icon: ReactNode; title: string; desc: string }[] = [ + { + slot: 'dashboard', + icon: , + title: t('pages.sponsors.placementDashboard'), + desc: t('pages.sponsors.placementDashboardDesc'), + }, + { + slot: 'sidebar', + icon: , + title: t('pages.sponsors.placementSidebar'), + desc: t('pages.sponsors.placementSidebarDesc'), + }, + { + slot: 'login', + icon: , + title: t('pages.sponsors.placementLogin'), + desc: t('pages.sponsors.placementLoginDesc'), + }, + { + slot: 'page', + icon: , + title: t('pages.sponsors.placementPage'), + desc: t('pages.sponsors.placementPageDesc'), + }, + ]; + + const pageClass = useMemo(() => { + const classes = ['sponsors-page']; + if (isDark) classes.push('is-dark'); + if (isUltra) classes.push('is-ultra'); + return classes.join(' '); + }, [isDark, isUltra]); + + return ( + + + + + +
+
+
+ + {t('pages.sponsors.title')} + + {t('pages.sponsors.intro')} +
+ +
+ + + {sponsors.length === 0 ? ( + fetched && + ) : ( + + {sponsors.map((sponsor) => ( + + + + ))} + {data.contact && ( + + + + )} + + )} + + + + {t('pages.sponsors.placements')} + + + {placements.map((p) => { + const status = placementStatus(data.sponsors, p.slot); + return ( + +
+ {p.icon} +
+
{p.title}
+
{p.desc}
+
+ {status.takenUntil ? ( + + {t('pages.sponsors.takenUntil', { + date: IntlUtil.formatDate(status.takenUntil, datepicker), + })} + + ) : ( + {t('pages.sponsors.available')} + )} + {status.count > 0 && !status.takenUntil && ( + + {t('pages.sponsors.activeCount', { + count: + status.capacity && status.capacity > 1 + ? `${status.count}/${status.capacity}` + : status.count, + })} + + )} +
+
+
+ + ); + })} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index beb9a4e1e..e5927ce4e 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -13,6 +13,7 @@ const HostsPage = lazy(() => import('@/pages/hosts/HostsPage')); const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage')); const XrayPage = lazy(() => import('@/pages/xray/XrayPage')); const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage')); +const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage')); function withSuspense(node: React.ReactNode) { return ( @@ -51,6 +52,7 @@ const routes: RouteObject[] = [ { path: 'outbound', element: withSuspense() }, { path: 'routing', element: withSuspense() }, { path: 'api-docs', element: withSuspense() }, + { path: 'sponsors', element: withSuspense() }, ], }, ]; diff --git a/frontend/src/styles/page-shell.css b/frontend/src/styles/page-shell.css index 2ebf2b026..1125543ea 100644 --- a/frontend/src/styles/page-shell.css +++ b/frontend/src/styles/page-shell.css @@ -5,7 +5,8 @@ .settings-page, .nodes-page, .groups-page, -.api-docs-page { +.api-docs-page, +.sponsors-page { --bg-page: #e6e8ec; --bg-card: #ffffff; min-height: 100vh; @@ -19,7 +20,8 @@ .settings-page.is-dark, .nodes-page.is-dark, .groups-page.is-dark, -.api-docs-page.is-dark { +.api-docs-page.is-dark, +.sponsors-page.is-dark { --bg-page: #1a1b1f; --bg-card: #23252b; } @@ -31,7 +33,8 @@ .settings-page.is-dark.is-ultra, .nodes-page.is-dark.is-ultra, .groups-page.is-dark.is-ultra, -.api-docs-page.is-dark.is-ultra { +.api-docs-page.is-dark.is-ultra, +.sponsors-page.is-dark.is-ultra { --bg-page: #000; --bg-card: #101013; } diff --git a/frontend/src/test/sponsors.test.tsx b/frontend/src/test/sponsors.test.tsx new file mode 100644 index 000000000..218491101 --- /dev/null +++ b/frontend/src/test/sponsors.test.tsx @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, test } from 'vitest'; + +import { + SPONSOR_DISMISS_MS, + dismissSponsor, + isSponsorDismissed, + pickLocale, + placementStatus, +} from '@/lib/sponsors'; + +afterEach(() => { + localStorage.clear(); +}); + +describe('pickLocale', () => { + const map = { en: 'Hello', fa: 'سلام', 'zh-TW': '哈囉' }; + + test.each([ + ['fa-IR', 'سلام'], + ['zh-TW', '哈囉'], + ['zh-CN', 'Hello'], + ['ru-RU', 'Hello'], + ])('%s resolves to %s', (lang, want) => { + expect(pickLocale(map, lang)).toBe(want); + }); + + test('missing map yields empty string', () => { + expect(pickLocale(undefined, 'en-US')).toBe(''); + }); +}); + +describe('sponsor dismissal', () => { + const t0 = 1_800_000_000_000; + + test('hides only the dismissed slot until the window elapses', () => { + dismissSponsor('acme', 'sidebar', t0); + expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true); + expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false); + expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS)).toBe(false); + }); + + test('prunes expired entries on the next dismiss', () => { + dismissSponsor('old', 'login', t0); + dismissSponsor('new', 'login', t0 + SPONSOR_DISMISS_MS); + const stored = JSON.parse(localStorage.getItem('xui.sponsor.dismissed') || '{}'); + expect(Object.keys(stored)).toEqual(['new:login']); + }); + + test('corrupt storage is treated as nothing dismissed', () => { + localStorage.setItem('xui.sponsor.dismissed', 'not json'); + expect(isSponsorDismissed('acme', 'sidebar', t0)).toBe(false); + }); +}); + +describe('placementStatus', () => { + const sp = (id: string, slots: string[], until: string) => ({ + id, + name: id, + slots, + until, + title: {}, + text: {}, + link: 'https://x.example/', + }); + + test('exclusive slot is taken until the latest booking ends', () => { + const sponsors = [ + sp('a', ['dashboard'], '2026-11-01T00:00:00Z'), + sp('b', ['dashboard'], '2026-12-15T00:00:00Z'), + ]; + expect(placementStatus(sponsors, 'dashboard')).toEqual({ + count: 2, + capacity: 1, + takenUntil: '2026-12-15T00:00:00Z', + }); + }); + + test('sidebar is full at three and frees up when the earliest ends', () => { + const sponsors = [ + sp('a', ['sidebar'], '2026-12-01T00:00:00Z'), + sp('b', ['sidebar'], '2026-10-20T00:00:00Z'), + sp('c', ['sidebar'], '2026-11-10T00:00:00Z'), + ]; + expect(placementStatus(sponsors, 'sidebar')).toEqual({ + count: 3, + capacity: 3, + takenUntil: '2026-10-20T00:00:00Z', + }); + expect(placementStatus(sponsors.slice(0, 2), 'sidebar')).toEqual({ count: 2, capacity: 3 }); + }); + + test('uncapped page slot reports only a count', () => { + expect(placementStatus([sp('a', ['page'], '2026-12-01T00:00:00Z')], 'page')).toEqual({ + count: 1, + capacity: undefined, + }); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 304dc6bc4..d91e52342 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -286,7 +286,7 @@ export default defineConfig({ port: 5173, strictPort: true, proxy: { - '^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET), + '^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|sponsors|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET), '^/$': makeBackendProxy(BACKEND_TARGET), '^/[^/]+/$': makeBackendProxy(BACKEND_TARGET), '^/(?:[^/]+/)?ws$': { diff --git a/internal/web/controller/index.go b/internal/web/controller/index.go index a5ad9afc9..faf13bcca 100644 --- a/internal/web/controller/index.go +++ b/internal/web/controller/index.go @@ -6,6 +6,7 @@ import ( "time" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/web/entity" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" @@ -28,6 +29,7 @@ type IndexController struct { settingService service.SettingService userService panel.UserService + panelService panel.PanelService tgbot tgbot.Tgbot } @@ -42,12 +44,36 @@ func NewIndexController(g *gin.RouterGroup) *IndexController { func (a *IndexController) initRouter(g *gin.RouterGroup) { g.GET("/", a.index) g.GET("/csrf-token", a.csrfToken) + g.GET("/sponsors", a.sponsors) + g.GET("/sponsors/logo/:name", a.sponsorLogo) g.POST("/login", middleware.CSRFMiddleware(), a.login) g.POST("/logout", middleware.CSRFMiddleware(), a.logout) g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable) } +// sponsors is public so the login page can render its slot; failures stay silent. +func (a *IndexController) sponsors(c *gin.Context) { + list, err := a.panelService.GetSponsors() + if err != nil { + logger.Debug("sponsors fetch failed:", err) + c.JSON(http.StatusOK, entity.Msg{Success: false}) + return + } + jsonObj(c, list, nil) +} + +func (a *IndexController) sponsorLogo(c *gin.Context) { + data, contentType, err := a.panelService.GetSponsorLogo(c.Param("name")) + if err != nil { + logger.Debug("sponsor logo failed:", err) + c.Status(http.StatusNotFound) + return + } + c.Header("Cache-Control", "public, max-age=3600") + c.Data(http.StatusOK, contentType, data) +} + // index handles the root route, redirecting logged-in users to the panel or showing the login page. func (a *IndexController) index(c *gin.Context) { if session.IsLogin(c) { diff --git a/internal/web/routes_contract_test.go b/internal/web/routes_contract_test.go index dd7329985..bcf7bec99 100644 --- a/internal/web/routes_contract_test.go +++ b/internal/web/routes_contract_test.go @@ -23,16 +23,17 @@ and an entry for a removed route documents an endpoint that 404s. This test constructs the real router and diffs it against the registry both ways. Scope: everything under /panel/api/ plus the session-auth surface the -registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable, -/ws). SPA page routes are UI, not API, and stay out; registry paths that -start with "/{" describe the standalone subscription server, which this -engine does not serve. +registry also documents (/login, /logout, /csrf-token, /sponsors, +/getTwoFactorEnable, /ws). SPA page routes are UI, not API, and stay out; +registry paths that start with "/{" describe the standalone subscription +server, which this engine does not serve. */ var contractExtraRoutes = map[string]bool{ "POST /login": true, "POST /logout": true, "GET /csrf-token": true, + "GET /sponsors": true, "POST /getTwoFactorEnable": true, "GET /ws": true, } diff --git a/internal/web/service/panel/sponsor.go b/internal/web/service/panel/sponsor.go new file mode 100644 index 000000000..282d42f48 --- /dev/null +++ b/internal/web/service/panel/sponsor.go @@ -0,0 +1,246 @@ +package panel + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) + +// Sponsor is one paid placement published in the repo's sponsors.json. +type Sponsor struct { + ID string `json:"id" example:"acme-2026-10"` + Name string `json:"name" example:"Acme VPS"` + Enable *bool `json:"enable,omitempty" example:"true"` + Slots []string `json:"slots"` + Until time.Time `json:"until" example:"2026-11-01T00:00:00Z"` + Logo string `json:"logo,omitempty" example:"/sponsors/logo/acme.png"` + Title map[string]string `json:"title"` + Text map[string]string `json:"text"` + Link string `json:"link" example:"https://acme.example/?utm_source=3x-ui"` +} + +// SponsorList is the active sponsor set plus the contact link for new sponsors. +type SponsorList struct { + Contact string `json:"contact,omitempty" example:"https://t.me/example"` + Sponsors []Sponsor `json:"sponsors"` +} + +const ( + sponsorsTTL = time.Hour + sponsorsErrTTL = 10 * time.Minute + maxSponsorsBytes = 256 << 10 + maxLogoBytes = 256 << 10 + maxSidebarSlots = 3 + localSponsorsDir = "../sponsors/3X" + sponsorLogoPath = "/sponsors/logo/" +) + +// ErrSponsorLogoUnknown rejects logo names not used by an active sponsor. +var ErrSponsorLogoUnknown = errors.New("unknown sponsor logo") + +type sponsorLogo struct { + data []byte + contentType string + err error + retryAt time.Time +} + +var ( + sponsorsURL = "https://sponsors.sanaei.dev/3X/sponsors.json" + sponsorLogoBase = "https://sponsors.sanaei.dev/3X/logos/" + sponsorNow = time.Now + + // The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs. + sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`) + sponsorSlots = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true} + + sponsorsMu sync.Mutex + sponsorsRaw *SponsorList + sponsorsErr error + sponsorsRetryAt time.Time + + logosMu sync.Mutex + logos = map[string]sponsorLogo{} +) + +// GetSponsors returns the currently active sponsors. The remote file is cached, +// but expiry is re-checked on every call so a slot ends exactly at Until. +func (s *PanelService) GetSponsors() (*SponsorList, error) { + raw, err := cachedSponsors() + if err != nil { + return nil, err + } + return activeSponsors(raw, sponsorNow()), nil +} + +func cachedSponsors() (*SponsorList, error) { + sponsorsMu.Lock() + defer sponsorsMu.Unlock() + now := sponsorNow() + if !config.IsDebug() && now.Before(sponsorsRetryAt) { + return sponsorsRaw, sponsorsErr + } + list, err := fetchSponsors() + switch { + case err == nil: + sponsorsRaw, sponsorsErr, sponsorsRetryAt = list, nil, now.Add(sponsorsTTL) + case sponsorsRaw != nil: + // An upstream blip keeps the last good list, so paid slots do not blink out. + logger.Debug("sponsors refresh failed, keeping last list:", err) + sponsorsRetryAt = now.Add(sponsorsErrTTL) + default: + sponsorsErr, sponsorsRetryAt = err, now.Add(sponsorsErrTTL) + } + return sponsorsRaw, sponsorsErr +} + +// GetSponsorLogo returns the image bytes for a logo of a currently active sponsor. +func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) { + sponsors, err := s.GetSponsors() + if err != nil { + return nil, "", err + } + if !slices.ContainsFunc(sponsors.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) { + return nil, "", ErrSponsorLogoUnknown + } + logosMu.Lock() + defer logosMu.Unlock() + now := sponsorNow() + l, ok := logos[name] + if ok && !config.IsDebug() && now.Before(l.retryAt) { + return l.data, l.contentType, l.err + } + // Failures are cached too: this route is public and each miss is an outbound fetch. + data, contentType, err := fetchSponsorLogo(name) + switch { + case err == nil: + l = sponsorLogo{data: data, contentType: contentType, retryAt: now.Add(sponsorsTTL)} + case l.data != nil: + l.retryAt = now.Add(sponsorsErrTTL) + default: + l = sponsorLogo{err: err, retryAt: now.Add(sponsorsErrTTL)} + } + logos[name] = l + return l.data, l.contentType, l.err +} + +func fetchSponsorLogo(name string) ([]byte, string, error) { + data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes) + if err != nil { + return nil, "", err + } + contentType := http.DetectContentType(data) + switch contentType { + case "image/png", "image/webp", "image/jpeg": + return data, contentType, nil + default: + return nil, "", fmt.Errorf("sponsor logo %s has content type %s", name, contentType) + } +} + +// readSponsorSource reads a sibling checkout of MHSanaei/sponsors under XUI_DEBUG so +// sponsor edits can be previewed locally before they are pushed. +func readSponsorSource(url, localPath string, limit int) ([]byte, error) { + if !config.IsDebug() { + return fetchLimited(url, limit) + } + data, err := os.ReadFile(localPath) + if err != nil { + return nil, err + } + if len(data) > limit { + return nil, fmt.Errorf("%s exceeds %d bytes", localPath, limit) + } + return data, nil +} + +func fetchLimited(url string, limit int) ([]byte, error) { + client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch %s returned status %d", url, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, int64(limit)+1)) + if err != nil { + return nil, err + } + if len(body) > limit { + return nil, fmt.Errorf("%s exceeds %d bytes", url, limit) + } + return body, nil +} + +func fetchSponsors() (*SponsorList, error) { + body, err := readSponsorSource(sponsorsURL, filepath.Join(localSponsorsDir, "sponsors.json"), maxSponsorsBytes) + if err != nil { + return nil, err + } + var list SponsorList + if err := json.Unmarshal(body, &list); err != nil { + return nil, err + } + return &list, nil +} + +func activeSponsors(raw *SponsorList, now time.Time) *SponsorList { + out := &SponsorList{Sponsors: []Sponsor{}} + if raw == nil { + return out + } + if strings.HasPrefix(raw.Contact, "https://") { + out.Contact = raw.Contact + } + sidebarTaken := 0 + for _, sp := range raw.Sponsors { + // A missing enable counts as on, so a forgotten field never hides a paid slot. + disabled := sp.Enable != nil && !*sp.Enable + if disabled || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") { + continue + } + // A bad logo name drops only the logo; the paid slot still renders with its initial. + if sponsorLogoRe.MatchString(sp.Logo) { + sp.Logo = sponsorLogoPath + sp.Logo + } else { + sp.Logo = "" + } + slots := make([]string, 0, len(sp.Slots)) + for _, slot := range sp.Slots { + // The sidebar rotates, so capping it keeps each paid card on screen long enough. + if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) { + continue + } + slots = append(slots, slot) + } + if len(slots) == 0 { + continue + } + if slices.Contains(slots, "sidebar") { + sidebarTaken++ + } + sp.Slots = slots + out.Sponsors = append(out.Sponsors, sp) + } + return out +} diff --git a/internal/web/service/panel/sponsor_test.go b/internal/web/service/panel/sponsor_test.go new file mode 100644 index 000000000..e213664ad --- /dev/null +++ b/internal/web/service/panel/sponsor_test.go @@ -0,0 +1,334 @@ +package panel + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/database" +) + +var sponsorTestNow = time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC) + +func validSponsor() Sponsor { + return Sponsor{ + ID: "acme", + Name: "Acme", + Slots: []string{"dashboard", "sidebar"}, + Until: sponsorTestNow.Add(24 * time.Hour), + Logo: "acme.png", + Link: "https://acme.example/", + } +} + +func TestActiveSponsorsFilters(t *testing.T) { + cases := []struct { + name string + mutate func(*Sponsor) + kept bool + }{ + {"valid", func(*Sponsor) {}, true}, + {"expired", func(s *Sponsor) { s.Until = sponsorTestNow }, false}, + {"enable false with future until", func(s *Sponsor) { s.Enable = new(false) }, false}, + {"enable true without until", func(s *Sponsor) { s.Enable, s.Until = new(true), time.Time{} }, false}, + {"enable true with future until", func(s *Sponsor) { s.Enable = new(true) }, true}, + {"missing id", func(s *Sponsor) { s.ID = "" }, false}, + {"http link", func(s *Sponsor) { s.Link = "http://acme.example/" }, false}, + {"javascript link", func(s *Sponsor) { s.Link = "javascript:alert(1)" }, false}, + {"only unknown slots", func(s *Sponsor) { s.Slots = []string{"subpage"} }, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sp := validSponsor() + tc.mutate(&sp) + got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow) + if kept := len(got.Sponsors) == 1; kept != tc.kept { + t.Fatalf("kept = %v, want %v", kept, tc.kept) + } + }) + } +} + +func TestActiveSponsorsCapsSidebarAtThree(t *testing.T) { + raw := &SponsorList{} + for _, id := range []string{"expired", "a", "b", "c", "d", "e"} { + sp := validSponsor() + sp.ID = id + sp.Slots = []string{"sidebar", "page"} + if id == "expired" { + sp.Until = sponsorTestNow + } + if id == "e" { + sp.Slots = []string{"sidebar"} + } + raw.Sponsors = append(raw.Sponsors, sp) + } + got := activeSponsors(raw, sponsorTestNow) + want := map[string][]string{ + "a": {"sidebar", "page"}, "b": {"sidebar", "page"}, "c": {"sidebar", "page"}, "d": {"page"}, + } + if len(got.Sponsors) != len(want) { + t.Fatalf("got %d sponsors, want %d (e has only sidebar and must drop)", len(got.Sponsors), len(want)) + } + for _, sp := range got.Sponsors { + if !slices.Equal(sp.Slots, want[sp.ID]) { + t.Errorf("%s slots = %v, want %v", sp.ID, sp.Slots, want[sp.ID]) + } + } +} + +func TestActiveSponsorsLogoName(t *testing.T) { + cases := []struct{ logo, want string }{ + {"acme.png", "/sponsors/logo/acme.png"}, + {"VPS.png", "/sponsors/logo/VPS.png"}, + {"../x.png", ""}, + {"https://evil.example/x.png", ""}, + {"..png", ""}, + {"logo.svg", ""}, + {"", ""}, + } + for _, tc := range cases { + t.Run(tc.logo, func(t *testing.T) { + sp := validSponsor() + sp.Logo = tc.logo + got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow) + if len(got.Sponsors) != 1 { + t.Fatalf("sponsor dropped for logo %q; want it kept", tc.logo) + } + if got.Sponsors[0].Logo != tc.want { + t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, tc.want) + } + }) + } +} + +func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) { + sp := validSponsor() + sp.Slots = []string{"subpage", "login"} + got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow) + if len(got.Sponsors) != 1 { + t.Fatalf("got %d sponsors, want 1", len(got.Sponsors)) + } + if want := "/sponsors/logo/acme.png"; got.Sponsors[0].Logo != want { + t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, want) + } + if s := got.Sponsors[0].Slots; len(s) != 1 || s[0] != "login" { + t.Errorf("slots = %v, want [login]", s) + } + if got.Contact != "" { + t.Errorf("contact = %q, want empty for non-https", got.Contact) + } +} + +func setupSponsorServer(t *testing.T, body string) *atomic.Int32 { + t.Helper() + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + if err := database.InitDB(config.GetDBPath()); err != nil { + t.Fatalf("init db: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + isLogo := strings.HasPrefix(r.URL.Path, "/media/") + if (isLogo && failSponsorLogos.Load()) || (!isLogo && failSponsorList.Load()) { + w.WriteHeader(http.StatusBadGateway) + return + } + switch r.URL.Path { + case "/media/acme.png": + _, _ = w.Write(pngMagic) + case "/media/fake.png": + _, _ = w.Write([]byte("")) + 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) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 97981fb9f..3b83b1dfa 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -120,6 +120,7 @@ "outbounds": "الصادرات", "apiDocs": "توثيق API", "donate": "تبرع", + "sponsors": "الرعاة", "hosts": "المضيفات", "docs": "التوثيق", "openMenu": "فتح القائمة", @@ -2256,6 +2257,27 @@ "badTag": "وسم غير صالح", "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": { diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 3ea988c2a..f0a53b970 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -121,6 +121,7 @@ "outbounds": "Outbounds", "apiDocs": "API Docs", "donate": "Donate", + "sponsors": "Sponsors", "docs": "Documentation", "openMenu": "Open menu", "pinSidebar": "Pin sidebar", @@ -2256,6 +2257,27 @@ }, "defaultOutbound": "Default Outbound", "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": { diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index f414d957d..342cf2400 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -120,6 +120,7 @@ "outbounds": "Salidas", "apiDocs": "Documentación de la API", "donate": "Donar", + "sponsors": "Patrocinadores", "hosts": "Hosts", "docs": "Documentación", "openMenu": "Abrir menú", @@ -2256,6 +2257,27 @@ "badTag": "Etiqueta no válida", "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": { diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 491301a58..7c607c8d8 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -120,6 +120,7 @@ "outbounds": "خروجی‌ها", "apiDocs": "مستندات API", "donate": "حمایت مالی", + "sponsors": "اسپانسرها", "hosts": "میزبان‌ها", "docs": "مستندات", "openMenu": "باز کردن منو", @@ -2256,6 +2257,27 @@ "badTag": "برچسب نامعتبر", "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": { diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 2438adacd..fabb7661a 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -120,6 +120,7 @@ "outbounds": "Outbound", "apiDocs": "Dokumentasi API", "donate": "Donasi", + "sponsors": "Sponsor", "hosts": "Host", "docs": "Dokumentasi", "openMenu": "Buka menu", @@ -2256,6 +2257,27 @@ "badTag": "Tag tidak valid", "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": { diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 7535ed587..21c835b10 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -120,6 +120,7 @@ "outbounds": "アウトバウンド", "apiDocs": "API ドキュメント", "donate": "寄付", + "sponsors": "スポンサー", "hosts": "ホスト", "docs": "ドキュメント", "openMenu": "メニューを開く", @@ -2256,6 +2257,27 @@ "badTag": "無効なタグ", "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": { diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 15a811f3e..821065b8a 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -120,6 +120,7 @@ "outbounds": "Saídas", "apiDocs": "Documentação da API", "donate": "Doar", + "sponsors": "Patrocinadores", "hosts": "Hosts", "docs": "Documentação", "openMenu": "Abrir menu", @@ -2256,6 +2257,27 @@ "badTag": "Tag inválida", "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": { diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index aeaa2dfb7..fe099cc23 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -120,6 +120,7 @@ "outbounds": "Исходящие", "apiDocs": "Документация API", "donate": "Поддержать", + "sponsors": "Спонсоры", "hosts": "Хосты", "docs": "Документация", "openMenu": "Открыть меню", @@ -2256,6 +2257,27 @@ "badTag": "Недопустимый тег", "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": { diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index c98fb4318..ad5cfcfa7 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -120,6 +120,7 @@ "outbounds": "Giden Bağlantılar", "apiDocs": "API Belgeleri", "donate": "Bağış Yap", + "sponsors": "Sponsorlar", "hosts": "Host'lar", "docs": "Belgeler", "openMenu": "Menüyü aç", @@ -2256,6 +2257,27 @@ "badTag": "Geçersiz etiket", "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": { diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 9f324e047..4dd8296ba 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -120,6 +120,7 @@ "outbounds": "Вихідні", "apiDocs": "Документація API", "donate": "Підтримати", + "sponsors": "Спонсори", "hosts": "Хости", "docs": "Документація", "openMenu": "Відкрити меню", @@ -2256,6 +2257,27 @@ "badTag": "Недійсний тег", "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": { diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index f60cfe378..a01524027 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -120,6 +120,7 @@ "outbounds": "Outbound", "apiDocs": "Tài liệu API", "donate": "Quyên góp", + "sponsors": "Nhà tài trợ", "hosts": "Hosts", "docs": "Tài liệu", "openMenu": "Mở menu", @@ -2256,6 +2257,27 @@ "badTag": "Tag không hợp lệ", "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": { diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index ae879e0a6..3a3d7656f 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -120,6 +120,7 @@ "outbounds": "出站", "apiDocs": "API 文档", "donate": "捐赠", + "sponsors": "赞助商", "hosts": "主机", "docs": "文档", "openMenu": "打开菜单", @@ -2256,6 +2257,27 @@ "badTag": "无效的标签", "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": { diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 36e338abb..e58153469 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -120,6 +120,7 @@ "outbounds": "出站", "apiDocs": "API 文件", "donate": "捐贈", + "sponsors": "贊助商", "hosts": "主機", "docs": "文件", "openMenu": "開啟選單", @@ -2256,6 +2257,27 @@ "badTag": "無效的標籤", "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": { diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index 6b1a68cfb..4429ffd4b 100644 --- a/tools/openapigen/main.go +++ b/tools/openapigen/main.go @@ -112,7 +112,7 @@ func run(root, outDir string) error { }, { Path: resolveRel(root, "internal/web/service/panel"), - StructAllow: setOf("ApiTokenView", "PanelUpdateStatus"), + StructAllow: setOf("ApiTokenView", "PanelUpdateStatus", "Sponsor", "SponsorList"), }, { Path: resolveRel(root, "internal/amneziawg"),