1
0
forked from boos/BoosAPI

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
+2
View File
@@ -31,6 +31,7 @@ import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationServ
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
import { reloadBackupWebdavScheduler } from './services/backupService.js';
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
import { existsSync } from 'fs';
@@ -400,6 +401,7 @@ if (existsSync(webDir)) {
// Start scheduler
await startScheduler();
await reloadBackupWebdavScheduler();
startSiteAnnouncementPolling();
try {
await startOAuthLoopbackCallbackServers();
@@ -0,0 +1,114 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('settings backup webdav api', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-backup-webdav-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const settingsRoutesModule = await import('./settings.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
});
beforeEach(async () => {
await db.delete(schema.settings).run();
await db.delete(schema.events).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('saves and returns masked webdav config', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/backup/webdav',
payload: {
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'accounts',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success?: boolean;
config?: {
fileUrl?: string;
username?: string;
exportType?: string;
autoSyncEnabled?: boolean;
autoSyncCron?: string;
hasPassword?: boolean;
passwordMasked?: string;
};
};
expect(body.success).toBe(true);
expect(body.config).toMatchObject({
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
exportType: 'accounts',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
hasPassword: true,
});
expect(body.config?.passwordMasked).toBeTruthy();
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'backup_webdav_config_v1')).get();
expect(saved?.value).toContain('"fileUrl":"https://dav.example.com/backups/metapi.json"');
expect(saved?.value).toContain('"password":"secret-pass"');
});
it('exports current backup to webdav through settings route', 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.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 * * * *',
}),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/settings/backup/webdav/export',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { success?: boolean; fileUrl?: string };
expect(body.success).toBe(true);
expect(body.fileUrl).toBe('https://dav.example.com/backups/metapi.json');
expect(fetchSpy).toHaveBeenCalledTimes(1);
fetchSpy.mockRestore();
});
});
+83 -1
View File
@@ -7,7 +7,16 @@ import { upsertSetting } from '../../db/upsertSetting.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { sendNotification } from '../../services/notifyService.js';
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
import {
exportBackup,
exportBackupToWebdav,
getBackupWebdavConfig,
importBackup,
importBackupFromWebdav,
reloadBackupWebdavScheduler,
saveBackupWebdavConfig,
type BackupExportType,
} from '../../services/backupService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import {
maskConnectionString,
@@ -75,6 +84,17 @@ interface SystemProxyTestBody {
proxyUrl?: unknown;
}
interface BackupWebdavConfigBody {
enabled?: unknown;
fileUrl?: unknown;
username?: unknown;
password?: unknown;
clearPassword?: unknown;
exportType?: unknown;
autoSyncEnabled?: unknown;
autoSyncCron?: unknown;
}
type RuntimeDatabaseConfig = {
dialect: MigrationDialect;
connectionString: string;
@@ -1267,6 +1287,9 @@ export async function settingsRoutes(app: FastifyInstance) {
for (const item of result.appliedSettings) {
applyImportedSettingToRuntime(item.key, item.value);
}
if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) {
await reloadBackupWebdavScheduler();
}
return {
success: true,
message: '导入完成',
@@ -1280,6 +1303,65 @@ export async function settingsRoutes(app: FastifyInstance) {
}
});
app.get('/api/settings/backup/webdav', async () => {
return getBackupWebdavConfig();
});
app.put<{ Body: BackupWebdavConfigBody }>('/api/settings/backup/webdav', async (request, reply) => {
try {
const body = request.body || {};
const result = await saveBackupWebdavConfig({
enabled: body.enabled === undefined ? undefined : body.enabled === true,
fileUrl: body.fileUrl === undefined ? undefined : String(body.fileUrl || ''),
username: body.username === undefined ? undefined : String(body.username || ''),
password: body.password === undefined ? undefined : String(body.password),
clearPassword: body.clearPassword === true,
exportType: body.exportType === undefined ? undefined : String(body.exportType || '') as BackupExportType,
autoSyncEnabled: body.autoSyncEnabled === undefined ? undefined : body.autoSyncEnabled === true,
autoSyncCron: body.autoSyncCron === undefined ? undefined : String(body.autoSyncCron || ''),
});
return result;
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'WebDAV 配置保存失败',
});
}
});
app.post<{ Body: { type?: string } }>('/api/settings/backup/webdav/export', async (request, reply) => {
try {
const rawType = typeof request.body?.type === 'string' ? request.body.type.trim().toLowerCase() : '';
const type: BackupExportType | undefined = rawType === 'all' || rawType === 'accounts' || rawType === 'preferences'
? rawType
: undefined;
return await exportBackupToWebdav(type);
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'WebDAV 导出失败',
});
}
});
app.post('/api/settings/backup/webdav/import', async (_, reply) => {
try {
const result = await importBackupFromWebdav();
for (const item of result.appliedSettings) {
applyImportedSettingToRuntime(item.key, item.value);
}
if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) {
await reloadBackupWebdavScheduler();
}
return result;
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'WebDAV 导入失败',
});
}
});
app.post('/api/settings/notify/test', async (_, reply) => {
try {
const result = await sendNotification(
+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();
}