Merge pull request #220 from cita-777/codex/webdav-backup-armv7l

Add WebDAV backup sync and armv7 Docker builds
This commit is contained in:
Cita
2026-03-21 01:24:51 +08:00
committed by GitHub
14 changed files with 1436 additions and 28 deletions
+250
View File
@@ -596,4 +596,254 @@ describe('backupService', () => {
expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com');
expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id');
});
it('exports configured backup payload to webdav and records sync state', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValue(new Response(null, { status: 201 }));
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi-preferences.json',
username: 'alice',
password: 'secret-pass',
exportType: 'preferences',
autoSyncEnabled: false,
autoSyncCron: '0 * * * *',
}),
}).run();
await db.insert(schema.settings).values({
key: 'ui_locale',
value: JSON.stringify('zh-CN'),
}).run();
const result = await (backupService as any).exportBackupToWebdav();
expect(result).toMatchObject({
success: true,
fileUrl: 'https://dav.example.com/backups/metapi-preferences.json',
exportType: 'preferences',
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [targetUrl, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(targetUrl).toBe('https://dav.example.com/backups/metapi-preferences.json');
expect(init.method).toBe('PUT');
expect(init.headers).toMatchObject({
Authorization: `Basic ${Buffer.from('alice:secret-pass').toString('base64')}`,
'Content-Type': 'application/json',
});
const payload = JSON.parse(String(init.body));
expect(payload.type).toBe('preferences');
expect(payload.preferences.settings).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'ui_locale', value: 'zh-CN' }),
]),
);
const syncState = await db.select().from(schema.settings).where(eq(schema.settings.key, 'backup_webdav_state_v1')).get();
expect(syncState?.value).toContain('"lastSyncAt"');
expect(syncState?.value).toContain('"lastError":null');
fetchSpy.mockRestore();
});
it('imports backup payload from webdav into local data', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const remotePayload = {
version: '2.0',
timestamp: Date.now(),
accounts: {
sites: [
{
id: 1,
name: 'remote-site',
url: 'https://remote.example.com',
platform: 'new-api',
externalCheckinUrl: null,
proxyUrl: null,
useSystemProxy: false,
customHeaders: null,
status: 'active',
isPinned: false,
sortOrder: 0,
globalWeight: 1,
apiKey: null,
createdAt: '2026-03-20T00:00:00.000Z',
updatedAt: '2026-03-20T00:00:00.000Z',
},
],
accounts: [
{
id: 1,
siteId: 1,
username: 'remote-user',
accessToken: 'remote-session',
apiToken: null,
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
balance: 0,
balanceUsed: 0,
quota: 0,
unitCost: null,
valueScore: 0,
status: 'active',
isPinned: false,
sortOrder: 0,
checkinEnabled: false,
lastCheckinAt: null,
lastBalanceRefresh: null,
extraConfig: null,
createdAt: '2026-03-20T00:00:00.000Z',
updatedAt: '2026-03-20T00:00:00.000Z',
},
],
accountTokens: [],
tokenRoutes: [],
routeChannels: [],
routeGroupSources: [],
},
};
fetchSpy.mockResolvedValue(new Response(JSON.stringify(remotePayload), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi-all.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 * * * *',
}),
}).run();
const result = await (backupService as any).importBackupFromWebdav();
expect(result.success).toBe(true);
expect(result.sections.accounts).toBe(true);
const sites = await db.select().from(schema.sites).all();
const accounts = await db.select().from(schema.accounts).all();
expect(sites).toHaveLength(1);
expect(accounts).toHaveLength(1);
expect(accounts[0].username).toBe('remote-user');
expect(fetchSpy).toHaveBeenCalledWith(
'https://dav.example.com/backups/metapi-all.json',
expect.objectContaining({
method: 'GET',
}),
);
fetchSpy.mockRestore();
});
it('times out stalled webdav export requests', async () => {
vi.useFakeTimers();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation((_, init) => {
const signal = init?.signal as AbortSignal | undefined;
if (!signal) {
return Promise.reject(new Error('missing abort signal')) as Promise<Response>;
}
return new Promise<Response>((_, reject) => {
signal.addEventListener('abort', () => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
});
});
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
}),
}).run();
const exportAssertion = expect(backupService.exportBackupToWebdav()).rejects.toThrow('WebDAV 请求超时');
await vi.advanceTimersByTimeAsync(16_000);
await exportAssertion;
} finally {
fetchSpy.mockRestore();
vi.useRealTimers();
}
});
it('times out stalled webdav import requests', async () => {
vi.useFakeTimers();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation((_, init) => {
const signal = init?.signal as AbortSignal | undefined;
if (!signal) {
return Promise.reject(new Error('missing abort signal')) as Promise<Response>;
}
return new Promise<Response>((_, reject) => {
signal.addEventListener('abort', () => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
});
});
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
}),
}).run();
const importAssertion = expect(backupService.importBackupFromWebdav()).rejects.toThrow('WebDAV 请求超时');
await vi.advanceTimersByTimeAsync(16_000);
await importAssertion;
} finally {
fetchSpy.mockRestore();
vi.useRealTimers();
}
});
it('does not schedule malformed imported webdav config', async () => {
const cronModule = await import('node-cron');
const scheduleSpy = vi.spyOn(cronModule.default, 'schedule');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'not-a-valid-url',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
}),
}).run();
await expect(backupService.reloadBackupWebdavScheduler()).resolves.toBeUndefined();
expect(scheduleSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid config'));
} finally {
backupService.__resetBackupWebdavSchedulerForTests();
scheduleSpy.mockRestore();
warnSpy.mockRestore();
}
});
});
+323 -1
View File
@@ -1,4 +1,4 @@
import { asc } from 'drizzle-orm';
import { asc, eq } from 'drizzle-orm';
import cron from 'node-cron';
import { db, schema } from '../db/index.js';
import { upsertSetting } from '../db/upsertSetting.js';
@@ -9,6 +9,32 @@ const BACKUP_VERSION = '2.0';
export type BackupExportType = 'all' | 'accounts' | 'preferences';
export interface BackupWebdavConfig {
enabled: boolean;
fileUrl: string;
username: string;
password: string;
exportType: BackupExportType;
autoSyncEnabled: boolean;
autoSyncCron: string;
}
export interface BackupWebdavConfigView {
enabled: boolean;
fileUrl: string;
username: string;
exportType: BackupExportType;
autoSyncEnabled: boolean;
autoSyncCron: string;
hasPassword: boolean;
passwordMasked: string;
}
export interface BackupWebdavState {
lastSyncAt: string | null;
lastError: string | null;
}
type SiteRow = typeof schema.sites.$inferSelect;
type AccountRow = typeof schema.accounts.$inferSelect;
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
@@ -76,6 +102,11 @@ const EXCLUDED_SETTING_KEYS = new Set<string>([
// Keep current admin login credential unchanged to avoid accidental lock-out.
'auth_token',
]);
const BACKUP_WEBDAV_CONFIG_SETTING_KEY = 'backup_webdav_config_v1';
const BACKUP_WEBDAV_STATE_SETTING_KEY = 'backup_webdav_state_v1';
const BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON = '0 */6 * * *';
const BACKUP_WEBDAV_FETCH_TIMEOUT_MS = 15_000;
let backupWebdavTask: cron.ScheduledTask | null = null;
const DIRECT_API_PLATFORMS = new Set([
'openai',
@@ -722,6 +753,131 @@ function stringifySettingValue(value: unknown): string {
return JSON.stringify(value);
}
function isValidBackupExportType(value: unknown): value is BackupExportType {
return value === 'all' || value === 'accounts' || value === 'preferences';
}
function maskSecret(value: string): string {
if (!value) return '';
if (value.length <= 4) return '****';
return `${value.slice(0, 2)}****${value.slice(-2)}`;
}
function isValidHttpUrl(raw: string): boolean {
if (!raw.trim()) return false;
try {
const parsed = new URL(raw);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function normalizeBackupWebdavConfig(raw: unknown): BackupWebdavConfig {
const source = isRecord(raw) ? raw : {};
const exportType = isValidBackupExportType(source.exportType) ? source.exportType : 'all';
const autoSyncCron = typeof source.autoSyncCron === 'string' && cron.validate(source.autoSyncCron)
? source.autoSyncCron
: BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON;
return {
enabled: source.enabled === true,
fileUrl: asString(source.fileUrl),
username: asString(source.username),
password: typeof source.password === 'string' ? source.password : '',
exportType,
autoSyncEnabled: source.autoSyncEnabled === true,
autoSyncCron,
};
}
function normalizeBackupWebdavState(raw: unknown): BackupWebdavState {
const source = isRecord(raw) ? raw : {};
return {
lastSyncAt: typeof source.lastSyncAt === 'string' && source.lastSyncAt.trim() ? source.lastSyncAt : null,
lastError: typeof source.lastError === 'string' && source.lastError.trim() ? source.lastError : null,
};
}
function toBackupWebdavConfigView(config: BackupWebdavConfig): BackupWebdavConfigView {
return {
enabled: config.enabled,
fileUrl: config.fileUrl,
username: config.username,
exportType: config.exportType,
autoSyncEnabled: config.autoSyncEnabled,
autoSyncCron: config.autoSyncCron,
hasPassword: config.password.length > 0,
passwordMasked: maskSecret(config.password),
};
}
async function readSettingValue(key: string): Promise<unknown> {
const row = await db.select({ value: schema.settings.value }).from(schema.settings).where(eq(schema.settings.key, key)).get();
return parseSettingValue(row?.value ?? null);
}
async function loadBackupWebdavConfig(): Promise<BackupWebdavConfig> {
return normalizeBackupWebdavConfig(await readSettingValue(BACKUP_WEBDAV_CONFIG_SETTING_KEY));
}
async function loadBackupWebdavState(): Promise<BackupWebdavState> {
return normalizeBackupWebdavState(await readSettingValue(BACKUP_WEBDAV_STATE_SETTING_KEY));
}
async function writeBackupWebdavState(next: BackupWebdavState) {
await upsertSetting(BACKUP_WEBDAV_STATE_SETTING_KEY, next);
}
function resolveBackupWebdavAuthHeader(config: BackupWebdavConfig): string | null {
if (!config.username && !config.password) return null;
return `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
}
function validateBackupWebdavConfig(config: BackupWebdavConfig) {
if (config.enabled && !isValidHttpUrl(config.fileUrl)) {
throw new Error('WebDAV 文件地址无效,请填写 http/https 文件 URL');
}
if (!isValidBackupExportType(config.exportType)) {
throw new Error('WebDAV 导出类型无效,仅支持 all/accounts/preferences');
}
if (!cron.validate(config.autoSyncCron)) {
throw new Error('WebDAV 自动同步 Cron 表达式无效');
}
if (config.autoSyncEnabled && !config.enabled) {
throw new Error('启用自动同步前请先启用 WebDAV 备份');
}
}
async function fetchBackupWebdav(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
controller.abort();
}, BACKUP_WEBDAV_FETCH_TIMEOUT_MS);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} catch (error: any) {
if (error?.name === 'AbortError') {
throw new Error(`WebDAV 请求超时(${Math.max(1, Math.round(BACKUP_WEBDAV_FETCH_TIMEOUT_MS / 1000))}s)`);
}
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
}
}
function stopBackupWebdavScheduler() {
backupWebdavTask?.stop();
backupWebdavTask = null;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
@@ -1103,3 +1259,169 @@ export async function importBackup(data: RawBackupData): Promise<BackupImportRes
warnings: importMetadata.warnings,
};
}
export async function getBackupWebdavConfig() {
const [config, state] = await Promise.all([
loadBackupWebdavConfig(),
loadBackupWebdavState(),
]);
return {
success: true,
config: toBackupWebdavConfigView(config),
state,
};
}
export async function saveBackupWebdavConfig(input: Partial<BackupWebdavConfig> & { password?: string; clearPassword?: boolean }) {
const existing = await loadBackupWebdavConfig();
const next: BackupWebdavConfig = {
enabled: input.enabled !== undefined ? input.enabled === true : existing.enabled,
fileUrl: input.fileUrl !== undefined ? asString(input.fileUrl) : existing.fileUrl,
username: input.username !== undefined ? asString(input.username) : existing.username,
password: input.clearPassword
? ''
: (input.password !== undefined
? String(input.password)
: existing.password),
exportType: isValidBackupExportType(input.exportType) ? input.exportType : existing.exportType,
autoSyncEnabled: input.autoSyncEnabled !== undefined ? input.autoSyncEnabled === true : existing.autoSyncEnabled,
autoSyncCron: typeof input.autoSyncCron === 'string' && input.autoSyncCron.trim()
? input.autoSyncCron.trim()
: existing.autoSyncCron,
};
if (!next.enabled) {
next.autoSyncEnabled = false;
}
validateBackupWebdavConfig(next);
await upsertSetting(BACKUP_WEBDAV_CONFIG_SETTING_KEY, next);
await reloadBackupWebdavScheduler();
return getBackupWebdavConfig();
}
export async function exportBackupToWebdav(type?: BackupExportType) {
const config = await loadBackupWebdavConfig();
validateBackupWebdavConfig(config);
if (!config.enabled) {
throw new Error('WebDAV 备份未启用');
}
if (!config.fileUrl) {
throw new Error('WebDAV 文件地址不能为空');
}
const exportType = type && isValidBackupExportType(type) ? type : config.exportType;
const payload = await exportBackup(exportType);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const authHeader = resolveBackupWebdavAuthHeader(config);
if (authHeader) headers.Authorization = authHeader;
try {
const response = await fetchBackupWebdav(config.fileUrl, {
method: 'PUT',
headers,
body: JSON.stringify(payload, null, 2),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`WebDAV 导出失败:HTTP ${response.status}${text ? ` ${text.slice(0, 120)}` : ''}`);
}
const syncedAt = new Date().toISOString();
await writeBackupWebdavState({
lastSyncAt: syncedAt,
lastError: null,
});
return {
success: true,
fileUrl: config.fileUrl,
exportType,
syncedAt,
lastSyncAt: syncedAt,
lastError: null,
};
} catch (error: any) {
const previousState = await loadBackupWebdavState();
await writeBackupWebdavState({
lastSyncAt: previousState.lastSyncAt,
lastError: error?.message || 'WebDAV 导出失败',
});
throw error;
}
}
export async function importBackupFromWebdav() {
const config = await loadBackupWebdavConfig();
validateBackupWebdavConfig(config);
if (!config.enabled) {
throw new Error('WebDAV 备份未启用');
}
if (!config.fileUrl) {
throw new Error('WebDAV 文件地址不能为空');
}
const headers: Record<string, string> = {};
const authHeader = resolveBackupWebdavAuthHeader(config);
if (authHeader) headers.Authorization = authHeader;
try {
const response = await fetchBackupWebdav(config.fileUrl, {
method: 'GET',
headers,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`WebDAV 导入失败:HTTP ${response.status}${text ? ` ${text.slice(0, 120)}` : ''}`);
}
const raw = await response.text();
const parsed = JSON.parse(raw) as RawBackupData;
const result = await importBackup(parsed);
const syncedAt = new Date().toISOString();
await writeBackupWebdavState({
lastSyncAt: syncedAt,
lastError: null,
});
return {
success: true,
fileUrl: config.fileUrl,
syncedAt,
lastSyncAt: syncedAt,
lastError: null,
...result,
};
} catch (error: any) {
const previousState = await loadBackupWebdavState();
await writeBackupWebdavState({
lastSyncAt: previousState.lastSyncAt,
lastError: error?.message || 'WebDAV 导入失败',
});
throw error;
}
}
export async function reloadBackupWebdavScheduler() {
stopBackupWebdavScheduler();
const config = await loadBackupWebdavConfig();
if (!config.enabled || !config.autoSyncEnabled) return;
try {
validateBackupWebdavConfig(config);
} catch (error: any) {
console.warn(`[backup/webdav] invalid config: ${error?.message || 'unknown error'}`);
return;
}
backupWebdavTask = cron.schedule(config.autoSyncCron, () => {
void exportBackupToWebdav(config.exportType).catch((error) => {
console.warn(`[backup/webdav] auto sync failed: ${(error as Error)?.message || 'unknown error'}`);
});
});
}
export function __resetBackupWebdavSchedulerForTests() {
stopBackupWebdavScheduler();
}