Add WebDAV backup sync and armv7 Docker builds

This commit is contained in:
ciat-777
2026-03-21 00:06:16 +08:00
parent 94b6cac871
commit bc52b6b5fd
13 changed files with 1074 additions and 24 deletions
@@ -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(