mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 17:15:24 +03:00
feat(panel): let a sponsor slot start at a scheduled time
sponsors.json only had an end date, so a booked placement had to be added to the file on the day it started. An optional `from` now hides a sponsor (and its logo) until that instant; entries without it show immediately as before.
This commit is contained in:
@@ -24,10 +24,10 @@ _openapi:
|
|||||||
url: '#mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests'
|
url: '#mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Public. Active paid sponsor placements read from the project
|
title: Public. Active paid sponsor placements read from the project
|
||||||
sponsors.json (cached for 1h); expired entries are dropped. Logos are
|
sponsors.json (cached for 1h); entries outside their from/until window
|
||||||
proxied by the panel at /sponsors/logo/{name}. Used by the login page
|
are dropped. Logos are proxied by the panel at /sponsors/logo/{name}.
|
||||||
and panel sponsor slots.
|
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'
|
url: '#public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-entries-outside-their-fromuntil-window-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Returns whether 2FA is enabled on the panel — used by the login page to
|
title: Returns whether 2FA is enabled on the panel — used by the login page to
|
||||||
decide whether to show the OTP field.
|
decide whether to show the OTP field.
|
||||||
@@ -46,10 +46,10 @@ _openapi:
|
|||||||
requests.
|
requests.
|
||||||
id: mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests
|
id: mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests
|
||||||
- content: Public. Active paid sponsor placements read from the project
|
- content: Public. Active paid sponsor placements read from the project
|
||||||
sponsors.json (cached for 1h); expired entries are dropped. Logos are
|
sponsors.json (cached for 1h); entries outside their from/until window
|
||||||
proxied by the panel at /sponsors/logo/{name}. Used by the login page
|
are dropped. Logos are proxied by the panel at /sponsors/logo/{name}.
|
||||||
and panel sponsor slots.
|
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
|
id: public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-entries-outside-their-fromuntil-window-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots
|
||||||
- content: Returns whether 2FA is enabled on the panel — used by the login page to
|
- content: Returns whether 2FA is enabled on the panel — used by the login page to
|
||||||
decide whether to show the OTP field.
|
decide whether to show the OTP field.
|
||||||
id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field
|
id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field
|
||||||
|
|||||||
@@ -4502,6 +4502,12 @@
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"from": {
|
||||||
|
"example": "2026-10-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"example": "acme-2026-10",
|
"example": "acme-2026-10",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -5029,7 +5035,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Authentication"
|
"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.",
|
"summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); entries outside their from/until window are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
|
||||||
"operationId": "get_sponsors",
|
"operationId": "get_sponsors",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@@ -5057,6 +5063,7 @@
|
|||||||
"sponsors": [
|
"sponsors": [
|
||||||
{
|
{
|
||||||
"enable": true,
|
"enable": true,
|
||||||
|
"from": "2026-10-01T00:00:00Z",
|
||||||
"id": "acme-2026-10",
|
"id": "acme-2026-10",
|
||||||
"link": "https://acme.example/?utm_source=3x-ui",
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
"logo": "/sponsors/logo/acme.png",
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
|||||||
@@ -4502,6 +4502,12 @@
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"from": {
|
||||||
|
"example": "2026-10-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"example": "acme-2026-10",
|
"example": "acme-2026-10",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -5029,7 +5035,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Authentication"
|
"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.",
|
"summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); entries outside their from/until window are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
|
||||||
"operationId": "get_sponsors",
|
"operationId": "get_sponsors",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@@ -5057,6 +5063,7 @@
|
|||||||
"sponsors": [
|
"sponsors": [
|
||||||
{
|
{
|
||||||
"enable": true,
|
"enable": true,
|
||||||
|
"from": "2026-10-01T00:00:00Z",
|
||||||
"id": "acme-2026-10",
|
"id": "acme-2026-10",
|
||||||
"link": "https://acme.example/?utm_source=3x-ui",
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
"logo": "/sponsors/logo/acme.png",
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
|||||||
@@ -1107,6 +1107,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
"Sponsor": {
|
"Sponsor": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
|
"from": "2026-10-01T00:00:00Z",
|
||||||
"id": "acme-2026-10",
|
"id": "acme-2026-10",
|
||||||
"link": "https://acme.example/?utm_source=3x-ui",
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
"logo": "/sponsors/logo/acme.png",
|
"logo": "/sponsors/logo/acme.png",
|
||||||
@@ -1123,6 +1124,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"sponsors": [
|
"sponsors": [
|
||||||
{
|
{
|
||||||
"enable": true,
|
"enable": true,
|
||||||
|
"from": "2026-10-01T00:00:00Z",
|
||||||
"id": "acme-2026-10",
|
"id": "acme-2026-10",
|
||||||
"link": "https://acme.example/?utm_source=3x-ui",
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
"logo": "/sponsors/logo/acme.png",
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
|||||||
@@ -4476,6 +4476,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"from": {
|
||||||
|
"example": "2026-10-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"example": "acme-2026-10",
|
"example": "acme-2026-10",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -1028,6 +1028,7 @@ export interface Setting {
|
|||||||
|
|
||||||
export interface Sponsor {
|
export interface Sponsor {
|
||||||
enable?: boolean | null;
|
enable?: boolean | null;
|
||||||
|
from?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
link: string;
|
link: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
|
|||||||
@@ -1091,6 +1091,7 @@ export type Setting = z.infer<typeof SettingSchema>;
|
|||||||
|
|
||||||
export const SponsorSchema = z.object({
|
export const SponsorSchema = z.object({
|
||||||
enable: z.boolean().nullable().optional(),
|
enable: z.boolean().nullable().optional(),
|
||||||
|
from: z.string().nullable().optional(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
link: z.string(),
|
link: z.string(),
|
||||||
logo: z.string().optional(),
|
logo: z.string().optional(),
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/sponsors',
|
path: '/sponsors',
|
||||||
summary:
|
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.',
|
'Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); entries outside their from/until window are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.',
|
||||||
responseSchema: 'SponsorList',
|
responseSchema: 'SponsorList',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ func BenchmarkStackTunWrite(b *testing.B) {
|
|||||||
|
|
||||||
b.SetBytes(int64(len(packet)))
|
b.SetBytes(int64(len(packet)))
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
b.ResetTimer()
|
|
||||||
for range b.N {
|
for b.Loop() {
|
||||||
if _, err := tun.Write(bufs, 0); err != nil {
|
if _, err := tun.Write(bufs, 0); err != nil {
|
||||||
b.Fatalf("Write: %v", err)
|
b.Fatalf("Write: %v", err)
|
||||||
}
|
}
|
||||||
@@ -49,8 +49,8 @@ func BenchmarkStackTunRead(b *testing.B) {
|
|||||||
|
|
||||||
b.SetBytes(int64(len(packet)))
|
b.SetBytes(int64(len(packet)))
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
b.ResetTimer()
|
|
||||||
for range b.N {
|
for b.Loop() {
|
||||||
tun.incomingPacket <- buffer.NewViewWithData(packet)
|
tun.incomingPacket <- buffer.NewViewWithData(packet)
|
||||||
if _, err := tun.Read(buf, sizes, 0); err != nil {
|
if _, err := tun.Read(buf, sizes, 0); err != nil {
|
||||||
b.Fatalf("Read: %v", err)
|
b.Fatalf("Read: %v", err)
|
||||||
@@ -77,8 +77,8 @@ func BenchmarkUDPDatagramDelivery(b *testing.B) {
|
|||||||
|
|
||||||
b.SetBytes(int64(len(payload)))
|
b.SetBytes(int64(len(payload)))
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
b.ResetTimer()
|
|
||||||
for range b.N {
|
for b.Loop() {
|
||||||
if _, err := st.Write(bufs, 0); err != nil {
|
if _, err := st.Write(bufs, 0); err != nil {
|
||||||
b.Fatalf("Write: %v", err)
|
b.Fatalf("Write: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Sponsor struct {
|
|||||||
Name string `json:"name" example:"Acme VPS"`
|
Name string `json:"name" example:"Acme VPS"`
|
||||||
Enable *bool `json:"enable,omitempty" example:"true"`
|
Enable *bool `json:"enable,omitempty" example:"true"`
|
||||||
Slots []string `json:"slots"`
|
Slots []string `json:"slots"`
|
||||||
|
From *time.Time `json:"from,omitempty" example:"2026-10-01T00:00:00Z"`
|
||||||
Until time.Time `json:"until" example:"2026-11-01T00:00:00Z"`
|
Until time.Time `json:"until" example:"2026-11-01T00:00:00Z"`
|
||||||
Logo string `json:"logo,omitempty" example:"/sponsors/logo/acme.png"`
|
Logo string `json:"logo,omitempty" example:"/sponsors/logo/acme.png"`
|
||||||
Title map[string]string `json:"title"`
|
Title map[string]string `json:"title"`
|
||||||
@@ -77,8 +78,8 @@ var (
|
|||||||
logos = map[string]sponsorLogo{}
|
logos = map[string]sponsorLogo{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetSponsors returns the currently active sponsors. The remote file is cached,
|
// GetSponsors returns the currently active sponsors. The remote file is cached, but the
|
||||||
// but expiry is re-checked on every call so a slot ends exactly at Until.
|
// window is re-checked on every call so a slot starts exactly at From and ends at Until.
|
||||||
func (s *PanelService) GetSponsors() (*SponsorList, error) {
|
func (s *PanelService) GetSponsors() (*SponsorList, error) {
|
||||||
raw, err := cachedSponsors()
|
raw, err := cachedSponsors()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,7 +221,8 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
|
|||||||
for _, sp := range raw.Sponsors {
|
for _, sp := range raw.Sponsors {
|
||||||
// A missing enable counts as on, so a forgotten field never hides a paid slot.
|
// A missing enable counts as on, so a forgotten field never hides a paid slot.
|
||||||
disabled := sp.Enable != nil && !*sp.Enable
|
disabled := sp.Enable != nil && !*sp.Enable
|
||||||
if disabled || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
|
notStarted := sp.From != nil && now.Before(*sp.From)
|
||||||
|
if disabled || notStarted || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// A bad logo name drops only the logo; the paid slot still renders with its initial.
|
// A bad logo name drops only the logo; the paid slot still renders with its initial.
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ func TestActiveSponsorsFilters(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"valid", func(*Sponsor) {}, true},
|
{"valid", func(*Sponsor) {}, true},
|
||||||
{"expired", func(s *Sponsor) { s.Until = sponsorTestNow }, false},
|
{"expired", func(s *Sponsor) { s.Until = sponsorTestNow }, false},
|
||||||
|
{"not started yet", func(s *Sponsor) { s.From = new(sponsorTestNow.Add(time.Hour)) }, false},
|
||||||
|
{"starts exactly now", func(s *Sponsor) { s.From = new(sponsorTestNow) }, true},
|
||||||
{"enable false with future until", func(s *Sponsor) { s.Enable = new(false) }, 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 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},
|
{"enable true with future until", func(s *Sponsor) { s.Enable = new(true) }, true},
|
||||||
|
|||||||
Reference in New Issue
Block a user