From 09617f04f5a7fc3b1c374993e5227368b3cf6b8d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 27 Sep 2026 16:30:20 +0200 Subject: [PATCH] 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. --- .../docs/en/reference/api/authentication.mdx | 16 ++++++++-------- docs/public/openapi.json | 9 ++++++++- frontend/public/openapi.json | 9 ++++++++- frontend/src/generated/examples.ts | 2 ++ frontend/src/generated/schemas.ts | 6 ++++++ frontend/src/generated/types.ts | 1 + frontend/src/generated/zod.ts | 1 + frontend/src/pages/api-docs/endpoints.ts | 2 +- internal/amneziawgnet/bench_test.go | 12 ++++++------ internal/web/service/panel/sponsor.go | 8 +++++--- internal/web/service/panel/sponsor_test.go | 2 ++ 11 files changed, 48 insertions(+), 20 deletions(-) diff --git a/docs/content/docs/en/reference/api/authentication.mdx b/docs/content/docs/en/reference/api/authentication.mdx index a7d2f467b..8ba39be38 100644 --- a/docs/content/docs/en/reference/api/authentication.mdx +++ b/docs/content/docs/en/reference/api/authentication.mdx @@ -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' - 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' + 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. + 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 title: Returns whether 2FA is enabled on the panel — used by the login page to decide whether to show the OTP field. @@ -46,10 +46,10 @@ _openapi: 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 + 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. + 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 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 diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 4eddab9b2..163336845 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -4502,6 +4502,12 @@ "nullable": true, "type": "boolean" }, + "from": { + "example": "2026-10-01T00:00:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, "id": { "example": "acme-2026-10", "type": "string" @@ -5029,7 +5035,7 @@ "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.", + "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", "responses": { "200": { @@ -5057,6 +5063,7 @@ "sponsors": [ { "enable": true, + "from": "2026-10-01T00:00:00Z", "id": "acme-2026-10", "link": "https://acme.example/?utm_source=3x-ui", "logo": "/sponsors/logo/acme.png", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 4eddab9b2..163336845 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -4502,6 +4502,12 @@ "nullable": true, "type": "boolean" }, + "from": { + "example": "2026-10-01T00:00:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, "id": { "example": "acme-2026-10", "type": "string" @@ -5029,7 +5035,7 @@ "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.", + "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", "responses": { "200": { @@ -5057,6 +5063,7 @@ "sponsors": [ { "enable": true, + "from": "2026-10-01T00:00:00Z", "id": "acme-2026-10", "link": "https://acme.example/?utm_source=3x-ui", "logo": "/sponsors/logo/acme.png", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index de8397f45..ce0ef8676 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -1107,6 +1107,7 @@ export const EXAMPLES: Record = { }, "Sponsor": { "enable": true, + "from": "2026-10-01T00:00:00Z", "id": "acme-2026-10", "link": "https://acme.example/?utm_source=3x-ui", "logo": "/sponsors/logo/acme.png", @@ -1123,6 +1124,7 @@ export const EXAMPLES: Record = { "sponsors": [ { "enable": true, + "from": "2026-10-01T00:00:00Z", "id": "acme-2026-10", "link": "https://acme.example/?utm_source=3x-ui", "logo": "/sponsors/logo/acme.png", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index b9496a017..4c72b6ac7 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -4476,6 +4476,12 @@ export const SCHEMAS: Record = { "nullable": true, "type": "boolean" }, + "from": { + "example": "2026-10-01T00:00:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, "id": { "example": "acme-2026-10", "type": "string" diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index dda3025fa..86334f324 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -1028,6 +1028,7 @@ export interface Setting { export interface Sponsor { enable?: boolean | null; + from?: string | null; id: string; link: string; logo?: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 11c5c11fa..b3fbcd047 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -1091,6 +1091,7 @@ export type Setting = z.infer; export const SponsorSchema = z.object({ enable: z.boolean().nullable().optional(), + from: z.string().nullable().optional(), id: z.string(), link: z.string(), logo: z.string().optional(), diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index d505869ef..b6bfad445 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -240,7 +240,7 @@ export const sections: readonly Section[] = [ 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.', + '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', }, { diff --git a/internal/amneziawgnet/bench_test.go b/internal/amneziawgnet/bench_test.go index 037ac6b01..a63327f14 100644 --- a/internal/amneziawgnet/bench_test.go +++ b/internal/amneziawgnet/bench_test.go @@ -31,8 +31,8 @@ func BenchmarkStackTunWrite(b *testing.B) { b.SetBytes(int64(len(packet))) b.ReportAllocs() - b.ResetTimer() - for range b.N { + + for b.Loop() { if _, err := tun.Write(bufs, 0); err != nil { b.Fatalf("Write: %v", err) } @@ -49,8 +49,8 @@ func BenchmarkStackTunRead(b *testing.B) { b.SetBytes(int64(len(packet))) b.ReportAllocs() - b.ResetTimer() - for range b.N { + + for b.Loop() { tun.incomingPacket <- buffer.NewViewWithData(packet) if _, err := tun.Read(buf, sizes, 0); err != nil { b.Fatalf("Read: %v", err) @@ -77,8 +77,8 @@ func BenchmarkUDPDatagramDelivery(b *testing.B) { b.SetBytes(int64(len(payload))) b.ReportAllocs() - b.ResetTimer() - for range b.N { + + for b.Loop() { if _, err := st.Write(bufs, 0); err != nil { b.Fatalf("Write: %v", err) } diff --git a/internal/web/service/panel/sponsor.go b/internal/web/service/panel/sponsor.go index 0bc564339..6f9b27a92 100644 --- a/internal/web/service/panel/sponsor.go +++ b/internal/web/service/panel/sponsor.go @@ -26,6 +26,7 @@ type Sponsor struct { Name string `json:"name" example:"Acme VPS"` Enable *bool `json:"enable,omitempty" example:"true"` 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"` Logo string `json:"logo,omitempty" example:"/sponsors/logo/acme.png"` Title map[string]string `json:"title"` @@ -77,8 +78,8 @@ var ( 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. +// GetSponsors returns the currently active sponsors. The remote file is cached, but the +// window is re-checked on every call so a slot starts exactly at From and ends at Until. func (s *PanelService) GetSponsors() (*SponsorList, error) { raw, err := cachedSponsors() if err != nil { @@ -220,7 +221,8 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList { 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://") { + notStarted := sp.From != nil && now.Before(*sp.From) + if disabled || notStarted || 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. diff --git a/internal/web/service/panel/sponsor_test.go b/internal/web/service/panel/sponsor_test.go index a7066206b..b2d244c12 100644 --- a/internal/web/service/panel/sponsor_test.go +++ b/internal/web/service/panel/sponsor_test.go @@ -37,6 +37,8 @@ func TestActiveSponsorsFilters(t *testing.T) { }{ {"valid", func(*Sponsor) {}, true}, {"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 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},