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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user