diff --git a/frontend/src/pages/settings/SecurityTab.tsx b/frontend/src/pages/settings/SecurityTab.tsx index 7e045268e..5a042f6ad 100644 --- a/frontend/src/pages/settings/SecurityTab.tsx +++ b/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) => 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() { diff --git a/frontend/src/test/api-token-date.test.tsx b/frontend/src/test/api-token-date.test.tsx new file mode 100644 index 000000000..31b75befa --- /dev/null +++ b/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(); + 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); + }); +}); diff --git a/internal/database/api_token_timestamp_test.go b/internal/database/api_token_timestamp_test.go new file mode 100644 index 000000000..5bfa471b6 --- /dev/null +++ b/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) + } + } +} diff --git a/internal/database/db.go b/internal/database/db.go index 55589d784..ef21409e2 100644 --- a/internal/database/db.go +++ b/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 { diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 0b5604298..a39d3b58a 100644 --- a/internal/database/model/model.go +++ b/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 diff --git a/internal/web/service/panel/api_token.go b/internal/web/service/panel/api_token.go index 624de5d54..6360be522 100644 --- a/internal/web/service/panel/api_token.go +++ b/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), } } diff --git a/internal/web/service/panel/api_token_test.go b/internal/web/service/panel/api_token_test.go new file mode 100644 index 000000000..04f37fcb6 --- /dev/null +++ b/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) + } + }) + } +}