From 1ce036fd468d75259f8ccd810e148dc366d23847 Mon Sep 17 00:00:00 2001 From: ciat-777 Date: Sat, 21 Mar 2026 00:48:42 +0800 Subject: [PATCH] Address PR review for WebDAV and armv7 --- .github/workflows/ci.yml | 29 ++++ README.md | 1 + README_EN.md | 1 + scripts/dev/docker.workflow.test.ts | 9 ++ src/server/services/backupService.test.ts | 105 ++++++++++++++ src/server/services/backupService.ts | 38 +++++- src/web/pages/ImportExport.test.tsx | 106 +++++++++++++++ src/web/pages/ImportExport.tsx | 159 +++++++++++++++------- 8 files changed, 396 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8569640..451fa08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -299,6 +299,35 @@ jobs: - name: Audit production dependencies run: npm audit --omit=dev --audit-level=high + docker-smoke-armv7: + name: Docker Smoke Build (armv7) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + with: + platforms: arm + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build linux/arm/v7 image + uses: docker/build-push-action@v7 + with: + context: . + file: docker/Dockerfile + platforms: linux/arm/v7 + push: false + provenance: false + cache-from: type=gha,scope=ci-armv7-pr + cache-to: type=gha,mode=max,scope=ci-armv7-pr + publish-docker-arch: name: Publish Docker Image (${{ matrix.arch }}) if: github.event_name == 'push' diff --git a/README.md b/README.md index 6b5768a..33f526f 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,7 @@ docker run -d --name metapi \ > Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署。 > 当前 `armv7l` 支持范围仅限服务端 / Docker 运行,不包含桌面安装包。 + > [!IMPORTANT] > 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。 diff --git a/README_EN.md b/README_EN.md index 593efeb..41fb7a4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -348,6 +348,7 @@ After starting, visit `http://localhost:4000` and log in with your `AUTH_TOKEN`! > 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. diff --git a/scripts/dev/docker.workflow.test.ts b/scripts/dev/docker.workflow.test.ts index 2236d86..3218182 100644 --- a/scripts/dev/docker.workflow.test.ts +++ b/scripts/dev/docker.workflow.test.ts @@ -15,4 +15,13 @@ describe('docker workflows', () => { expect(releaseWorkflow).toContain('platform: linux/arm/v7'); expect(releaseWorkflow).toContain('"${tag}-armv7"'); }); + + it('smoke builds the armv7 docker image on pull requests', () => { + const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8'); + + expect(ciWorkflow).toContain('Docker Smoke Build (armv7)'); + expect(ciWorkflow).toContain('if: github.event_name == \'pull_request\''); + expect(ciWorkflow).toContain('platforms: linux/arm/v7'); + expect(ciWorkflow).toContain('push: false'); + }); }); diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 8392387..28dbd47 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -741,4 +741,109 @@ describe('backupService', () => { 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; + } + return new Promise((_, 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; + } + return new Promise((_, 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(); + } + }); }); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index b3db5fa..81c4f52 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -105,6 +105,7 @@ const EXCLUDED_SETTING_KEYS = new Set([ 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([ @@ -848,6 +849,30 @@ function validateBackupWebdavConfig(config: BackupWebdavConfig) { } } +async function fetchBackupWebdav(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + let timeoutHandle: ReturnType | 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; @@ -1294,7 +1319,7 @@ export async function exportBackupToWebdav(type?: BackupExportType) { if (authHeader) headers.Authorization = authHeader; try { - const response = await fetch(config.fileUrl, { + const response = await fetchBackupWebdav(config.fileUrl, { method: 'PUT', headers, body: JSON.stringify(payload, null, 2), @@ -1342,7 +1367,7 @@ export async function importBackupFromWebdav() { if (authHeader) headers.Authorization = authHeader; try { - const response = await fetch(config.fileUrl, { + const response = await fetchBackupWebdav(config.fileUrl, { method: 'GET', headers, }); @@ -1381,9 +1406,12 @@ export async function importBackupFromWebdav() { 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}`); + if (!config.enabled || !config.autoSyncEnabled) return; + + try { + validateBackupWebdavConfig(config); + } catch (error: any) { + console.warn(`[backup/webdav] invalid config: ${error?.message || 'unknown error'}`); return; } diff --git a/src/web/pages/ImportExport.test.tsx b/src/web/pages/ImportExport.test.tsx index a02d0f5..3623233 100644 --- a/src/web/pages/ImportExport.test.tsx +++ b/src/web/pages/ImportExport.test.tsx @@ -393,4 +393,110 @@ describe('ImportExport', () => { root?.unmount(); } }); + + it('disables webdav actions while config has unsaved changes', async () => { + let root: ReturnType | null = null; + + try { + await act(async () => { + root = create( + + + , + ); + }); + await flushMicrotasks(); + + const fileUrlInput = root!.root.findAll((node) => ( + node.type === 'input' + && node.props.placeholder === 'https://dav.example.com/backups/metapi.json' + )).at(-1); + + expect(fileUrlInput).toBeTruthy(); + + await act(async () => { + fileUrlInput!.props.onChange({ target: { value: 'https://dav.example.com/backups/changed.json' } }); + }); + await flushMicrotasks(); + + const exportButton = root!.root.findAll((node) => ( + node.type === 'button' + && collectText(node).includes('立即导出到 WebDAV') + )).at(-1); + const importButton = root!.root.findAll((node) => ( + node.type === 'button' + && collectText(node).includes('从 WebDAV 拉取') + )).at(-1); + + expect(exportButton?.props.disabled).toBe(true); + expect(importButton?.props.disabled).toBe(true); + } finally { + root?.unmount(); + } + }); + + it('can clear a saved webdav password', async () => { + apiMock.saveBackupWebdavConfig.mockResolvedValueOnce({ + success: true, + config: { + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi.json', + username: 'alice', + exportType: 'all', + autoSyncEnabled: true, + autoSyncCron: '0 */6 * * *', + hasPassword: false, + passwordMasked: '', + }, + state: { + lastSyncAt: null, + lastError: null, + }, + }); + + let root: ReturnType | null = null; + + try { + await act(async () => { + root = create( + + + , + ); + }); + await flushMicrotasks(); + + const clearPasswordToggle = root!.root.findAll((node) => ( + node.type === 'label' + && collectText(node).includes('清空已保存密码') + )).at(-1); + + expect(clearPasswordToggle).toBeTruthy(); + + const checkbox = clearPasswordToggle!.findByType('input'); + await act(async () => { + checkbox.props.onChange({ target: { checked: true } }); + }); + await flushMicrotasks(); + + 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).toHaveBeenCalledWith(expect.objectContaining({ + clearPassword: true, + })); + } finally { + root?.unmount(); + } + }); }); diff --git a/src/web/pages/ImportExport.tsx b/src/web/pages/ImportExport.tsx index 75ec16f..0ef2c85 100644 --- a/src/web/pages/ImportExport.tsx +++ b/src/web/pages/ImportExport.tsx @@ -41,6 +41,16 @@ type WebdavSyncState = { lastError: string | null; }; +type WebdavConfigSnapshot = { + enabled: boolean; + fileUrl: string; + username: string; + exportType: BackupType; + autoSyncEnabled: boolean; + autoSyncCron: string; + hasPassword: boolean; +}; + const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = { enabled: false, fileUrl: '', @@ -53,6 +63,16 @@ const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = { passwordMasked: '', }; +const DEFAULT_WEBDAV_SNAPSHOT: WebdavConfigSnapshot = { + enabled: false, + fileUrl: '', + username: '', + exportType: 'all', + autoSyncEnabled: false, + autoSyncCron: '0 */6 * * *', + hasPassword: false, +}; + function downloadJsonFile(data: unknown, filename: string) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -218,35 +238,71 @@ export default function ImportExport() { const [selectedFileName, setSelectedFileName] = useState(''); const [dragOver, setDragOver] = useState(false); const [webdavConfig, setWebdavConfig] = useState(DEFAULT_WEBDAV_CONFIG); + const [savedWebdavConfig, setSavedWebdavConfig] = useState(DEFAULT_WEBDAV_SNAPSHOT); const [webdavState, setWebdavState] = useState({ lastSyncAt: null, lastError: null }); const [webdavSaving, setWebdavSaving] = useState(false); const [webdavAction, setWebdavAction] = useState<'export' | 'import' | ''>(''); + const [clearWebdavPassword, setClearWebdavPassword] = useState(false); const fileInputRef = useRef(null); const summary = useMemo(() => parseImportSummary(importData), [importData]); + const buildWebdavForm = (config: any): WebdavConfigForm => ({ + 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 || ''), + }); + + const buildWebdavSnapshot = (config: any): WebdavConfigSnapshot => ({ + enabled: config.enabled === true, + fileUrl: String(config.fileUrl || ''), + username: String(config.username || ''), + 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, + }); + + const applyWebdavResponse = (result: any) => { + const config = result?.config; + if (config) { + setWebdavConfig(buildWebdavForm(config)); + setSavedWebdavConfig(buildWebdavSnapshot(config)); + setClearWebdavPassword(false); + } + 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 webdavConfigDirty = ( + webdavConfig.enabled !== savedWebdavConfig.enabled + || webdavConfig.fileUrl !== savedWebdavConfig.fileUrl + || webdavConfig.username !== savedWebdavConfig.username + || webdavConfig.exportType !== savedWebdavConfig.exportType + || webdavConfig.autoSyncEnabled !== savedWebdavConfig.autoSyncEnabled + || webdavConfig.autoSyncCron !== savedWebdavConfig.autoSyncCron + || webdavConfig.hasPassword !== savedWebdavConfig.hasPassword + || webdavConfig.password.trim().length > 0 + || clearWebdavPassword + ); + 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, - }); + applyWebdavResponse(result); }) .catch((err: any) => { if (!alive) return; @@ -346,33 +402,10 @@ 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 nextPassword = webdavConfig.password.trim(); const payload: Record = { enabled: webdavConfig.enabled, fileUrl: webdavConfig.fileUrl, @@ -381,12 +414,13 @@ export default function ImportExport() { autoSyncEnabled: webdavConfig.autoSyncEnabled, autoSyncCron: webdavConfig.autoSyncCron, }; - if (webdavConfig.password.trim()) { + if (nextPassword) { payload.password = webdavConfig.password; + } else if (clearWebdavPassword) { + payload.clearPassword = true; } 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 配置失败'); @@ -662,10 +696,35 @@ export default function ImportExport() { setWebdavConfig((prev) => ({ ...prev, password: e.target.value }))} - placeholder={webdavConfig.hasPassword ? `已保存 ${webdavConfig.passwordMasked},留空则保持不变` : '请输入密码'} + onChange={(e) => { + const nextPassword = e.target.value; + setWebdavConfig((prev) => ({ ...prev, password: nextPassword })); + if (nextPassword.trim()) { + setClearWebdavPassword(false); + } + }} + placeholder={clearWebdavPassword + ? '保存后将清空已存密码' + : (webdavConfig.hasPassword ? `已保存 ${webdavConfig.passwordMasked},留空则保持不变` : '请输入密码')} + disabled={clearWebdavPassword} style={{ width: '100%' }} /> + {webdavConfig.hasPassword ? ( + + ) : null}