feat: implement macOS notarization and ad-hoc signing in afterSign script
feat: add run-server script to manage server execution and compatibility checks feat: create main application logic for desktop with window management, server handling, and auto-updater test: add unit tests for desktop runtime helpers feat: implement desktop server environment configuration and health check test: add tests for server configuration and desktop routes feat: add native module guard for better-sqlite3 compatibility test: add tests for native module guard functionality test: add comprehensive tests for account verification feedback handling feat: implement account verification feedback helpers chore: add TypeScript configuration for desktop builds
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
Tray,
|
||||
dialog,
|
||||
nativeImage,
|
||||
shell,
|
||||
} from 'electron';
|
||||
import log from 'electron-log';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import getPort, { portNumbers } from 'get-port';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
buildDesktopServerEnv,
|
||||
createDesktopHealthUrl,
|
||||
createDesktopServerUrl,
|
||||
isFatalServerExit,
|
||||
waitForServerReady,
|
||||
} from './runtime.js';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let serverUrl = '';
|
||||
let isQuitting = false;
|
||||
let isRestartingBackend = false;
|
||||
|
||||
log.initialize();
|
||||
|
||||
function getUserDataDir() {
|
||||
return join(app.getPath('userData'), 'data');
|
||||
}
|
||||
|
||||
function getLogsDir() {
|
||||
return join(app.getPath('userData'), 'logs');
|
||||
}
|
||||
|
||||
function ensureDesktopDirs() {
|
||||
mkdirSync(getUserDataDir(), { recursive: true });
|
||||
mkdirSync(getLogsDir(), { recursive: true });
|
||||
}
|
||||
|
||||
function getServerEntryPath() {
|
||||
return join(app.getAppPath(), 'dist', 'server', 'index.js');
|
||||
}
|
||||
|
||||
function getTrayIconPath() {
|
||||
return join(app.getAppPath(), 'dist', 'web', 'favicon.png');
|
||||
}
|
||||
|
||||
function getWindowIconPath() {
|
||||
return getTrayIconPath();
|
||||
}
|
||||
|
||||
function resolveFrontendUrl() {
|
||||
const devUrl = (process.env.METAPI_DESKTOP_DEV_SERVER_URL || '').trim();
|
||||
if (devUrl) return devUrl;
|
||||
return serverUrl;
|
||||
}
|
||||
|
||||
function resolveExternalServerUrl() {
|
||||
return (process.env.METAPI_DESKTOP_EXTERNAL_SERVER_URL || '').trim();
|
||||
}
|
||||
|
||||
async function resolveServerPort() {
|
||||
const forcedPort = Number.parseInt(process.env.METAPI_DESKTOP_SERVER_PORT || '', 10);
|
||||
if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort;
|
||||
return getPort({ port: portNumbers(4310, 4399) });
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function buildTrayMenu() {
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open Metapi',
|
||||
click: () => showMainWindow(),
|
||||
},
|
||||
{
|
||||
label: 'Restart Backend',
|
||||
click: () => {
|
||||
void restartBackend();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Launch at Login',
|
||||
type: 'checkbox',
|
||||
checked: app.getLoginItemSettings().openAtLogin,
|
||||
click: (menuItem) => {
|
||||
app.setLoginItemSettings({ openAtLogin: menuItem.checked });
|
||||
tray?.setContextMenu(buildTrayMenu());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates',
|
||||
click: () => {
|
||||
void checkForUpdates(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Logs Folder',
|
||||
click: () => {
|
||||
void shell.openPath(getLogsDir());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function setupTray() {
|
||||
if (tray) return;
|
||||
const trayImage = nativeImage.createFromPath(getTrayIconPath());
|
||||
tray = new Tray(trayImage);
|
||||
tray.setToolTip('Metapi');
|
||||
tray.setContextMenu(buildTrayMenu());
|
||||
tray.on('double-click', () => showMainWindow());
|
||||
}
|
||||
|
||||
async function createMainWindow() {
|
||||
if (mainWindow) return mainWindow;
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1480,
|
||||
height: 960,
|
||||
minWidth: 1180,
|
||||
minHeight: 760,
|
||||
show: false,
|
||||
icon: existsSync(getWindowIconPath()) ? getWindowIconPath() : undefined,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
await mainWindow.loadURL(resolveFrontendUrl());
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function attachServerLogs(processHandle: ChildProcess) {
|
||||
processHandle.stdout?.on('data', (chunk) => {
|
||||
log.info(`[server] ${String(chunk).trimEnd()}`);
|
||||
});
|
||||
processHandle.stderr?.on('data', (chunk) => {
|
||||
log.error(`[server] ${String(chunk).trimEnd()}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForManagedServerReady(url: string) {
|
||||
await waitForServerReady({
|
||||
url,
|
||||
timeoutMs: 45_000,
|
||||
intervalMs: 250,
|
||||
});
|
||||
}
|
||||
|
||||
async function startManagedBackend() {
|
||||
ensureDesktopDirs();
|
||||
const serverEntryPath = getServerEntryPath();
|
||||
const port = await resolveServerPort();
|
||||
serverUrl = createDesktopServerUrl(port);
|
||||
|
||||
const env = buildDesktopServerEnv({
|
||||
inheritedEnv: process.env,
|
||||
userDataDir: getUserDataDir(),
|
||||
logsDir: getLogsDir(),
|
||||
port,
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [serverEntryPath], {
|
||||
cwd: app.getAppPath(),
|
||||
env: {
|
||||
...env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODE_ENV: app.isPackaged ? 'production' : 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
serverProcess = child;
|
||||
|
||||
attachServerLogs(child);
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
const fatal = isFatalServerExit({ code, signal });
|
||||
serverProcess = null;
|
||||
if (!fatal || isQuitting || isRestartingBackend) return;
|
||||
void handleServerCrash(code);
|
||||
});
|
||||
|
||||
await waitForManagedServerReady(createDesktopHealthUrl(port));
|
||||
}
|
||||
|
||||
async function connectToExternalBackend() {
|
||||
const externalServerUrl = resolveExternalServerUrl();
|
||||
serverUrl = externalServerUrl;
|
||||
await waitForServerReady({
|
||||
url: `${externalServerUrl}/api/desktop/health`,
|
||||
timeoutMs: 45_000,
|
||||
intervalMs: 250,
|
||||
});
|
||||
}
|
||||
|
||||
async function stopManagedBackend() {
|
||||
if (!serverProcess) return;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const current = serverProcess;
|
||||
if (!current) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
current.kill('SIGKILL');
|
||||
}, 10_000);
|
||||
|
||||
current.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
current.kill();
|
||||
});
|
||||
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
async function handleServerCrash(code: number | null) {
|
||||
mainWindow?.hide();
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Metapi backend stopped',
|
||||
message: `The local Metapi backend exited unexpectedly${typeof code === 'number' ? ` (code ${code})` : ''}.`,
|
||||
detail: 'You can restart the backend now or quit the desktop app.',
|
||||
buttons: ['Restart Backend', 'Quit'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await restartBackend();
|
||||
return;
|
||||
}
|
||||
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
|
||||
async function restartBackend() {
|
||||
if (isRestartingBackend) return;
|
||||
isRestartingBackend = true;
|
||||
try {
|
||||
await stopManagedBackend();
|
||||
if (resolveExternalServerUrl()) {
|
||||
await connectToExternalBackend();
|
||||
} else {
|
||||
await startManagedBackend();
|
||||
}
|
||||
if (mainWindow) {
|
||||
await mainWindow.loadURL(resolveFrontendUrl());
|
||||
showMainWindow();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to restart backend', error);
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Restart failed',
|
||||
message: 'Metapi could not restart the local backend.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
isRestartingBackend = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates(isManual: boolean) {
|
||||
if (!app.isPackaged) {
|
||||
if (isManual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Updates unavailable',
|
||||
message: 'Auto-update is only available for packaged desktop builds.',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
log.error('Failed to check for updates', error);
|
||||
if (isManual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Update check failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupAutoUpdater() {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.logger = log;
|
||||
|
||||
autoUpdater.on('update-available', async (info) => {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update available',
|
||||
message: `Metapi ${info.version} is available.`,
|
||||
detail: 'Download and install it after the current session?',
|
||||
buttons: ['Download', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await autoUpdater.downloadUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', async () => {
|
||||
log.info('No desktop updates available');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', async () => {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update ready',
|
||||
message: 'The new Metapi desktop update is ready to install.',
|
||||
buttons: ['Install and Restart', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
isQuitting = true;
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
log.error('Auto-update error', error);
|
||||
});
|
||||
|
||||
void checkForUpdates(false);
|
||||
}
|
||||
|
||||
async function bootDesktopApp() {
|
||||
if (resolveExternalServerUrl()) {
|
||||
await connectToExternalBackend();
|
||||
} else {
|
||||
await startManagedBackend();
|
||||
}
|
||||
|
||||
setupTray();
|
||||
await createMainWindow();
|
||||
setupAutoUpdater();
|
||||
app.setLoginItemSettings({ openAtLogin: app.getLoginItemSettings().openAtLogin });
|
||||
}
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
showMainWindow();
|
||||
});
|
||||
|
||||
app.whenReady()
|
||||
.then(async () => {
|
||||
try {
|
||||
await bootDesktopApp();
|
||||
} catch (error) {
|
||||
log.error('Failed to boot Metapi desktop', error);
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Metapi failed to start',
|
||||
message: 'The desktop shell could not start the local Metapi service.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
buttons: ['Retry', 'Quit'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await bootDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error('Unhandled startup error', error);
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (!mainWindow) {
|
||||
void createMainWindow();
|
||||
return;
|
||||
}
|
||||
showMainWindow();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
tray?.destroy();
|
||||
tray = null;
|
||||
void stopManagedBackend();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Keep the tray resident app alive until the user explicitly quits.
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildDesktopServerEnv,
|
||||
createDesktopServerUrl,
|
||||
isFatalServerExit,
|
||||
waitForServerReady,
|
||||
} from './runtime.js';
|
||||
|
||||
describe('desktop runtime helpers', () => {
|
||||
it('builds desktop server env with loopback host and app directories', () => {
|
||||
const env = buildDesktopServerEnv({
|
||||
inheritedEnv: {
|
||||
AUTH_TOKEN: 'admin-token',
|
||||
PROXY_TOKEN: 'proxy-token',
|
||||
},
|
||||
userDataDir: '/tmp/metapi-data',
|
||||
logsDir: '/tmp/metapi-logs',
|
||||
port: 4312,
|
||||
});
|
||||
|
||||
expect(env.HOST).toBe('127.0.0.1');
|
||||
expect(env.PORT).toBe('4312');
|
||||
expect(env.DATA_DIR).toBe('/tmp/metapi-data');
|
||||
expect(env.METAPI_LOG_DIR).toBe('/tmp/metapi-logs');
|
||||
expect(env.AUTH_TOKEN).toBe('admin-token');
|
||||
expect(env.PROXY_TOKEN).toBe('proxy-token');
|
||||
});
|
||||
|
||||
it('creates the browser URL from the local desktop port', () => {
|
||||
expect(createDesktopServerUrl(4312)).toBe('http://127.0.0.1:4312');
|
||||
});
|
||||
|
||||
it('waits until the health probe returns ok', async () => {
|
||||
const fetcher = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce({ ok: false })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await expect(waitForServerReady({
|
||||
url: 'http://127.0.0.1:4312/api/desktop/health',
|
||||
fetcher,
|
||||
timeoutMs: 250,
|
||||
intervalMs: 1,
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('fails when the health probe never becomes ready', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({ ok: false });
|
||||
|
||||
await expect(waitForServerReady({
|
||||
url: 'http://127.0.0.1:4312/api/desktop/health',
|
||||
fetcher,
|
||||
timeoutMs: 10,
|
||||
intervalMs: 1,
|
||||
})).rejects.toThrow('Timed out waiting for metapi desktop server');
|
||||
});
|
||||
|
||||
it('treats non-zero non-signal exits as fatal', () => {
|
||||
expect(isFatalServerExit({ code: 1, signal: null })).toBe(true);
|
||||
expect(isFatalServerExit({ code: 0, signal: null })).toBe(false);
|
||||
expect(isFatalServerExit({ code: null, signal: 'SIGTERM' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
type DesktopServerEnvInput = {
|
||||
inheritedEnv?: NodeJS.ProcessEnv;
|
||||
userDataDir: string;
|
||||
logsDir: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type WaitForServerReadyInput = {
|
||||
url: string;
|
||||
fetcher?: (input: string, init?: RequestInit) => Promise<{ ok: boolean }>;
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
type ServerExitState = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_READY_INTERVAL_MS = 250;
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function buildDesktopServerEnv(input: DesktopServerEnvInput): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...(input.inheritedEnv || {}),
|
||||
HOST: '127.0.0.1',
|
||||
PORT: String(input.port),
|
||||
DATA_DIR: input.userDataDir,
|
||||
METAPI_DESKTOP: '1',
|
||||
METAPI_LOG_DIR: input.logsDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopServerUrl(port: number): string {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export function createDesktopHealthUrl(port: number): string {
|
||||
return `${createDesktopServerUrl(port)}/api/desktop/health`;
|
||||
}
|
||||
|
||||
export async function waitForServerReady(input: WaitForServerReadyInput): Promise<void> {
|
||||
const fetcher = input.fetcher || ((url: string, init?: RequestInit) => fetch(url, init));
|
||||
const timeoutMs = input.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
||||
const intervalMs = input.intervalMs ?? DEFAULT_READY_INTERVAL_MS;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
try {
|
||||
const response = await fetcher(input.url, { method: 'GET' });
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Retry until timeout.
|
||||
}
|
||||
await delay(intervalMs);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for metapi desktop server');
|
||||
}
|
||||
|
||||
export function isFatalServerExit(exitState: ServerExitState): boolean {
|
||||
return exitState.code !== null && exitState.code !== 0 && !exitState.signal;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildConfig } from './config.js';
|
||||
|
||||
describe('buildConfig', () => {
|
||||
it('defaults to external listen host for server deployments', () => {
|
||||
const config = buildConfig({});
|
||||
|
||||
expect(config.listenHost).toBe('0.0.0.0');
|
||||
expect(config.port).toBe(4000);
|
||||
expect(config.dataDir).toBe('./data');
|
||||
});
|
||||
|
||||
it('keeps desktop deployments bound to loopback', () => {
|
||||
const config = buildConfig({
|
||||
HOST: '0.0.0.0',
|
||||
METAPI_DESKTOP: '1',
|
||||
PORT: '4312',
|
||||
DATA_DIR: '/tmp/metapi-data',
|
||||
});
|
||||
|
||||
expect(config.listenHost).toBe('127.0.0.1');
|
||||
expect(config.port).toBe(4312);
|
||||
expect(config.dataDir).toBe('/tmp/metapi-data');
|
||||
});
|
||||
|
||||
it('honors explicit loopback host outside desktop mode', () => {
|
||||
const config = buildConfig({
|
||||
HOST: '127.0.0.1',
|
||||
});
|
||||
|
||||
expect(config.listenHost).toBe('127.0.0.1');
|
||||
});
|
||||
});
|
||||
+56
-42
@@ -28,46 +28,60 @@ function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres'
|
||||
return 'sqlite';
|
||||
}
|
||||
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
function parseListenHost(env: NodeJS.ProcessEnv): string {
|
||||
const requestedHost = (env.HOST || '0.0.0.0').trim() || '0.0.0.0';
|
||||
if (parseBoolean(env.METAPI_DESKTOP, false)) {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
return requestedHost;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
authToken: process.env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
proxyToken: process.env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
||||
accountCredentialSecret: process.env.ACCOUNT_CREDENTIAL_SECRET || process.env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
checkinCron: process.env.CHECKIN_CRON || '0 8 * * *',
|
||||
balanceRefreshCron: process.env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||
webhookUrl: process.env.WEBHOOK_URL || '',
|
||||
barkUrl: process.env.BARK_URL || '',
|
||||
webhookEnabled: parseBoolean(process.env.WEBHOOK_ENABLED, true),
|
||||
barkEnabled: parseBoolean(process.env.BARK_ENABLED, true),
|
||||
serverChanEnabled: parseBoolean(process.env.SERVERCHAN_ENABLED, true),
|
||||
serverChanKey: process.env.SERVERCHAN_KEY || '',
|
||||
telegramEnabled: parseBoolean(process.env.TELEGRAM_ENABLED, false),
|
||||
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||
telegramChatId: process.env.TELEGRAM_CHAT_ID || '',
|
||||
smtpEnabled: parseBoolean(process.env.SMTP_ENABLED, false),
|
||||
smtpHost: process.env.SMTP_HOST || '',
|
||||
smtpPort: parseInt(process.env.SMTP_PORT || '587'),
|
||||
smtpSecure: parseBoolean(process.env.SMTP_SECURE, false),
|
||||
smtpUser: process.env.SMTP_USER || '',
|
||||
smtpPass: process.env.SMTP_PASS || '',
|
||||
smtpFrom: process.env.SMTP_FROM || '',
|
||||
smtpTo: process.env.SMTP_TO || '',
|
||||
notifyCooldownSec: Math.max(0, Math.trunc(parseNumber(process.env.NOTIFY_COOLDOWN_SEC, 300))),
|
||||
adminIpAllowlist: parseCsvList(process.env.ADMIN_IP_ALLOWLIST),
|
||||
port: Math.trunc(parseNumber(process.env.PORT, 4000)),
|
||||
dataDir,
|
||||
dbType: parseDbType(process.env.DB_TYPE),
|
||||
dbUrl: (process.env.DB_URL || '').trim(),
|
||||
routingFallbackUnitCost: Math.max(1e-6, parseNumber(process.env.ROUTING_FALLBACK_UNIT_COST, 1)),
|
||||
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(process.env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
|
||||
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(process.env.PROXY_LOG_RETENTION_DAYS, 30))),
|
||||
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(process.env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
|
||||
routingWeights: {
|
||||
baseWeightFactor: parseNumber(process.env.BASE_WEIGHT_FACTOR, 0.5),
|
||||
valueScoreFactor: parseNumber(process.env.VALUE_SCORE_FACTOR, 0.5),
|
||||
costWeight: parseNumber(process.env.COST_WEIGHT, 0.4),
|
||||
balanceWeight: parseNumber(process.env.BALANCE_WEIGHT, 0.3),
|
||||
usageWeight: parseNumber(process.env.USAGE_WEIGHT, 0.3),
|
||||
},
|
||||
};
|
||||
export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
const dataDir = env.DATA_DIR || './data';
|
||||
|
||||
return {
|
||||
authToken: env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
||||
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
|
||||
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||
webhookUrl: env.WEBHOOK_URL || '',
|
||||
barkUrl: env.BARK_URL || '',
|
||||
webhookEnabled: parseBoolean(env.WEBHOOK_ENABLED, true),
|
||||
barkEnabled: parseBoolean(env.BARK_ENABLED, true),
|
||||
serverChanEnabled: parseBoolean(env.SERVERCHAN_ENABLED, true),
|
||||
serverChanKey: env.SERVERCHAN_KEY || '',
|
||||
telegramEnabled: parseBoolean(env.TELEGRAM_ENABLED, false),
|
||||
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
|
||||
telegramChatId: env.TELEGRAM_CHAT_ID || '',
|
||||
smtpEnabled: parseBoolean(env.SMTP_ENABLED, false),
|
||||
smtpHost: env.SMTP_HOST || '',
|
||||
smtpPort: parseInt(env.SMTP_PORT || '587'),
|
||||
smtpSecure: parseBoolean(env.SMTP_SECURE, false),
|
||||
smtpUser: env.SMTP_USER || '',
|
||||
smtpPass: env.SMTP_PASS || '',
|
||||
smtpFrom: env.SMTP_FROM || '',
|
||||
smtpTo: env.SMTP_TO || '',
|
||||
notifyCooldownSec: Math.max(0, Math.trunc(parseNumber(env.NOTIFY_COOLDOWN_SEC, 300))),
|
||||
adminIpAllowlist: parseCsvList(env.ADMIN_IP_ALLOWLIST),
|
||||
port: Math.trunc(parseNumber(env.PORT, 4000)),
|
||||
listenHost: parseListenHost(env),
|
||||
dataDir,
|
||||
dbType: parseDbType(env.DB_TYPE),
|
||||
dbUrl: (env.DB_URL || '').trim(),
|
||||
dbSsl: parseBoolean(env.DB_SSL, false),
|
||||
routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)),
|
||||
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
|
||||
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
|
||||
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
|
||||
routingWeights: {
|
||||
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
|
||||
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
|
||||
costWeight: parseNumber(env.COST_WEIGHT, 0.4),
|
||||
balanceWeight: parseNumber(env.BALANCE_WEIGHT, 0.3),
|
||||
usageWeight: parseNumber(env.USAGE_WEIGHT, 0.3),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const config = buildConfig(process.env);
|
||||
|
||||
+16
-3
@@ -532,7 +532,11 @@ function initMysqlDb(): AppDb {
|
||||
if (!config.dbUrl) {
|
||||
throw new Error('DB_URL is required when DB_TYPE=mysql');
|
||||
}
|
||||
mysqlPool = mysql.createPool(config.dbUrl);
|
||||
const poolOptions: mysql.PoolOptions = { uri: config.dbUrl };
|
||||
if (config.dbSsl) {
|
||||
poolOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
mysqlPool = mysql.createPool(poolOptions);
|
||||
|
||||
const rawDb = drizzleMysqlProxy(
|
||||
(sqlText, params, method) => mysqlProxyQuery(mysqlPool!, sqlText, params, method as SqlMethod),
|
||||
@@ -564,7 +568,11 @@ function initPostgresDb(): AppDb {
|
||||
if (!config.dbUrl) {
|
||||
throw new Error('DB_URL is required when DB_TYPE=postgres');
|
||||
}
|
||||
pgPool = new pg.Pool({ connectionString: config.dbUrl });
|
||||
const poolOptions: pg.PoolConfig = { connectionString: config.dbUrl };
|
||||
if (config.dbSsl) {
|
||||
poolOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
pgPool = new pg.Pool(poolOptions);
|
||||
|
||||
const rawDb = drizzlePgProxy(
|
||||
(sqlText, params, method) => pgProxyQuery(pgPool!, sqlText, params, method as SqlMethod),
|
||||
@@ -622,16 +630,20 @@ export async function closeDbConnections(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextDbUrl: string): Promise<void> {
|
||||
export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextDbUrl: string, nextDbSsl?: boolean): Promise<void> {
|
||||
const previousDialect = runtimeDbDialect;
|
||||
const previousDbUrl = config.dbUrl;
|
||||
const previousConfigDialect = config.dbType;
|
||||
const previousDbSsl = config.dbSsl;
|
||||
|
||||
await closeDbConnections();
|
||||
|
||||
runtimeDbDialect = nextDialect;
|
||||
config.dbType = nextDialect;
|
||||
config.dbUrl = nextDbUrl;
|
||||
if (nextDbSsl !== undefined) {
|
||||
config.dbSsl = nextDbSsl;
|
||||
}
|
||||
|
||||
try {
|
||||
activeDb = initDb();
|
||||
@@ -640,6 +652,7 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD
|
||||
runtimeDbDialect = previousDialect;
|
||||
config.dbType = previousConfigDialect;
|
||||
config.dbUrl = previousDbUrl;
|
||||
config.dbSsl = previousDbSsl;
|
||||
activeDb = initDb();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Fastify from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||
|
||||
describe('desktop server routes', () => {
|
||||
it('marks only the desktop health route as public', () => {
|
||||
expect(isPublicApiRoute('/api/desktop/health')).toBe(true);
|
||||
expect(isPublicApiRoute('/api/stats/dashboard')).toBe(false);
|
||||
});
|
||||
|
||||
it('registers a public desktop health probe', async () => {
|
||||
const app = Fastify();
|
||||
await registerDesktopRoutes(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/desktop/health',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ ok: true });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const DESKTOP_HEALTH_ROUTE = '/api/desktop/health';
|
||||
|
||||
export function isPublicApiRoute(url: string): boolean {
|
||||
return url === DESKTOP_HEALTH_ROUTE;
|
||||
}
|
||||
|
||||
export async function registerDesktopRoutes(app: FastifyInstance) {
|
||||
app.get(DESKTOP_HEALTH_ROUTE, async () => ({ ok: true }));
|
||||
}
|
||||
+14
-8
@@ -22,6 +22,7 @@ import { startScheduler } from './services/checkinScheduler.js';
|
||||
import { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||
import { existsSync } from 'fs';
|
||||
import { normalize, resolve, sep } from 'path';
|
||||
import { db, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js';
|
||||
@@ -74,14 +75,19 @@ function validateSavedDbUrl(dialect: RuntimeDbDialect, value: unknown): string |
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): { dialect: RuntimeDbDialect; dbUrl: string } | null {
|
||||
function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): { dialect: RuntimeDbDialect; dbUrl: string; ssl: boolean } | null {
|
||||
const rawType = parseSettingFromMap<unknown>(settingsMap, 'db_type');
|
||||
const rawUrl = parseSettingFromMap<unknown>(settingsMap, 'db_url');
|
||||
const rawSsl = parseSettingFromMap<boolean>(settingsMap, 'db_ssl');
|
||||
const dialect = normalizeSavedDbType(rawType);
|
||||
if (!dialect) return null;
|
||||
const dbUrl = validateSavedDbUrl(dialect, rawUrl);
|
||||
if (!dbUrl) return null;
|
||||
return { dialect, dbUrl };
|
||||
return {
|
||||
dialect,
|
||||
dbUrl,
|
||||
ssl: typeof rawSsl === 'boolean' ? rawSsl : false,
|
||||
};
|
||||
}
|
||||
|
||||
function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||
@@ -171,9 +177,9 @@ try {
|
||||
const initialMap = toSettingsMap(initialRows);
|
||||
const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap);
|
||||
const activeDbUrl = (config.dbUrl || '').trim();
|
||||
if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl)) {
|
||||
if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl || savedDbConfig.ssl !== config.dbSsl)) {
|
||||
try {
|
||||
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl);
|
||||
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl, savedDbConfig.ssl);
|
||||
console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`);
|
||||
@@ -194,12 +200,13 @@ await app.register(cors);
|
||||
|
||||
// Auth middleware for /api routes
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (request.url.startsWith('/api/')) {
|
||||
if (request.url.startsWith('/api/') && !isPublicApiRoute(request.url)) {
|
||||
await authMiddleware(request, reply);
|
||||
}
|
||||
});
|
||||
|
||||
// Register API routes
|
||||
await app.register(registerDesktopRoutes);
|
||||
await app.register(sitesRoutes);
|
||||
await app.register(accountsRoutes);
|
||||
await app.register(checkinRoutes);
|
||||
@@ -254,11 +261,10 @@ app.addHook('onClose', async () => {
|
||||
|
||||
// Start server
|
||||
try {
|
||||
const listenHost = '0.0.0.0';
|
||||
await app.listen({ port: config.port, host: listenHost });
|
||||
await app.listen({ port: config.port, host: config.listenHost });
|
||||
const summaryLines = buildStartupSummaryLines({
|
||||
port: config.port,
|
||||
host: listenHost,
|
||||
host: config.listenHost,
|
||||
authToken: config.authToken,
|
||||
proxyToken: config.proxyToken,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildBetterSqliteRebuildCommand,
|
||||
isNodeModuleVersionMismatch,
|
||||
} from './nativeModuleGuard.js';
|
||||
|
||||
describe('nativeModuleGuard', () => {
|
||||
it('detects better-sqlite3 ABI mismatch errors', () => {
|
||||
const error = Object.assign(
|
||||
new Error('NODE_MODULE_VERSION 133. This version of Node.js requires NODE_MODULE_VERSION 137.'),
|
||||
{ code: 'ERR_DLOPEN_FAILED' },
|
||||
);
|
||||
|
||||
expect(isNodeModuleVersionMismatch(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unrelated native module errors', () => {
|
||||
const error = Object.assign(
|
||||
new Error('Cannot find module'),
|
||||
{ code: 'MODULE_NOT_FOUND' },
|
||||
);
|
||||
|
||||
expect(isNodeModuleVersionMismatch(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a rebuild command for the current npm client', () => {
|
||||
const command = buildBetterSqliteRebuildCommand({
|
||||
npmExecPath: '/tmp/npm-cli.js',
|
||||
});
|
||||
|
||||
expect(command.command).toBe(process.execPath);
|
||||
expect(command.args).toEqual([
|
||||
'/tmp/npm-cli.js',
|
||||
'rebuild',
|
||||
'better-sqlite3',
|
||||
'--no-audit',
|
||||
'--fund=false',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
type RebuildCommandInput = {
|
||||
npmExecPath?: string;
|
||||
};
|
||||
|
||||
type RebuildCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
export function isNodeModuleVersionMismatch(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const code = 'code' in error ? String((error as { code?: unknown }).code || '') : '';
|
||||
const message = 'message' in error ? String((error as { message?: unknown }).message || '') : '';
|
||||
return code === 'ERR_DLOPEN_FAILED' && message.includes('NODE_MODULE_VERSION');
|
||||
}
|
||||
|
||||
export function buildBetterSqliteRebuildCommand(input: RebuildCommandInput = {}): RebuildCommand {
|
||||
const npmExecPath = input.npmExecPath || process.env.npm_execpath;
|
||||
if (npmExecPath && npmExecPath.length > 0) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
npmExecPath,
|
||||
'rebuild',
|
||||
'better-sqlite3',
|
||||
'--no-audit',
|
||||
'--fund=false',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
return {
|
||||
command: npmCommand,
|
||||
args: ['rebuild', 'better-sqlite3', '--no-audit', '--fund=false'],
|
||||
};
|
||||
}
|
||||
@@ -207,6 +207,53 @@ describe('accounts health refresh runtime state', () => {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
it('skips runtime refresh for proxy-only accounts', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Proxy Site',
|
||||
url: 'https://proxy.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: 'api-key-token',
|
||||
status: 'expired',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'apikey',
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/health/refresh',
|
||||
payload: { accountId: account.id, wait: true },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
success: boolean;
|
||||
summary: {
|
||||
total: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
results: Array<{ status: string; state: string; message: string }>;
|
||||
};
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.summary).toMatchObject({
|
||||
total: 1,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(body.results[0]).toMatchObject({
|
||||
status: 'skipped',
|
||||
state: 'unknown',
|
||||
});
|
||||
expect(refreshBalanceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finishes the background refresh-all task after the 10 second site timeout instead of staying in progress', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -47,6 +47,8 @@ type AccountCapabilities = {
|
||||
proxyOnly: boolean;
|
||||
};
|
||||
|
||||
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
|
||||
|
||||
function hasSessionTokenValue(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -126,6 +128,8 @@ type LoginFailureInfo = {
|
||||
};
|
||||
|
||||
const ACCOUNT_HEALTH_REFRESH_TIMEOUT_MS = 10_000;
|
||||
const ACCOUNT_VERIFY_TIMEOUT_MS = 10_000;
|
||||
const ACCOUNT_VERIFY_DIAG_TIMEOUT_MS = 2_500;
|
||||
|
||||
function normalizeLoginFailure(message: string | null | undefined): LoginFailureInfo {
|
||||
const raw = (message || '').trim();
|
||||
@@ -184,12 +188,53 @@ async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMe
|
||||
}
|
||||
}
|
||||
|
||||
function isVerificationTimeoutError(error: unknown): boolean {
|
||||
const name = typeof error === 'object' && error && 'name' in error
|
||||
? String((error as { name?: unknown }).name || '')
|
||||
: '';
|
||||
const message = typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: unknown }).message || '')
|
||||
: String(error || '');
|
||||
const lowered = `${name} ${message}`.toLowerCase();
|
||||
return lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('abort');
|
||||
}
|
||||
|
||||
function resolveUserIdFailureReason(message: string, hasProvidedUserId: boolean): VerifyFailureReason {
|
||||
const lowered = String(message || '').trim().toLowerCase();
|
||||
if (!lowered) return null;
|
||||
|
||||
if (
|
||||
lowered.includes('mismatch')
|
||||
|| lowered.includes('not match')
|
||||
|| lowered.includes('invalid user id')
|
||||
|| lowered.includes('wrong user id')
|
||||
) {
|
||||
return 'invalid-user-id';
|
||||
}
|
||||
|
||||
if (
|
||||
lowered.includes('missing new-api-user')
|
||||
|| lowered.includes('new-api-user required')
|
||||
|| lowered.includes('requires user id')
|
||||
|| lowered.includes('missing user id')
|
||||
) {
|
||||
return 'needs-user-id';
|
||||
}
|
||||
|
||||
if (lowered.includes('new-api-user') || lowered.includes('user id')) {
|
||||
return hasProvidedUserId ? 'invalid-user-id' : 'needs-user-id';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<AccountHealthRefreshResult> {
|
||||
const accountId = row.accounts.id;
|
||||
const username = row.accounts.username;
|
||||
const siteName = row.sites.name;
|
||||
|
||||
if ((row.accounts.status || 'active') === 'disabled' || (row.sites.status || 'active') === 'disabled') {
|
||||
const accountId = row.accounts.id;
|
||||
const username = row.accounts.username;
|
||||
const siteName = row.sites.name;
|
||||
const capabilities = buildCapabilitiesForAccount(row.accounts);
|
||||
|
||||
if ((row.accounts.status || 'active') === 'disabled' || (row.sites.status || 'active') === 'disabled') {
|
||||
setAccountRuntimeHealth(accountId, {
|
||||
state: 'disabled',
|
||||
reason: '账号或站点已禁用',
|
||||
@@ -202,7 +247,18 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
||||
status: 'skipped',
|
||||
state: 'disabled',
|
||||
message: '账号或站点已禁用',
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (capabilities.proxyOnly) {
|
||||
return {
|
||||
accountId,
|
||||
username,
|
||||
siteName,
|
||||
status: 'skipped',
|
||||
state: 'unknown',
|
||||
message: '仅代理账号不支持会话健康检查',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -218,6 +274,7 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
||||
accountStatus: refreshedAccount?.status || row.accounts.status,
|
||||
siteStatus: row.sites.status,
|
||||
extraConfig: refreshedAccount?.extraConfig ?? row.accounts.extraConfig,
|
||||
sessionCapable: capabilities.canRefreshBalance,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -333,6 +390,10 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
accountStatus: r.accounts.status,
|
||||
siteStatus: r.sites.status,
|
||||
extraConfig: r.accounts.extraConfig,
|
||||
sessionCapable: buildCapabilitiesFromCredentialMode(
|
||||
credentialMode,
|
||||
hasSessionTokenValue(r.accounts.accessToken),
|
||||
).canRefreshBalance,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -464,9 +525,119 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
const adapter = getAdapter(site.platform);
|
||||
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
|
||||
|
||||
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
|
||||
const hasProvidedUserId = Number.isFinite(platformUserId) && platformUserId > 0;
|
||||
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
|
||||
const diagnoseVerificationFailure = async (): Promise<VerifyFailureReason> => {
|
||||
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
|
||||
const text = bodyText || '';
|
||||
const ct = (contentType || '').toLowerCase();
|
||||
if (
|
||||
!skipRawShieldDetection
|
||||
&& ct.includes('text/html')
|
||||
&& /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)
|
||||
) {
|
||||
return 'shield-blocked';
|
||||
}
|
||||
|
||||
try {
|
||||
const body = JSON.parse(text) as any;
|
||||
const message = typeof body?.message === 'string' ? body.message : '';
|
||||
const userIdReason = resolveUserIdFailureReason(message, hasProvidedUserId);
|
||||
if (userIdReason) return userIdReason;
|
||||
if (!skipRawShieldDetection && /shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) {
|
||||
return 'shield-blocked';
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
const { fetch } = await import('undici');
|
||||
const candidates = new Set<string>();
|
||||
const raw = accessToken.startsWith('Bearer ') ? accessToken.slice(7).trim() : accessToken;
|
||||
if (raw) {
|
||||
if (raw.includes('=')) candidates.add(raw);
|
||||
candidates.add(`session=${raw}`);
|
||||
candidates.add(`token=${raw}`);
|
||||
}
|
||||
|
||||
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
|
||||
const headerVariants: Record<string, string>[] = [
|
||||
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
|
||||
];
|
||||
|
||||
for (const cookie of candidates) {
|
||||
headerVariants.push({
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(hasProvidedUserId ? { 'New-Api-User': diagnosticUserId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const headers of headerVariants) {
|
||||
try {
|
||||
const testRes = await fetch(
|
||||
`${site.url}/api/user/self`,
|
||||
withExplicitProxyRequestInit(site.proxyUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
|
||||
}),
|
||||
);
|
||||
const bodyText = await testRes.text();
|
||||
const contentType = testRes.headers.get('content-type') || '';
|
||||
const reason = parseFailureReason(bodyText, contentType);
|
||||
if (reason) return reason;
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return null;
|
||||
};
|
||||
const buildVerificationFailureResponse = (failureReason: VerifyFailureReason) => {
|
||||
if (failureReason === 'needs-user-id') {
|
||||
return {
|
||||
success: false,
|
||||
needsUserId: true,
|
||||
message: 'This site requires a user ID. Please fill in your site user ID.',
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'invalid-user-id') {
|
||||
return {
|
||||
success: false,
|
||||
invalidUserId: true,
|
||||
message: 'The provided user ID does not match this token. Please check your site user ID.',
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'shield-blocked') {
|
||||
return {
|
||||
success: false,
|
||||
shieldBlocked: true,
|
||||
message: 'This site is shielded by anti-bot challenge. Create an API key on the target site and import that key.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!platformUserId && (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter')) {
|
||||
const preflightReason = await diagnoseVerificationFailure();
|
||||
if (preflightReason === 'needs-user-id') {
|
||||
return buildVerificationFailureResponse(preflightReason);
|
||||
}
|
||||
}
|
||||
|
||||
if (credentialMode === 'apikey') {
|
||||
try {
|
||||
const models = await adapter.getModels(site.url, accessToken, platformUserId);
|
||||
const models = await withTimeout(
|
||||
() => adapter.getModels(site.url, accessToken, platformUserId),
|
||||
ACCOUNT_VERIFY_TIMEOUT_MS,
|
||||
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
|
||||
);
|
||||
const availableModels = Array.isArray(models) ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
|
||||
if (availableModels.length === 0) {
|
||||
return {
|
||||
@@ -481,6 +652,10 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
models: availableModels.slice(0, 10),
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (isVerificationTimeoutError(err)) {
|
||||
const failure = buildVerificationFailureResponse(await diagnoseVerificationFailure());
|
||||
if (failure) return failure;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: err?.message || 'API Key 验证失败',
|
||||
@@ -490,8 +665,16 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
result = await adapter.verifyToken(site.url, accessToken, platformUserId);
|
||||
result = await withTimeout(
|
||||
() => adapter.verifyToken(site.url, accessToken, platformUserId),
|
||||
ACCOUNT_VERIFY_TIMEOUT_MS,
|
||||
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (isVerificationTimeoutError(err)) {
|
||||
const failure = buildVerificationFailureResponse(await diagnoseVerificationFailure());
|
||||
if (failure) return failure;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
|
||||
@@ -524,12 +707,6 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// Try to explain unknown failures: missing user id vs anti-bot challenge page.
|
||||
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
|
||||
// New-API family already runs shield-aware probing inside adapters.
|
||||
// Raw fallback probe below does not include challenge-solving and can
|
||||
// misclassify valid Cookie/Session flows as shield-blocked.
|
||||
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
|
||||
type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
|
||||
const detectVerifyFailureReason = async (): Promise<VerifyFailureReason> => {
|
||||
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
|
||||
const text = bodyText || '';
|
||||
@@ -545,7 +722,8 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
const body = JSON.parse(text) as any;
|
||||
const message = typeof body?.message === 'string' ? body.message : '';
|
||||
if (/mismatch|new-api-user|user id/i.test(message)) return 'needs-user-id';
|
||||
const userIdReason = resolveUserIdFailureReason(message, hasProvidedUserId);
|
||||
if (userIdReason) return userIdReason;
|
||||
if (!skipRawShieldDetection && /shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) {
|
||||
return 'shield-blocked';
|
||||
}
|
||||
@@ -564,8 +742,9 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
candidates.add(`token=${raw}`);
|
||||
}
|
||||
|
||||
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
|
||||
const headerVariants: Record<string, string>[] = [
|
||||
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': '0' },
|
||||
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
|
||||
];
|
||||
|
||||
for (const cookie of candidates) {
|
||||
@@ -573,12 +752,19 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(hasProvidedUserId ? { 'New-Api-User': diagnosticUserId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const headers of headerVariants) {
|
||||
try {
|
||||
const testRes = await fetch(`${site.url}/api/user/self`, withExplicitProxyRequestInit(site.proxyUrl, { headers }));
|
||||
const testRes = await fetch(
|
||||
`${site.url}/api/user/self`,
|
||||
withExplicitProxyRequestInit(site.proxyUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
|
||||
}),
|
||||
);
|
||||
const bodyText = await testRes.text();
|
||||
const contentType = testRes.headers.get('content-type') || '';
|
||||
const reason = parseFailureReason(bodyText, contentType);
|
||||
@@ -599,6 +785,14 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'invalid-user-id') {
|
||||
return {
|
||||
success: false,
|
||||
invalidUserId: true,
|
||||
message: 'The provided user ID does not match this token. Please check your site user ID.',
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'shield-blocked') {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -187,4 +187,114 @@ describe('accounts verify-token shield detection', () => {
|
||||
});
|
||||
expect(undiciFetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to needsUserId diagnosis when verifyToken hangs', async () => {
|
||||
vi.useFakeTimers();
|
||||
verifyTokenMock.mockImplementationOnce(() => new Promise(() => {}));
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
text: async () => JSON.stringify({ success: false, message: 'missing New-Api-User' }),
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Needs User Id',
|
||||
url: 'https://needs-user-id.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const responsePromise = app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/verify-token',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
accessToken: 'slow-session-token',
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_100);
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
needsUserId: true,
|
||||
});
|
||||
expect(undiciFetchMock).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('short-circuits to needsUserId before heavy verification when probe already confirms it', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({
|
||||
tokenType: 'session',
|
||||
userInfo: { username: 'should-not-run' },
|
||||
balance: null,
|
||||
apiToken: null,
|
||||
});
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
text: async () => JSON.stringify({ success: false, message: 'missing New-Api-User' }),
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Needs User Id Fast',
|
||||
url: 'https://needs-user-id-fast.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/verify-token',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
accessToken: 'token-without-user-id',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
needsUserId: true,
|
||||
});
|
||||
expect(verifyTokenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns invalidUserId when the provided user id does not match the token', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
text: async () => JSON.stringify({ success: false, message: 'user id mismatch' }),
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Wrong User Id',
|
||||
url: 'https://wrong-user-id.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/verify-token',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
accessToken: 'token-with-wrong-user-id',
|
||||
platformUserId: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
invalidUserId: true,
|
||||
});
|
||||
expect(response.json()).not.toMatchObject({
|
||||
needsUserId: true,
|
||||
});
|
||||
expect(undiciFetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,4 +122,69 @@ describe('settings database runtime config api', () => {
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.message || '').toContain('SQLite');
|
||||
});
|
||||
|
||||
it('saves and loads ssl setting correctly', async () => {
|
||||
const putResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/database/runtime',
|
||||
payload: {
|
||||
dialect: 'mysql',
|
||||
connectionString: 'mysql://root:pass@tidb.example.com:4000/db',
|
||||
ssl: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(putResponse.statusCode).toBe(200);
|
||||
const putBody = putResponse.json() as {
|
||||
success?: boolean;
|
||||
saved?: { dialect?: string; ssl?: boolean };
|
||||
};
|
||||
expect(putBody.success).toBe(true);
|
||||
expect(putBody.saved?.ssl).toBe(true);
|
||||
|
||||
const dbSsl = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_ssl')).get();
|
||||
expect(dbSsl?.value).toBe(JSON.stringify(true));
|
||||
|
||||
const getResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/settings/database/runtime',
|
||||
});
|
||||
const getBody = getResponse.json() as {
|
||||
success?: boolean;
|
||||
saved?: { ssl?: boolean };
|
||||
};
|
||||
expect(getBody.success).toBe(true);
|
||||
expect(getBody.saved?.ssl).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults ssl to false when not provided', async () => {
|
||||
const putResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/database/runtime',
|
||||
payload: {
|
||||
dialect: 'postgres',
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/mydb',
|
||||
},
|
||||
});
|
||||
|
||||
expect(putResponse.statusCode).toBe(200);
|
||||
const putBody = putResponse.json() as {
|
||||
success?: boolean;
|
||||
saved?: { ssl?: boolean };
|
||||
};
|
||||
expect(putBody.success).toBe(true);
|
||||
expect(putBody.saved?.ssl).toBe(false);
|
||||
});
|
||||
|
||||
it('includes ssl in active runtime state', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/settings/database/runtime',
|
||||
});
|
||||
|
||||
const body = response.json() as {
|
||||
active?: { ssl?: boolean };
|
||||
};
|
||||
expect(typeof body.active?.ssl).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,16 +50,19 @@ interface DatabaseMigrationBody {
|
||||
dialect?: unknown;
|
||||
connectionString?: unknown;
|
||||
overwrite?: unknown;
|
||||
ssl?: unknown;
|
||||
}
|
||||
|
||||
type RuntimeDatabaseConfig = {
|
||||
dialect: MigrationDialect;
|
||||
connectionString: string;
|
||||
ssl: boolean;
|
||||
};
|
||||
|
||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
const DB_TYPE_SETTING_KEY = 'db_type';
|
||||
const DB_URL_SETTING_KEY = 'db_url';
|
||||
const DB_SSL_SETTING_KEY = 'db_ssl';
|
||||
|
||||
function isValidProxyToken(value: string): boolean {
|
||||
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
||||
@@ -316,6 +319,7 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
|
||||
const map = new Map(settingsRows.map((row) => [row.key, row.value]));
|
||||
const rawDialect = parseJsonValue(map.get(DB_TYPE_SETTING_KEY));
|
||||
const rawConnection = parseJsonValue(map.get(DB_URL_SETTING_KEY));
|
||||
const rawSsl = parseJsonValue(map.get(DB_SSL_SETTING_KEY));
|
||||
if (typeof rawDialect !== 'string' || typeof rawConnection !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -324,10 +328,12 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: rawDialect,
|
||||
connectionString: rawConnection,
|
||||
ssl: rawSsl,
|
||||
});
|
||||
return {
|
||||
dialect: normalized.dialect,
|
||||
connectionString: normalized.connectionString,
|
||||
ssl: normalized.ssl,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -337,19 +343,24 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
|
||||
function buildRuntimeDatabaseState(saved: RuntimeDatabaseConfig | null) {
|
||||
const activeDialect = runtimeDbDialect;
|
||||
const activeConnection = (config.dbUrl || '').trim();
|
||||
const activeSsl = config.dbSsl;
|
||||
const restartRequired = !!saved && (
|
||||
saved.dialect !== activeDialect || saved.connectionString.trim() !== activeConnection
|
||||
saved.dialect !== activeDialect ||
|
||||
saved.connectionString.trim() !== activeConnection ||
|
||||
saved.ssl !== activeSsl
|
||||
);
|
||||
|
||||
return {
|
||||
active: {
|
||||
dialect: activeDialect,
|
||||
connection: maskRuntimeConnection(activeDialect, activeConnection),
|
||||
ssl: activeSsl,
|
||||
},
|
||||
saved: saved
|
||||
? {
|
||||
dialect: saved.dialect,
|
||||
connection: maskRuntimeConnection(saved.dialect, saved.connectionString),
|
||||
ssl: saved.ssl,
|
||||
}
|
||||
: null,
|
||||
restartRequired,
|
||||
@@ -690,16 +701,18 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const normalized = normalizeMigrationInput(request.body || {});
|
||||
await upsertSetting(DB_TYPE_SETTING_KEY, normalized.dialect);
|
||||
await upsertSetting(DB_URL_SETTING_KEY, normalized.connectionString);
|
||||
await upsertSetting(DB_SSL_SETTING_KEY, normalized.ssl);
|
||||
|
||||
await appendSettingsEvent({
|
||||
type: 'status',
|
||||
title: '数据库运行配置已更新',
|
||||
message: `已保存运行数据库配置:${normalized.dialect}(重启后生效)`,
|
||||
message: `已保存运行数据库配置:${normalized.dialect}${normalized.ssl ? ' (SSL)' : ''}(重启后生效)`,
|
||||
});
|
||||
|
||||
const saved: RuntimeDatabaseConfig = {
|
||||
dialect: normalized.dialect,
|
||||
connectionString: normalized.connectionString,
|
||||
ssl: normalized.ssl,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,6 +20,21 @@ describe('accountHealthService', () => {
|
||||
expect(health.state).toBe('unhealthy');
|
||||
});
|
||||
|
||||
it('does not reuse expired session-token health for proxy-only accounts', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'expired',
|
||||
siteStatus: 'active',
|
||||
extraConfig: null,
|
||||
sessionCapable: false,
|
||||
});
|
||||
|
||||
expect(health).toMatchObject({
|
||||
state: 'unknown',
|
||||
source: 'none',
|
||||
});
|
||||
expect(health.reason).not.toContain('访问令牌已过期');
|
||||
});
|
||||
|
||||
it('returns stored runtime health from extra config when available', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'active',
|
||||
|
||||
@@ -86,6 +86,7 @@ export function buildRuntimeHealthForAccount(input: {
|
||||
accountStatus?: string | null;
|
||||
siteStatus?: string | null;
|
||||
extraConfig?: string | null;
|
||||
sessionCapable?: boolean;
|
||||
}): RuntimeHealthInfo {
|
||||
const accountStatus = (input.accountStatus || 'active').toLowerCase();
|
||||
const siteStatus = (input.siteStatus || 'active').toLowerCase();
|
||||
@@ -99,7 +100,7 @@ export function buildRuntimeHealthForAccount(input: {
|
||||
};
|
||||
}
|
||||
|
||||
if (accountStatus === 'expired') {
|
||||
if (accountStatus === 'expired' && input.sessionCapable !== false) {
|
||||
return {
|
||||
state: 'unhealthy',
|
||||
reason: '访问令牌已过期',
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('databaseMigrationService', () => {
|
||||
dialect: 'postgres',
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
|
||||
overwrite: true,
|
||||
ssl: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +25,7 @@ describe('databaseMigrationService', () => {
|
||||
|
||||
expect(normalized.dialect).toBe('mysql');
|
||||
expect(normalized.overwrite).toBe(true);
|
||||
expect(normalized.ssl).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts sqlite file migration target path', () => {
|
||||
@@ -37,6 +39,7 @@ describe('databaseMigrationService', () => {
|
||||
dialect: 'sqlite',
|
||||
connectionString: './data/target.db',
|
||||
overwrite: false,
|
||||
ssl: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,5 +61,40 @@ describe('databaseMigrationService', () => {
|
||||
const masked = maskConnectionString('postgres://admin:super-secret@db.example.com:5432/metapi');
|
||||
expect(masked).toBe('postgres://admin:***@db.example.com:5432/metapi');
|
||||
});
|
||||
|
||||
it('normalizes ssl boolean from input', () => {
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: 'mysql',
|
||||
connectionString: 'mysql://user:pass@tidb.example.com:4000/db',
|
||||
ssl: true,
|
||||
});
|
||||
expect(normalized.ssl).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults ssl to false when not provided', () => {
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: 'postgres',
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
|
||||
});
|
||||
expect(normalized.ssl).toBe(false);
|
||||
});
|
||||
|
||||
it('parses ssl from string values', () => {
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: 'mysql',
|
||||
connectionString: 'mysql://user:pass@host:3306/db',
|
||||
ssl: '1',
|
||||
});
|
||||
expect(normalized.ssl).toBe(true);
|
||||
});
|
||||
|
||||
it('parses ssl false from string "0"', () => {
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: 'mysql',
|
||||
connectionString: 'mysql://user:pass@host:3306/db',
|
||||
ssl: '0',
|
||||
});
|
||||
expect(normalized.ssl).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@ export interface DatabaseMigrationInput {
|
||||
dialect?: unknown;
|
||||
connectionString?: unknown;
|
||||
overwrite?: unknown;
|
||||
ssl?: unknown;
|
||||
}
|
||||
|
||||
export interface NormalizedDatabaseMigrationInput {
|
||||
dialect: MigrationDialect;
|
||||
connectionString: string;
|
||||
overwrite: boolean;
|
||||
ssl: boolean;
|
||||
}
|
||||
|
||||
type BackupSnapshot = {
|
||||
@@ -173,6 +175,7 @@ export function normalizeMigrationInput(input: DatabaseMigrationInput): Normaliz
|
||||
dialect,
|
||||
connectionString,
|
||||
overwrite: input.overwrite === undefined ? true : asBoolean(input.overwrite, true),
|
||||
ssl: asBoolean(input.ssl, false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,8 +227,12 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
|
||||
};
|
||||
}
|
||||
|
||||
async function createPostgresClient(connectionString: string): Promise<SqlClient> {
|
||||
const client = new pg.Client({ connectionString });
|
||||
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<SqlClient> {
|
||||
const clientOptions: pg.ClientConfig = { connectionString };
|
||||
if (ssl) {
|
||||
clientOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const client = new pg.Client(clientOptions);
|
||||
await client.connect();
|
||||
|
||||
return {
|
||||
@@ -244,8 +251,12 @@ async function createPostgresClient(connectionString: string): Promise<SqlClient
|
||||
};
|
||||
}
|
||||
|
||||
async function createMySqlClient(connectionString: string): Promise<SqlClient> {
|
||||
const connection = await mysql.createConnection(connectionString);
|
||||
async function createMySqlClient(connectionString: string, ssl: boolean): Promise<SqlClient> {
|
||||
const connectionOptions: mysql.ConnectionOptions = { uri: connectionString };
|
||||
if (ssl) {
|
||||
connectionOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const connection = await mysql.createConnection(connectionOptions);
|
||||
|
||||
return {
|
||||
dialect: 'mysql',
|
||||
@@ -291,8 +302,8 @@ async function createSqliteClient(connectionString: string): Promise<SqlClient>
|
||||
}
|
||||
|
||||
async function createClient(input: NormalizedDatabaseMigrationInput): Promise<SqlClient> {
|
||||
if (input.dialect === 'postgres') return createPostgresClient(input.connectionString);
|
||||
if (input.dialect === 'mysql') return createMySqlClient(input.connectionString);
|
||||
if (input.dialect === 'postgres') return createPostgresClient(input.connectionString, input.ssl);
|
||||
if (input.dialect === 'mysql') return createMySqlClient(input.connectionString, input.ssl);
|
||||
return createSqliteClient(input.connectionString);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -203,17 +203,17 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'),
|
||||
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string }) =>
|
||||
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
|
||||
request('/api/settings/database/runtime', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
testExternalDatabaseConnection: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean }) =>
|
||||
testExternalDatabaseConnection: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
|
||||
request('/api/settings/database/test-connection', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
migrateExternalDatabase: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean }) =>
|
||||
migrateExternalDatabase: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean; ssl?: boolean }) =>
|
||||
request('/api/settings/database/migrate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -352,6 +352,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'其他': 'other',
|
||||
'其他未归类的模型': 'Other uncategorized models',
|
||||
'启用': 'enable',
|
||||
'启用 SSL/TLS 加密连接': 'Enable SSL/TLS encrypted connection',
|
||||
'启用站点后再试': 'Try again after enabling the site',
|
||||
'签到': 'Sign in',
|
||||
'签到 Cron': 'Sign in Cron',
|
||||
|
||||
+22
-12
@@ -5,6 +5,11 @@ import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './helpers/accountVerifyFeedback.js';
|
||||
import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
@@ -80,6 +85,8 @@ export default function Accounts() {
|
||||
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
|
||||
[accounts, sortMode],
|
||||
);
|
||||
const verifyFailureHint = buildVerifyFailureHint(verifyResult);
|
||||
const addAccountPrereqHint = buildAddAccountPrereqHint(verifyResult);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -134,11 +141,11 @@ export default function Accounts() {
|
||||
toast.success(`Session 验证成功: ${result.userInfo?.username || '未知用户'}`);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message || 'Token 无效');
|
||||
toast.error(normalizeVerifyFailureMessage(result.message || 'Token 无效'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '验证失败');
|
||||
setVerifyResult({ success: false, message: e.message });
|
||||
toast.error(normalizeVerifyFailureMessage(e?.message));
|
||||
setVerifyResult({ success: false, message: e?.message });
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
@@ -223,9 +230,10 @@ export default function Accounts() {
|
||||
};
|
||||
|
||||
const resolveRuntimeHealth = (account: any) => {
|
||||
const capabilities = resolveAccountCapabilities(account);
|
||||
const fallbackState = account.status === 'disabled' || account.site?.status === 'disabled'
|
||||
? 'disabled'
|
||||
: (account.status === 'expired' ? 'unhealthy' : 'unknown');
|
||||
: (!capabilities.proxyOnly && account.status === 'expired' ? 'unhealthy' : 'unknown');
|
||||
const state = account.runtimeHealth?.state || fallbackState;
|
||||
const cfg = runtimeHealthMap[state] || runtimeHealthMap.unknown;
|
||||
const reason = account.runtimeHealth?.reason
|
||||
@@ -378,11 +386,11 @@ export default function Accounts() {
|
||||
} else if (result.success && result.tokenType !== 'session') {
|
||||
toast.error('当前是 API Key,不是 Session Token');
|
||||
} else {
|
||||
toast.error(result.message || 'Token 无效');
|
||||
toast.error(normalizeVerifyFailureMessage(result.message || 'Token 无效'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '验证失败');
|
||||
setRebindVerifyResult({ success: false, message: e.message });
|
||||
toast.error(normalizeVerifyFailureMessage(e?.message));
|
||||
setRebindVerifyResult({ success: false, message: e?.message });
|
||||
} finally {
|
||||
setRebindVerifying(false);
|
||||
}
|
||||
@@ -436,7 +444,7 @@ export default function Accounts() {
|
||||
setHighlightAccountId((current) => (current === accountId ? null : current));
|
||||
}, 2200);
|
||||
|
||||
if (openRebind && target.status === 'expired') {
|
||||
if (openRebind && target.status === 'expired' && !resolveAccountCapabilities(target).proxyOnly) {
|
||||
setShowAdd(false);
|
||||
if (!rebindTarget || rebindTarget.id !== target.id) {
|
||||
openRebindPanel(target);
|
||||
@@ -645,9 +653,11 @@ export default function Accounts() {
|
||||
{verifyResult && !verifyResult.success && !verifyResult.needsUserId && (
|
||||
<div className="alert alert-error animate-scale-in">
|
||||
<div className="alert-title">
|
||||
{verifyResult.message || 'Token 无效或已过期'}
|
||||
{normalizeVerifyFailureMessage(verifyResult.message) || 'Token 无效或已过期'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{verifyFailureHint || '请检查 Token 是否正确'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>请检查 Token 是否正确</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -663,7 +673,7 @@ export default function Accounts() {
|
||||
</div>
|
||||
{!verifyResult?.success && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
请先点击“验证 Token”,验证成功后才能添加账号
|
||||
{addAccountPrereqHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -949,7 +959,7 @@ export default function Accounts() {
|
||||
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'expired' && (
|
||||
{a.status === 'expired' && !capabilities.proxyOnly && (
|
||||
<button
|
||||
onClick={() => openRebindPanel(a)}
|
||||
className="btn btn-link btn-link-warning"
|
||||
|
||||
@@ -85,10 +85,12 @@ type RuntimeDatabaseState = {
|
||||
active: {
|
||||
dialect: DbDialect;
|
||||
connection: string;
|
||||
ssl: boolean;
|
||||
};
|
||||
saved: {
|
||||
dialect: DbDialect;
|
||||
connection: string;
|
||||
ssl: boolean;
|
||||
} | null;
|
||||
restartRequired: boolean;
|
||||
};
|
||||
@@ -200,6 +202,7 @@ export default function Settings() {
|
||||
database: 'postgres',
|
||||
});
|
||||
const [migrationOverwrite, setMigrationOverwrite] = useState(true);
|
||||
const [migrationSsl, setMigrationSsl] = useState(false);
|
||||
const [testingMigrationConnection, setTestingMigrationConnection] = useState(false);
|
||||
const [migratingDatabase, setMigratingDatabase] = useState(false);
|
||||
const [savingRuntimeDatabase, setSavingRuntimeDatabase] = useState(false);
|
||||
@@ -391,11 +394,13 @@ export default function Settings() {
|
||||
active: {
|
||||
dialect: (runtimeDatabaseInfo?.active?.dialect || 'sqlite') as DbDialect,
|
||||
connection: String(runtimeDatabaseInfo?.active?.connection || ''),
|
||||
ssl: !!runtimeDatabaseInfo?.active?.ssl,
|
||||
},
|
||||
saved: runtimeDatabaseInfo?.saved
|
||||
? {
|
||||
dialect: runtimeDatabaseInfo.saved.dialect as DbDialect,
|
||||
connection: String(runtimeDatabaseInfo.saved.connection || ''),
|
||||
ssl: !!runtimeDatabaseInfo.saved.ssl,
|
||||
}
|
||||
: null,
|
||||
restartRequired: !!runtimeDatabaseInfo?.restartRequired,
|
||||
@@ -701,7 +706,7 @@ export default function Settings() {
|
||||
const res = await api.testExternalDatabaseConnection({
|
||||
dialect: migrationDialect,
|
||||
connectionString: effectiveMigrationConnectionString,
|
||||
overwrite: migrationOverwrite,
|
||||
ssl: migrationSsl,
|
||||
});
|
||||
toast.success(`Connection success: ${res.connection || migrationDialect}`);
|
||||
} catch (err: any) {
|
||||
@@ -734,6 +739,7 @@ export default function Settings() {
|
||||
dialect: migrationDialect,
|
||||
connectionString: effectiveMigrationConnectionString,
|
||||
overwrite: migrationOverwrite,
|
||||
ssl: migrationSsl,
|
||||
});
|
||||
setMigrationSummary(res);
|
||||
toast.success(res?.message || 'Database migration completed');
|
||||
@@ -761,16 +767,19 @@ export default function Settings() {
|
||||
const res = await api.updateRuntimeDatabaseConfig({
|
||||
dialect: migrationDialect,
|
||||
connectionString: effectiveMigrationConnectionString,
|
||||
ssl: migrationSsl,
|
||||
});
|
||||
setRuntimeDatabaseState({
|
||||
active: {
|
||||
dialect: (res?.active?.dialect || 'sqlite') as DbDialect,
|
||||
connection: String(res?.active?.connection || ''),
|
||||
ssl: !!res?.active?.ssl,
|
||||
},
|
||||
saved: res?.saved
|
||||
? {
|
||||
dialect: res.saved.dialect as DbDialect,
|
||||
connection: String(res.saved.connection || ''),
|
||||
ssl: !!res.saved.ssl,
|
||||
}
|
||||
: null,
|
||||
restartRequired: !!res?.restartRequired,
|
||||
@@ -1164,6 +1173,18 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{migrationDialect !== 'sqlite' && (
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={migrationSsl}
|
||||
onChange={(e) => setMigrationSsl(e.target.checked)}
|
||||
style={{ width: 14, height: 14, accentColor: 'var(--color-primary)' }}
|
||||
/>
|
||||
启用 SSL/TLS 加密连接
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1202,11 +1223,11 @@ export default function Settings() {
|
||||
|
||||
{runtimeDatabaseState && (
|
||||
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8, marginBottom: migrationSummary ? 12 : 0 }}>
|
||||
<div>当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '(empty)' })</div>
|
||||
<div>当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '(empty)' }){runtimeDatabaseState.active.ssl && ' [SSL]'}</div>
|
||||
<div>
|
||||
已保存待生效:
|
||||
{runtimeDatabaseState.saved
|
||||
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})`
|
||||
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})${runtimeDatabaseState.saved.ssl ? ' [SSL]' : ''}`
|
||||
: ' 未保存'}
|
||||
</div>
|
||||
{runtimeDatabaseState.restartRequired && (
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts proxy-only expired state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not show session-expired health copy or rebind action for proxy-only accounts', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
username: '',
|
||||
balance: 0,
|
||||
balanceUsed: 0,
|
||||
todayReward: 0,
|
||||
todaySpend: 0,
|
||||
accessToken: 'api-key-only-token',
|
||||
status: 'expired',
|
||||
checkinEnabled: false,
|
||||
capabilities: {
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: true,
|
||||
},
|
||||
siteId: 10,
|
||||
site: { id: 10, name: '小呆api', status: 'active', url: 'https://example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getSites.mockResolvedValue([{ id: 10, name: '小呆api', platform: 'new-api', status: 'active' }]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('仅代理');
|
||||
expect(rendered).not.toContain('访问令牌已过期');
|
||||
|
||||
const actionTexts = root.root.findAll((node) => node.type === 'button').map((node) => collectText(node));
|
||||
expect(actionTexts).not.toContain('重新绑定');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import Accounts from './Accounts.js';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './helpers/accountVerifyFeedback.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
verifyToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts verify feedback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows connectivity guidance instead of blaming the token when verification request fails', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.verifyToken.mockRejectedValueOnce(new Error('Failed to fetch'));
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const addButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn btn-primary')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
addButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const selects = root.root.findAllByType(ModernSelect);
|
||||
expect(selects.length).toBeGreaterThan(1);
|
||||
|
||||
await act(async () => {
|
||||
selects[1]!.props.onChange('10');
|
||||
});
|
||||
|
||||
const textareas = root.root.findAll((node) => node.type === 'textarea');
|
||||
expect(textareas.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
textareas[0]!.props.onChange({ target: { value: 'demo-token' } });
|
||||
});
|
||||
|
||||
const verifyButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn btn-ghost')
|
||||
&& collectText(node).includes('Token')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await verifyButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain(normalizeVerifyFailureMessage('Failed to fetch'));
|
||||
expect(rendered).toContain(buildVerifyFailureHint({ success: false, message: 'Failed to fetch' })!);
|
||||
expect(rendered).toContain(buildAddAccountPrereqHint({ success: false, message: 'Failed to fetch' }));
|
||||
expect(rendered).not.toContain('请检查 Token 是否正确');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Dashboard from './Dashboard.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getDashboard: vi.fn(),
|
||||
getSiteDistribution: vi.fn(),
|
||||
getSiteTrend: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Dashboard site speed buttons', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getDashboard.mockResolvedValue({
|
||||
totalBalance: 0,
|
||||
totalUsed: 0,
|
||||
todaySpend: 0,
|
||||
todayReward: 0,
|
||||
activeAccounts: 0,
|
||||
totalAccounts: 0,
|
||||
todayCheckin: { success: 0, total: 0 },
|
||||
proxy24h: { success: 0, total: 0, totalTokens: 0 },
|
||||
modelAnalysis: null,
|
||||
});
|
||||
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
|
||||
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 1, name: 'Demo Site', url: 'https://example.com', status: 'active' },
|
||||
]);
|
||||
|
||||
globalThis.document = {
|
||||
visibilityState: 'visible',
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getElementById: vi.fn(() => null),
|
||||
} as unknown as Document;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('updates site speed status without imperative document lookups', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<Dashboard />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const speedButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('娴嬮€?')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await speedButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(globalThis.document.getElementById).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './accountVerifyFeedback.js';
|
||||
|
||||
describe('account verify feedback', () => {
|
||||
it('treats fetch failures as connectivity issues instead of token mistakes', () => {
|
||||
expect(normalizeVerifyFailureMessage('Failed to fetch')).not.toBe('Failed to fetch');
|
||||
expect(buildVerifyFailureHint({ success: false, message: 'Failed to fetch' })).not.toBeNull();
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: 'Failed to fetch' })).toMatch(/metapi|站点|代理/i);
|
||||
});
|
||||
|
||||
it('treats timeout failures as reachability issues instead of token mistakes', () => {
|
||||
const timeoutMessage = 'Token verification timed out (10s)';
|
||||
expect(normalizeVerifyFailureMessage(timeoutMessage)).toBe(timeoutMessage);
|
||||
expect(buildVerifyFailureHint({ success: false, message: timeoutMessage })).toMatch(/Token|站点|代理|超时/i);
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: timeoutMessage })).toMatch(/站点|代理|超时/i);
|
||||
});
|
||||
|
||||
it('keeps token guidance for actual credential failures', () => {
|
||||
const invalidHint = buildVerifyFailureHint({ success: false, message: 'Token invalid' });
|
||||
expect(invalidHint).toMatch(/Token/i);
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: 'Token invalid' })).toMatch(/Token/i);
|
||||
});
|
||||
|
||||
it('normalizes invalid user-id mismatch messages for display', () => {
|
||||
expect(normalizeVerifyFailureMessage('The provided user ID does not match this token. Please check your site user ID.'))
|
||||
.toMatch(/ID/i);
|
||||
expect(normalizeVerifyFailureMessage('The provided user ID does not match this token. Please check your site user ID.'))
|
||||
.not.toContain('does not match this token');
|
||||
});
|
||||
|
||||
it('prioritizes user-id guidance when the site requires it', () => {
|
||||
expect(buildVerifyFailureHint({ success: false, needsUserId: true, message: 'missing New-Api-User' })).toBeNull();
|
||||
expect(buildAddAccountPrereqHint({ success: false, needsUserId: true, message: 'missing New-Api-User' })).toMatch(/ID/i);
|
||||
});
|
||||
|
||||
it('treats invalid user-id as a mismatch instead of a missing-value hint', () => {
|
||||
const invalidUserIdHint = buildVerifyFailureHint({ success: false, invalidUserId: true, message: 'user id mismatch' });
|
||||
const genericTokenHint = buildVerifyFailureHint({ success: false, message: 'Token invalid' });
|
||||
const invalidUserIdPrereq = buildAddAccountPrereqHint({ success: false, invalidUserId: true, message: 'user id mismatch' });
|
||||
const genericPrereq = buildAddAccountPrereqHint({ success: false, message: 'Token invalid' });
|
||||
|
||||
expect(invalidUserIdHint).toMatch(/ID/i);
|
||||
expect(invalidUserIdHint).not.toBe(genericTokenHint);
|
||||
expect(invalidUserIdPrereq).toMatch(/ID/i);
|
||||
expect(invalidUserIdPrereq).not.toBe(genericPrereq);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
type VerifyResultLike = {
|
||||
success?: boolean;
|
||||
needsUserId?: boolean;
|
||||
invalidUserId?: boolean;
|
||||
message?: string | null;
|
||||
} | null | undefined;
|
||||
|
||||
function normalizeMessageText(message: unknown): string {
|
||||
return typeof message === 'string' ? message.trim() : '';
|
||||
}
|
||||
|
||||
export function isNetworkFailureMessage(message: unknown): boolean {
|
||||
const lowered = normalizeMessageText(message).toLowerCase();
|
||||
return lowered.includes('failed to fetch')
|
||||
|| lowered.includes('fetch failed')
|
||||
|| lowered.includes('networkerror')
|
||||
|| lowered.includes('load failed');
|
||||
}
|
||||
|
||||
export function isTimeoutFailureMessage(message: unknown): boolean {
|
||||
const lowered = normalizeMessageText(message).toLowerCase();
|
||||
return lowered.includes('timed out')
|
||||
|| lowered.includes('timeout')
|
||||
|| lowered.includes('请求超时');
|
||||
}
|
||||
|
||||
export function normalizeVerifyFailureMessage(message: unknown): string {
|
||||
const text = normalizeMessageText(message);
|
||||
if (!text) return '验证失败';
|
||||
const lowered = text.toLowerCase();
|
||||
if (isNetworkFailureMessage(text)) {
|
||||
return '无法连接到 metapi 服务端,请检查服务状态或网络连接';
|
||||
}
|
||||
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
|
||||
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function buildVerifyFailureHint(result: VerifyResultLike): string | null {
|
||||
if (!result || result.success || result.needsUserId) return null;
|
||||
if (result.invalidUserId) {
|
||||
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
|
||||
}
|
||||
if (isNetworkFailureMessage(result.message)) {
|
||||
return '这不是 Token 错误判断。请检查 metapi 服务是否在线,以及目标站点或代理是否可达。';
|
||||
}
|
||||
if (isTimeoutFailureMessage(result.message)) {
|
||||
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
|
||||
}
|
||||
return '请检查 Token 是否正确';
|
||||
}
|
||||
|
||||
export function buildAddAccountPrereqHint(result: VerifyResultLike): string {
|
||||
if (!result) {
|
||||
return '请先点击“验证 Token”,验证成功后才能添加账号。';
|
||||
}
|
||||
if (result.success) return '';
|
||||
if (result.needsUserId) {
|
||||
return '请先补充用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||
}
|
||||
if (result.invalidUserId) {
|
||||
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||
}
|
||||
if (isNetworkFailureMessage(result.message)) {
|
||||
return '验证请求未成功完成,请先检查 metapi 服务、站点网络或代理配置。';
|
||||
}
|
||||
if (isTimeoutFailureMessage(result.message)) {
|
||||
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
||||
}
|
||||
return '请先点击“验证 Token”,验证成功后才能添加账号。';
|
||||
}
|
||||
Reference in New Issue
Block a user