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

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

An entry shows only while enable is not false and until is in the
future; links must be https and logos are png/webp/jpg by name only.
The list is cached for an hour and the last good copy survives upstream
failures; logos are proxied through /sponsors/logo/:name with failures
cached, so CSP stays 'self' and admin browsers never reach a third
party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads
a sibling ../sponsors/3X checkout so edits can be previewed before push.
This commit is contained in:
MHSanaei
2026-09-26 03:31:51 +02:00
parent 89e200ead4
commit fd7b3559bc
47 changed files with 2323 additions and 10 deletions
+31
View File
@@ -1019,6 +1019,37 @@ export const EXAMPLES: Record<string, unknown> = {
"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,
+78
View File
@@ -4109,6 +4109,84 @@ export const SCHEMAS: Record<string, unknown> = {
],
"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": {
+17
View File
@@ -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<string, string>;
title: Record<string, string>;
until: string;
}
export interface SponsorList {
contact?: string;
sponsors: Sponsor[];
}
export interface SubBalancer {
createdAt: number;
enabled: boolean;
+19
View File
@@ -998,6 +998,25 @@ export const SettingSchema = z.object({
});
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({
createdAt: z.number().int(),
enabled: z.boolean(),