diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61065ea..8569640 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -314,6 +314,9 @@ jobs: - arch: arm64 platform: linux/arm64 runner: ubuntu-24.04-arm + - arch: armv7 + platform: linux/arm/v7 + runner: ubuntu-latest steps: - name: Checkout @@ -329,6 +332,10 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 + - name: Set up QEMU + if: matrix.arch == 'armv7' + uses: docker/setup-qemu-action@v4 + - name: Log in to Docker Hub uses: docker/login-action@v4 with: @@ -419,5 +426,6 @@ jobs: docker buildx imagetools create \ --tag "$tag" \ "${tag}-amd64" \ - "${tag}-arm64" + "${tag}-arm64" \ + "${tag}-armv7" done <<< "${{ steps.meta.outputs.tags }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b95a5d6..0872070 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -284,14 +284,17 @@ jobs: if: startsWith(github.ref, 'refs/tags/') strategy: fail-fast: false - matrix: - include: - - arch: amd64 - platform: linux/amd64 - runner: ubuntu-latest - - arch: arm64 - platform: linux/arm64 - runner: ubuntu-24.04-arm + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + - arch: armv7 + platform: linux/arm/v7 + runner: ubuntu-latest steps: - name: Checkout @@ -304,8 +307,12 @@ jobs: exit 1 fi - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Set up QEMU + if: matrix.arch == 'armv7' + uses: docker/setup-qemu-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v4 @@ -410,8 +417,9 @@ jobs: set -euo pipefail while IFS= read -r tag; do [ -n "$tag" ] || continue - docker buildx imagetools create \ - --tag "$tag" \ - "${tag}-amd64" \ - "${tag}-arm64" - done <<< "${{ steps.meta.outputs.tags }}" + docker buildx imagetools create \ + --tag "$tag" \ + "${tag}-amd64" \ + "${tag}-arm64" \ + "${tag}-armv7" + done <<< "${{ steps.meta.outputs.tags }}" diff --git a/README.md b/README.md index 5e6c43e..6b5768a 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ ### 📦 轻量部署 - **单 Docker 容器**,默认本地数据目录部署,支持外接 MySQL / PostgreSQL 运行时数据库 -- Alpine 基础镜像,体积精简 +- Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署 - 数据完整导入导出,迁移无忧 --- @@ -343,6 +343,10 @@ docker run -d --name metapi \ 启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。 +> [!NOTE] +> Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署。 +> 当前 `armv7l` 支持范围仅限服务端 / Docker 运行,不包含桌面安装包。 + > [!IMPORTANT] > 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。 @@ -369,7 +373,7 @@ Docker Compose、桌面安装包、反向代理、升级与数据库选项等详 | **数据库** | SQLite / MySQL / PostgreSQL +[Drizzle ORM](https://orm.drizzle.team) | | **数据可视化** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) | | **定时任务** | [node-cron](https://github.com/node-cron/node-cron) | -| **容器化** | Docker (Alpine) + Docker Compose | +| **容器化** | Docker (Debian slim) + Docker Compose | | **测试** | [Vitest](https://vitest.dev) | --- diff --git a/README_EN.md b/README_EN.md index 7ba84bc..593efeb 100644 --- a/README_EN.md +++ b/README_EN.md @@ -287,7 +287,7 @@ Alert scenarios: low balance warning, site/account anomalies, check-in failures, ### Lightweight Deployment - **Single Docker container** with a default local data directory, plus optional external MySQL / PostgreSQL runtime DB -- Alpine base image, minimal footprint +- Docker images support `amd64`, `arm64`, and `armv7l` (`linux/arm/v7`) server deployments - Full data import/export for worry-free migration --- @@ -344,6 +344,10 @@ docker run -d --name metapi \ After starting, visit `http://localhost:4000` and log in with your `AUTH_TOKEN`! +> [!NOTE] +> Docker images support `amd64`, `arm64`, and `armv7l` (`linux/arm/v7`) server deployments. +> Current `armv7l` support is limited to server / Docker usage and does not include Electron desktop packaging support. + > [!IMPORTANT] > Make sure to change `AUTH_TOKEN` and `PROXY_TOKEN` — do not use default values. Data is stored in the `./data` directory and persists across upgrades. @@ -477,7 +481,7 @@ For detailed per-client setup, examples, and troubleshooting, see [docs/client-i | **Database** | SQLite / MySQL / PostgreSQL + [Drizzle ORM](https://orm.drizzle.team) | | **Charts** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) | | **Scheduling** | [node-cron](https://github.com/node-cron/node-cron) | -| **Containerization** | Docker (Alpine) + Docker Compose | +| **Containerization** | Docker (Debian slim) + Docker Compose | | **Testing** | [Vitest](https://vitest.dev) | --- diff --git a/scripts/dev/docker.workflow.test.ts b/scripts/dev/docker.workflow.test.ts new file mode 100644 index 0000000..2236d86 --- /dev/null +++ b/scripts/dev/docker.workflow.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('docker workflows', () => { + it('publishes armv7 docker images in ci and release workflows', () => { + const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8'); + const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8'); + + expect(ciWorkflow).toContain('arch: armv7'); + expect(ciWorkflow).toContain('platform: linux/arm/v7'); + expect(ciWorkflow).toContain('"${tag}-armv7"'); + + expect(releaseWorkflow).toContain('arch: armv7'); + expect(releaseWorkflow).toContain('platform: linux/arm/v7'); + expect(releaseWorkflow).toContain('"${tag}-armv7"'); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts index c6b388d..b62355d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -30,6 +30,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'; @@ -399,6 +400,7 @@ if (existsSync(webDir)) { // Start scheduler await startScheduler(); +await reloadBackupWebdavScheduler(); startSiteAnnouncementPolling(); try { await startOAuthLoopbackCallbackServers(); diff --git a/src/server/routes/api/settings.backup-webdav.test.ts b/src/server/routes/api/settings.backup-webdav.test.ts new file mode 100644 index 0000000..9343731 --- /dev/null +++ b/src/server/routes/api/settings.backup-webdav.test.ts @@ -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(); + }); +}); diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index d39b142..f99f2ba 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -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( diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 2026af2..8392387 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -596,4 +596,149 @@ 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(); + }); }); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 07f1bcb..b3db5fa 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -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,10 @@ const EXCLUDED_SETTING_KEYS = new Set([ // 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 * * *'; +let backupWebdavTask: cron.ScheduledTask | null = null; const DIRECT_API_PLATFORMS = new Set([ 'openai', @@ -722,6 +752,107 @@ 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 { + 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 { + return normalizeBackupWebdavConfig(await readSettingValue(BACKUP_WEBDAV_CONFIG_SETTING_KEY)); +} + +async function loadBackupWebdavState(): Promise { + 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 备份'); + } +} + +function stopBackupWebdavScheduler() { + backupWebdavTask?.stop(); + backupWebdavTask = null; +} + function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } @@ -1103,3 +1234,166 @@ export async function importBackup(data: RawBackupData): Promise & { 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 = { + 'Content-Type': 'application/json', + }; + const authHeader = resolveBackupWebdavAuthHeader(config); + if (authHeader) headers.Authorization = authHeader; + + try { + const response = await fetch(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 = {}; + const authHeader = resolveBackupWebdavAuthHeader(config); + if (authHeader) headers.Authorization = authHeader; + + try { + const response = await fetch(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 || !config.fileUrl) return; + if (!cron.validate(config.autoSyncCron)) { + console.warn(`[backup/webdav] invalid auto sync cron: ${config.autoSyncCron}`); + 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(); +} diff --git a/src/web/api.ts b/src/web/api.ts index abccbef..704c16f 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -587,6 +587,33 @@ export const api = { method: 'POST', body: JSON.stringify({ data }), }), + getBackupWebdavConfig: () => request('/api/settings/backup/webdav'), + saveBackupWebdavConfig: (data: { + enabled: boolean; + fileUrl: string; + username: string; + password?: string; + clearPassword?: boolean; + exportType: 'all' | 'accounts' | 'preferences'; + autoSyncEnabled: boolean; + autoSyncCron: string; + }) => + request('/api/settings/backup/webdav', { + method: 'PUT', + body: JSON.stringify(data), + }), + exportBackupToWebdav: (type?: 'all' | 'accounts' | 'preferences') => + request('/api/settings/backup/webdav/export', { + method: 'POST', + body: JSON.stringify(type ? { type } : {}), + timeoutMs: 60_000, + }), + importBackupFromWebdav: () => + request('/api/settings/backup/webdav/import', { + method: 'POST', + body: JSON.stringify({}), + timeoutMs: 60_000, + }), clearRuntimeCache: () => request('/api/settings/maintenance/clear-cache', { method: 'POST' }), clearUsageData: () => request('/api/settings/maintenance/clear-usage', { method: 'POST' }), factoryReset: () => request('/api/settings/maintenance/factory-reset', { method: 'POST' }), diff --git a/src/web/pages/ImportExport.test.tsx b/src/web/pages/ImportExport.test.tsx index 28fad0f..a02d0f5 100644 --- a/src/web/pages/ImportExport.test.tsx +++ b/src/web/pages/ImportExport.test.tsx @@ -7,6 +7,10 @@ const { apiMock, toastMock } = vi.hoisted(() => ({ apiMock: { exportBackup: vi.fn(), importBackup: vi.fn(), + getBackupWebdavConfig: vi.fn(), + saveBackupWebdavConfig: vi.fn(), + exportBackupToWebdav: vi.fn(), + importBackupFromWebdav: vi.fn(), }, toastMock: { success: vi.fn(), @@ -185,6 +189,53 @@ describe('ImportExport', () => { vi.stubGlobal('window', { confirm: vi.fn(() => true), }); + apiMock.getBackupWebdavConfig.mockResolvedValue({ + success: true, + config: { + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi.json', + username: 'alice', + exportType: 'all', + autoSyncEnabled: true, + autoSyncCron: '0 */6 * * *', + hasPassword: true, + passwordMasked: 'se****ss', + }, + state: { + lastSyncAt: null, + lastError: null, + }, + }); + apiMock.saveBackupWebdavConfig.mockResolvedValue({ + success: true, + config: { + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi.json', + username: 'alice', + exportType: 'all', + autoSyncEnabled: true, + autoSyncCron: '0 */6 * * *', + hasPassword: true, + passwordMasked: 'se****ss', + }, + state: { + lastSyncAt: null, + lastError: null, + }, + }); + apiMock.exportBackupToWebdav.mockResolvedValue({ + success: true, + fileUrl: 'https://dav.example.com/backups/metapi.json', + exportType: 'all', + }); + apiMock.importBackupFromWebdav.mockResolvedValue({ + success: true, + sections: { + accounts: true, + preferences: true, + }, + appliedSettings: [], + }); apiMock.importBackup.mockResolvedValue({ allImported: true, sections: { @@ -305,4 +356,41 @@ describe('ImportExport', () => { root?.unmount(); } }); + + it('loads webdav config and saves updates from the import/export page', async () => { + let root: ReturnType | null = null; + + try { + await act(async () => { + root = create( + + + , + ); + }); + await flushMicrotasks(); + + const rendered = collectText(root!.root); + expect(apiMock.getBackupWebdavConfig).toHaveBeenCalledTimes(1); + expect(rendered).toContain('WebDAV'); + expect(rendered).toContain('自动同步'); + + const saveButton = root!.root.findAll((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('保存 WebDAV 配置') + )).at(-1); + + expect(saveButton).toBeTruthy(); + + await act(async () => { + saveButton!.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.saveBackupWebdavConfig).toHaveBeenCalledTimes(1); + } finally { + root?.unmount(); + } + }); }); diff --git a/src/web/pages/ImportExport.tsx b/src/web/pages/ImportExport.tsx index 12aa9a9..75ec16f 100644 --- a/src/web/pages/ImportExport.tsx +++ b/src/web/pages/ImportExport.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api.js'; import { useToast } from '../components/Toast.js'; import { tr } from '../i18n.js'; @@ -24,6 +24,35 @@ type ParsedSummary = { ignoredSections: string[]; }; +type WebdavConfigForm = { + enabled: boolean; + fileUrl: string; + username: string; + password: string; + exportType: BackupType; + autoSyncEnabled: boolean; + autoSyncCron: string; + hasPassword: boolean; + passwordMasked: string; +}; + +type WebdavSyncState = { + lastSyncAt: string | null; + lastError: string | null; +}; + +const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = { + enabled: false, + fileUrl: '', + username: '', + password: '', + exportType: 'all', + autoSyncEnabled: false, + autoSyncCron: '0 */6 * * *', + hasPassword: false, + passwordMasked: '', +}; + function downloadJsonFile(data: unknown, filename: string) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -188,10 +217,46 @@ export default function ImportExport() { const [importData, setImportData] = useState(''); const [selectedFileName, setSelectedFileName] = useState(''); const [dragOver, setDragOver] = useState(false); + const [webdavConfig, setWebdavConfig] = useState(DEFAULT_WEBDAV_CONFIG); + const [webdavState, setWebdavState] = useState({ lastSyncAt: null, lastError: null }); + const [webdavSaving, setWebdavSaving] = useState(false); + const [webdavAction, setWebdavAction] = useState<'export' | 'import' | ''>(''); const fileInputRef = useRef(null); const summary = useMemo(() => parseImportSummary(importData), [importData]); + useEffect(() => { + let alive = true; + void api.getBackupWebdavConfig() + .then((result: any) => { + if (!alive) return; + const config = result?.config || {}; + const state = result?.state || {}; + setWebdavConfig({ + enabled: config.enabled === true, + fileUrl: String(config.fileUrl || ''), + username: String(config.username || ''), + password: '', + exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all', + autoSyncEnabled: config.autoSyncEnabled === true, + autoSyncCron: String(config.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron), + hasPassword: config.hasPassword === true, + passwordMasked: String(config.passwordMasked || ''), + }); + setWebdavState({ + lastSyncAt: typeof state.lastSyncAt === 'string' ? state.lastSyncAt : null, + lastError: typeof state.lastError === 'string' ? state.lastError : null, + }); + }) + .catch((err: any) => { + if (!alive) return; + toast.error(err?.message || '加载 WebDAV 配置失败'); + }); + return () => { + alive = false; + }; + }, [toast]); + const handleExport = async (type: BackupType) => { setExportingType(type); try { @@ -281,6 +346,85 @@ export default function ImportExport() { } }; + const applyWebdavResponse = (result: any) => { + const config = result?.config; + if (config) { + setWebdavConfig((prev) => ({ + enabled: config.enabled === true, + fileUrl: String(config.fileUrl || ''), + username: String(config.username || ''), + password: '', + exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all', + autoSyncEnabled: config.autoSyncEnabled === true, + autoSyncCron: String(config.autoSyncCron || prev.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron), + hasPassword: config.hasPassword === true, + passwordMasked: String(config.passwordMasked || ''), + })); + } + const state = result?.state || result; + setWebdavState((prev) => ({ + lastSyncAt: typeof state?.lastSyncAt === 'string' ? state.lastSyncAt : prev.lastSyncAt, + lastError: typeof state?.lastError === 'string' + ? state.lastError + : (state?.lastError === null ? null : prev.lastError), + })); + }; + + const handleSaveWebdavConfig = async () => { + setWebdavSaving(true); + try { + const payload: Record = { + enabled: webdavConfig.enabled, + fileUrl: webdavConfig.fileUrl, + username: webdavConfig.username, + exportType: webdavConfig.exportType, + autoSyncEnabled: webdavConfig.autoSyncEnabled, + autoSyncCron: webdavConfig.autoSyncCron, + }; + if (webdavConfig.password.trim()) { + payload.password = webdavConfig.password; + } + const result = await api.saveBackupWebdavConfig(payload as any); + applyWebdavResponse(result); + setWebdavConfig((prev) => ({ ...prev, password: '' })); + toast.success('WebDAV 配置已保存'); + } catch (err: any) { + toast.error(err?.message || '保存 WebDAV 配置失败'); + } finally { + setWebdavSaving(false); + } + }; + + const handleExportToWebdav = async () => { + setWebdavAction('export'); + try { + const result = await api.exportBackupToWebdav(webdavConfig.exportType); + applyWebdavResponse(result); + toast.success(`已导出到 WebDAV:${result?.fileUrl || webdavConfig.fileUrl}`); + } catch (err: any) { + toast.error(err?.message || '导出到 WebDAV 失败'); + } finally { + setWebdavAction(''); + } + }; + + const handleImportFromWebdav = async () => { + const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function' + ? true + : window.confirm('从 WebDAV 导入会覆盖本地账号/路由或系统设置,确认继续?'); + if (!confirmed) return; + setWebdavAction('import'); + try { + const result = await api.importBackupFromWebdav(); + applyWebdavResponse(result); + toast.success(buildImportSuccessMessage(result)); + } catch (err: any) { + toast.error(err?.message || '从 WebDAV 导入失败'); + } finally { + setWebdavAction(''); + } + }; + return (
@@ -483,7 +627,119 @@ export default function ImportExport() {
-
+
+
+ + + + WebDAV 同步 +
+
+ 支持手动推送、手动拉取,以及定时自动导出到 WebDAV。 +
+ +
+ + + + + +
+ + +
+
+ +
+ + + +
+ +
+
上次同步:{webdavState.lastSyncAt ? new Date(webdavState.lastSyncAt).toLocaleString() : '尚未同步'}
+
最近错误:{webdavState.lastError || '无'}
+
+
+ +
注意事项
1. 导入账号分区会覆盖现有站点、账号、令牌和路由配置。