mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
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:
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user