Address PR review for WebDAV and armv7

This commit is contained in:
ciat-777
2026-03-21 00:48:42 +08:00
parent bc52b6b5fd
commit 1ce036fd46
8 changed files with 396 additions and 52 deletions
+105
View File
@@ -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<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();
}
});
});
+33 -5
View File
@@ -105,6 +105,7 @@ const EXCLUDED_SETTING_KEYS = new Set<string>([
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<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;
@@ -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;
}
+106
View File
@@ -393,4 +393,110 @@ describe('ImportExport', () => {
root?.unmount();
}
});
it('disables webdav actions while config has unsaved changes', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
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();
}
});
});
+112 -47
View File
@@ -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<WebdavConfigForm>(DEFAULT_WEBDAV_CONFIG);
const [savedWebdavConfig, setSavedWebdavConfig] = useState<WebdavConfigSnapshot>(DEFAULT_WEBDAV_SNAPSHOT);
const [webdavState, setWebdavState] = useState<WebdavSyncState>({ lastSyncAt: null, lastError: null });
const [webdavSaving, setWebdavSaving] = useState(false);
const [webdavAction, setWebdavAction] = useState<'export' | 'import' | ''>('');
const [clearWebdavPassword, setClearWebdavPassword] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<string, unknown> = {
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() {
<input
type="password"
value={webdavConfig.password}
onChange={(e) => 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 ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={clearWebdavPassword}
onChange={(e) => {
const checked = e.target.checked === true;
setClearWebdavPassword(checked);
if (checked) {
setWebdavConfig((prev) => ({ ...prev, password: '' }));
}
}}
/>
清空已保存密码
</label>
) : null}
</label>
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>导出分区</span>
@@ -717,7 +776,7 @@ export default function ImportExport() {
</button>
<button
onClick={handleExportToWebdav}
disabled={webdavAction !== ''}
disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
@@ -725,7 +784,7 @@ export default function ImportExport() {
</button>
<button
onClick={handleImportFromWebdav}
disabled={webdavAction !== ''}
disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
@@ -733,6 +792,12 @@ export default function ImportExport() {
</button>
</div>
{webdavConfigDirty ? (
<div style={{ marginTop: 10, fontSize: 12, color: 'var(--color-warning)' }}>
当前 WebDAV 配置有未保存改动,请先保存后再执行导入或导出。
</div>
) : null}
<div style={{ marginTop: 12, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
<div>上次同步:{webdavState.lastSyncAt ? new Date(webdavState.lastSyncAt).toLocaleString() : '尚未同步'}</div>
<div>最近错误:{webdavState.lastError || '无'}</div>