mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-23 10:17:09 +08: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,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
|
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 type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
@@ -39,6 +39,12 @@ interface SecurityTabProps {
|
|||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
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';
|
type TfaType = 'set' | 'confirm';
|
||||||
|
|
||||||
interface TfaState {
|
interface TfaState {
|
||||||
@@ -194,7 +200,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
|
|
||||||
function formatTokenDate(ts: number): string {
|
function formatTokenDate(ts: number): string {
|
||||||
if (!ts) return '';
|
if (!ts) return '';
|
||||||
return new Date(ts * 1000).toLocaleString();
|
return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTwoFactor() {
|
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 {
|
if err := migrateHostVerifyPeerCertByNameColumn(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := normalizeApiTokenCreatedAtSeconds(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := dropLegacyForeignKeys(); err != nil {
|
if err := dropLegacyForeignKeys(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1085,6 +1088,15 @@ func InitDB(dbPath string) error {
|
|||||||
return runSeeders(isUsersEmpty)
|
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.
|
// sqliteSynchronous returns the SQLite synchronous mode, defaulting to FULL.
|
||||||
// Whitelisted because the value is interpolated directly into a PRAGMA string.
|
// Whitelisted because the value is interpolated directly into a PRAGMA string.
|
||||||
func sqliteSynchronous() string {
|
func sqliteSynchronous() string {
|
||||||
|
|||||||
@@ -149,12 +149,16 @@ type HistoryOfSeeders struct {
|
|||||||
SeederName string `json:"seederName"`
|
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 {
|
type ApiToken struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
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
|
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"`
|
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
|
// MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ type ApiTokenView struct {
|
|||||||
CreatedAt int64 `json:"createdAt" example:"1736000000"`
|
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
|
// 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
|
// token value: only a SHA-256 hash is stored, and the plaintext is shown
|
||||||
// exactly once at creation time.
|
// exactly once at creation time.
|
||||||
@@ -32,7 +39,7 @@ func toView(t *model.ApiToken) *ApiTokenView {
|
|||||||
Id: t.Id,
|
Id: t.Id,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Enabled: t.Enabled,
|
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