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

* fix(settings): normalize API token timestamps

* refactor(api-token): share timestamp threshold

---------

Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com>
This commit is contained in:
Tomi lla
2026-06-27 16:30:58 +08:00
committed by GitHub
parent 6964d84742
commit 7a2179535a
7 changed files with 141 additions and 4 deletions
+8 -2
View File
@@ -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
View File
@@ -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);
});
});
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
}
}
@@ -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)
}
})
}
}