Просмотр исходного кода

fix(settings): normalize API token timestamps (#5599)

* fix(settings): normalize API token timestamps

* refactor(api-token): share timestamp threshold

---------

Co-authored-by: Tomilla <[email protected]>
Tomi lla 3 часов назад
Родитель
Сommit
7a2179535a

+ 8 - 2
frontend/src/pages/settings/SecurityTab.tsx

@@ -13,7 +13,7 @@ import {
   message,
 } from 'antd';
 import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
-import { ClipboardManager, HttpUtil, RandomUtil } from '@/utils';
+import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -39,6 +39,12 @@ interface SecurityTabProps {
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
+const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000;
+
+function apiTokenCreatedAtMilliseconds(createdAt: number): number {
+  return createdAt < UNIX_MILLISECONDS_THRESHOLD ? createdAt * 1000 : createdAt;
+}
+
 type TfaType = 'set' | 'confirm';
 
 interface TfaState {
@@ -194,7 +200,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
 
   function formatTokenDate(ts: number): string {
     if (!ts) return '';
-    return new Date(ts * 1000).toLocaleString();
+    return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts));
   }
 
   function toggleTwoFactor() {

+ 36 - 0
frontend/src/test/api-token-date.test.tsx

@@ -0,0 +1,36 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { AllSetting } from '@/models/setting';
+import SecurityTab from '@/pages/settings/SecurityTab';
+import { HttpUtil } from '@/utils';
+
+describe('API token creation date', () => {
+  it('renders both API seconds and legacy millisecond timestamps', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValueOnce({
+      success: true,
+      msg: '',
+      obj: [
+        {
+          id: 2,
+          name: 'seconds-token',
+          enabled: true,
+          createdAt: 1782485394,
+        },
+        {
+          id: 3,
+          name: 'legacy-milliseconds-token',
+          enabled: true,
+          createdAt: 1782485394270,
+        },
+      ],
+    });
+
+    render(<SecurityTab allSetting={{} as AllSetting} updateSetting={vi.fn()} />);
+    fireEvent.click(screen.getByRole('tab', { name: /API Token/ }));
+
+    expect(await screen.findByText('seconds-token')).toBeTruthy();
+    expect(screen.getByText('legacy-milliseconds-token')).toBeTruthy();
+    expect(screen.getAllByText(/2026/)).toHaveLength(2);
+  });
+});

+ 49 - 0
internal/database/api_token_timestamp_test.go

@@ -0,0 +1,49 @@
+package database
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+func TestNormalizeApiTokenCreatedAtSeconds(t *testing.T) {
+	originalDB := db
+	t.Cleanup(func() { db = originalDB })
+
+	var err error
+	db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open sqlite: %v", err)
+	}
+	if err := db.AutoMigrate(&model.ApiToken{}); err != nil {
+		t.Fatalf("migrate api_tokens: %v", err)
+	}
+
+	rows := []model.ApiToken{
+		{Name: "seconds", Token: "a", CreatedAt: 1_782_485_394},
+		{Name: "milliseconds", Token: "b", CreatedAt: 1_782_485_394_270},
+	}
+	if err := db.Create(&rows).Error; err != nil {
+		t.Fatalf("seed api tokens: %v", err)
+	}
+
+	if err := normalizeApiTokenCreatedAtSeconds(); err != nil {
+		t.Fatalf("normalize timestamps: %v", err)
+	}
+	if err := normalizeApiTokenCreatedAtSeconds(); err != nil {
+		t.Fatalf("normalize timestamps again: %v", err)
+	}
+
+	var got []model.ApiToken
+	if err := db.Order("id asc").Find(&got).Error; err != nil {
+		t.Fatalf("read api tokens: %v", err)
+	}
+	for _, row := range got {
+		if row.CreatedAt != 1_782_485_394 {
+			t.Fatalf("%s created_at = %d, want seconds", row.Name, row.CreatedAt)
+		}
+	}
+}

+ 12 - 0
internal/database/db.go

@@ -94,6 +94,9 @@ func initModels() error {
 	if err := migrateHostVerifyPeerCertByNameColumn(); err != nil {
 		return err
 	}
+	if err := normalizeApiTokenCreatedAtSeconds(); err != nil {
+		return err
+	}
 	if err := dropLegacyForeignKeys(); err != nil {
 		return err
 	}
@@ -1085,6 +1088,15 @@ func InitDB(dbPath string) error {
 	return runSeeders(isUsersEmpty)
 }
 
+// normalizeApiTokenCreatedAtSeconds repairs rows written while ApiToken used
+// autoCreateTime:milli. The threshold separates modern Unix milliseconds from
+// Unix seconds and makes this safe to run on every startup.
+func normalizeApiTokenCreatedAtSeconds() error {
+	return db.Model(&model.ApiToken{}).
+		Where("created_at >= ?", model.ApiTokenUnixMillisecondsThreshold).
+		UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error
+}
+
 // sqliteSynchronous returns the SQLite synchronous mode, defaulting to FULL.
 // Whitelisted because the value is interpolated directly into a PRAGMA string.
 func sqliteSynchronous() string {

+ 5 - 1
internal/database/model/model.go

@@ -149,12 +149,16 @@ type HistoryOfSeeders struct {
 	SeederName string `json:"seederName"`
 }
 
+// ApiTokenUnixMillisecondsThreshold separates legacy millisecond timestamps
+// from the seconds-based API token timestamp contract.
+const ApiTokenUnixMillisecondsThreshold int64 = 100_000_000_000
+
 type ApiToken struct {
 	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Name      string `json:"name" gorm:"uniqueIndex;not null"`
 	Token     string `json:"token" gorm:"not null"` // SHA-256 hash; the plaintext is shown only once at creation
 	Enabled   bool   `json:"enabled" gorm:"default:true"`
-	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
+	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime"`
 }
 
 // MarshalJSON emits settings, streamSettings, and sniffing as nested JSON

+ 8 - 1
internal/web/service/panel/api_token.go

@@ -24,6 +24,13 @@ type ApiTokenView struct {
 	CreatedAt int64  `json:"createdAt" example:"1736000000"`
 }
 
+func apiTokenCreatedAtSeconds(createdAt int64) int64 {
+	if createdAt >= model.ApiTokenUnixMillisecondsThreshold {
+		return createdAt / 1000
+	}
+	return createdAt
+}
+
 // toView builds the metadata view returned by List. It never carries the
 // token value: only a SHA-256 hash is stored, and the plaintext is shown
 // exactly once at creation time.
@@ -32,7 +39,7 @@ func toView(t *model.ApiToken) *ApiTokenView {
 		Id:        t.Id,
 		Name:      t.Name,
 		Enabled:   t.Enabled,
-		CreatedAt: t.CreatedAt,
+		CreatedAt: apiTokenCreatedAtSeconds(t.CreatedAt),
 	}
 }
 

+ 23 - 0
internal/web/service/panel/api_token_test.go

@@ -0,0 +1,23 @@
+package panel
+
+import "testing"
+
+func TestApiTokenCreatedAtSeconds(t *testing.T) {
+	tests := []struct {
+		name string
+		in   int64
+		want int64
+	}{
+		{name: "seconds", in: 1_782_485_394, want: 1_782_485_394},
+		{name: "legacy milliseconds", in: 1_782_485_394_270, want: 1_782_485_394},
+		{name: "unset", in: 0, want: 0},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := apiTokenCreatedAtSeconds(tt.in); got != tt.want {
+				t.Fatalf("apiTokenCreatedAtSeconds(%d) = %d, want %d", tt.in, got, tt.want)
+			}
+		})
+	}
+}