ソースを参照

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.
MHSanaei 6 時間 前
親
コミット
09617f04f5

+ 8 - 8
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

+ 8 - 1
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",

+ 8 - 1
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",

+ 2 - 0
frontend/src/generated/examples.ts

@@ -1107,6 +1107,7 @@ export const EXAMPLES: Record<string, unknown> = {
   },
   "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<string, unknown> = {
     "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",

+ 6 - 0
frontend/src/generated/schemas.ts

@@ -4476,6 +4476,12 @@ export const SCHEMAS: Record<string, unknown> = {
         "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"

+ 1 - 0
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;

+ 1 - 0
frontend/src/generated/zod.ts

@@ -1091,6 +1091,7 @@ export type Setting = z.infer<typeof SettingSchema>;
 
 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(),

+ 1 - 1
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',
       },
       {

+ 6 - 6
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)
 		}

+ 5 - 3
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.

+ 2 - 0
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},