forked from boos/BoosAPI
Address PR review for WebDAV and armv7
This commit is contained in:
@@ -299,6 +299,35 @@ jobs:
|
|||||||
- name: Audit production dependencies
|
- name: Audit production dependencies
|
||||||
run: npm audit --omit=dev --audit-level=high
|
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:
|
publish-docker-arch:
|
||||||
name: Publish Docker Image (${{ matrix.arch }})
|
name: Publish Docker Image (${{ matrix.arch }})
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ docker run -d --name metapi \
|
|||||||
> Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署。
|
> Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署。
|
||||||
> 当前 `armv7l` 支持范围仅限服务端 / Docker 运行,不包含桌面安装包。
|
> 当前 `armv7l` 支持范围仅限服务端 / Docker 运行,不包含桌面安装包。
|
||||||
|
|
||||||
|
<!-- markdownlint-disable-next-line MD028 -->
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。
|
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
> 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.
|
> Current `armv7l` support is limited to server / Docker usage and does not include Electron desktop packaging support.
|
||||||
|
|
||||||
|
<!-- markdownlint-disable-next-line MD028 -->
|
||||||
> [!IMPORTANT]
|
> [!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.
|
> 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.
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,13 @@ describe('docker workflows', () => {
|
|||||||
expect(releaseWorkflow).toContain('platform: linux/arm/v7');
|
expect(releaseWorkflow).toContain('platform: linux/arm/v7');
|
||||||
expect(releaseWorkflow).toContain('"${tag}-armv7"');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -741,4 +741,109 @@ describe('backupService', () => {
|
|||||||
|
|
||||||
fetchSpy.mockRestore();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const EXCLUDED_SETTING_KEYS = new Set<string>([
|
|||||||
const BACKUP_WEBDAV_CONFIG_SETTING_KEY = 'backup_webdav_config_v1';
|
const BACKUP_WEBDAV_CONFIG_SETTING_KEY = 'backup_webdav_config_v1';
|
||||||
const BACKUP_WEBDAV_STATE_SETTING_KEY = 'backup_webdav_state_v1';
|
const BACKUP_WEBDAV_STATE_SETTING_KEY = 'backup_webdav_state_v1';
|
||||||
const BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON = '0 */6 * * *';
|
const BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON = '0 */6 * * *';
|
||||||
|
const BACKUP_WEBDAV_FETCH_TIMEOUT_MS = 15_000;
|
||||||
let backupWebdavTask: cron.ScheduledTask | null = null;
|
let backupWebdavTask: cron.ScheduledTask | null = null;
|
||||||
|
|
||||||
const DIRECT_API_PLATFORMS = new Set([
|
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() {
|
function stopBackupWebdavScheduler() {
|
||||||
backupWebdavTask?.stop();
|
backupWebdavTask?.stop();
|
||||||
backupWebdavTask = null;
|
backupWebdavTask = null;
|
||||||
@@ -1294,7 +1319,7 @@ export async function exportBackupToWebdav(type?: BackupExportType) {
|
|||||||
if (authHeader) headers.Authorization = authHeader;
|
if (authHeader) headers.Authorization = authHeader;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(config.fileUrl, {
|
const response = await fetchBackupWebdav(config.fileUrl, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(payload, null, 2),
|
body: JSON.stringify(payload, null, 2),
|
||||||
@@ -1342,7 +1367,7 @@ export async function importBackupFromWebdav() {
|
|||||||
if (authHeader) headers.Authorization = authHeader;
|
if (authHeader) headers.Authorization = authHeader;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(config.fileUrl, {
|
const response = await fetchBackupWebdav(config.fileUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
@@ -1381,9 +1406,12 @@ export async function importBackupFromWebdav() {
|
|||||||
export async function reloadBackupWebdavScheduler() {
|
export async function reloadBackupWebdavScheduler() {
|
||||||
stopBackupWebdavScheduler();
|
stopBackupWebdavScheduler();
|
||||||
const config = await loadBackupWebdavConfig();
|
const config = await loadBackupWebdavConfig();
|
||||||
if (!config.enabled || !config.autoSyncEnabled || !config.fileUrl) return;
|
if (!config.enabled || !config.autoSyncEnabled) return;
|
||||||
if (!cron.validate(config.autoSyncCron)) {
|
|
||||||
console.warn(`[backup/webdav] invalid auto sync cron: ${config.autoSyncCron}`);
|
try {
|
||||||
|
validateBackupWebdavConfig(config);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn(`[backup/webdav] invalid config: ${error?.message || 'unknown error'}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -393,4 +393,110 @@ describe('ImportExport', () => {
|
|||||||
root?.unmount();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+106
-41
@@ -41,6 +41,16 @@ type WebdavSyncState = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WebdavConfigSnapshot = {
|
||||||
|
enabled: boolean;
|
||||||
|
fileUrl: string;
|
||||||
|
username: string;
|
||||||
|
exportType: BackupType;
|
||||||
|
autoSyncEnabled: boolean;
|
||||||
|
autoSyncCron: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = {
|
const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
fileUrl: '',
|
fileUrl: '',
|
||||||
@@ -53,6 +63,16 @@ const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = {
|
|||||||
passwordMasked: '',
|
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) {
|
function downloadJsonFile(data: unknown, filename: string) {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -218,21 +238,16 @@ export default function ImportExport() {
|
|||||||
const [selectedFileName, setSelectedFileName] = useState('');
|
const [selectedFileName, setSelectedFileName] = useState('');
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [webdavConfig, setWebdavConfig] = useState<WebdavConfigForm>(DEFAULT_WEBDAV_CONFIG);
|
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 [webdavState, setWebdavState] = useState<WebdavSyncState>({ lastSyncAt: null, lastError: null });
|
||||||
const [webdavSaving, setWebdavSaving] = useState(false);
|
const [webdavSaving, setWebdavSaving] = useState(false);
|
||||||
const [webdavAction, setWebdavAction] = useState<'export' | 'import' | ''>('');
|
const [webdavAction, setWebdavAction] = useState<'export' | 'import' | ''>('');
|
||||||
|
const [clearWebdavPassword, setClearWebdavPassword] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const summary = useMemo(() => parseImportSummary(importData), [importData]);
|
const summary = useMemo(() => parseImportSummary(importData), [importData]);
|
||||||
|
|
||||||
useEffect(() => {
|
const buildWebdavForm = (config: any): WebdavConfigForm => ({
|
||||||
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,
|
enabled: config.enabled === true,
|
||||||
fileUrl: String(config.fileUrl || ''),
|
fileUrl: String(config.fileUrl || ''),
|
||||||
username: String(config.username || ''),
|
username: String(config.username || ''),
|
||||||
@@ -243,10 +258,51 @@ export default function ImportExport() {
|
|||||||
hasPassword: config.hasPassword === true,
|
hasPassword: config.hasPassword === true,
|
||||||
passwordMasked: String(config.passwordMasked || ''),
|
passwordMasked: String(config.passwordMasked || ''),
|
||||||
});
|
});
|
||||||
setWebdavState({
|
|
||||||
lastSyncAt: typeof state.lastSyncAt === 'string' ? state.lastSyncAt : null,
|
const buildWebdavSnapshot = (config: any): WebdavConfigSnapshot => ({
|
||||||
lastError: typeof state.lastError === 'string' ? state.lastError : null,
|
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;
|
||||||
|
applyWebdavResponse(result);
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
if (!alive) return;
|
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 () => {
|
const handleSaveWebdavConfig = async () => {
|
||||||
setWebdavSaving(true);
|
setWebdavSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const nextPassword = webdavConfig.password.trim();
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
enabled: webdavConfig.enabled,
|
enabled: webdavConfig.enabled,
|
||||||
fileUrl: webdavConfig.fileUrl,
|
fileUrl: webdavConfig.fileUrl,
|
||||||
@@ -381,12 +414,13 @@ export default function ImportExport() {
|
|||||||
autoSyncEnabled: webdavConfig.autoSyncEnabled,
|
autoSyncEnabled: webdavConfig.autoSyncEnabled,
|
||||||
autoSyncCron: webdavConfig.autoSyncCron,
|
autoSyncCron: webdavConfig.autoSyncCron,
|
||||||
};
|
};
|
||||||
if (webdavConfig.password.trim()) {
|
if (nextPassword) {
|
||||||
payload.password = webdavConfig.password;
|
payload.password = webdavConfig.password;
|
||||||
|
} else if (clearWebdavPassword) {
|
||||||
|
payload.clearPassword = true;
|
||||||
}
|
}
|
||||||
const result = await api.saveBackupWebdavConfig(payload as any);
|
const result = await api.saveBackupWebdavConfig(payload as any);
|
||||||
applyWebdavResponse(result);
|
applyWebdavResponse(result);
|
||||||
setWebdavConfig((prev) => ({ ...prev, password: '' }));
|
|
||||||
toast.success('WebDAV 配置已保存');
|
toast.success('WebDAV 配置已保存');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || '保存 WebDAV 配置失败');
|
toast.error(err?.message || '保存 WebDAV 配置失败');
|
||||||
@@ -662,10 +696,35 @@ export default function ImportExport() {
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={webdavConfig.password}
|
value={webdavConfig.password}
|
||||||
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, password: e.target.value }))}
|
onChange={(e) => {
|
||||||
placeholder={webdavConfig.hasPassword ? `已保存 ${webdavConfig.passwordMasked},留空则保持不变` : '请输入密码'}
|
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%' }}
|
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>
|
||||||
<label style={{ display: 'grid', gap: 6 }}>
|
<label style={{ display: 'grid', gap: 6 }}>
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>导出分区</span>
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>导出分区</span>
|
||||||
@@ -717,7 +776,7 @@ export default function ImportExport() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleExportToWebdav}
|
onClick={handleExportToWebdav}
|
||||||
disabled={webdavAction !== ''}
|
disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ border: '1px solid var(--color-border)' }}
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
@@ -725,7 +784,7 @@ export default function ImportExport() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleImportFromWebdav}
|
onClick={handleImportFromWebdav}
|
||||||
disabled={webdavAction !== ''}
|
disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ border: '1px solid var(--color-border)' }}
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
@@ -733,6 +792,12 @@ export default function ImportExport() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 style={{ marginTop: 12, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
|
||||||
<div>上次同步:{webdavState.lastSyncAt ? new Date(webdavState.lastSyncAt).toLocaleString() : '尚未同步'}</div>
|
<div>上次同步:{webdavState.lastSyncAt ? new Date(webdavState.lastSyncAt).toLocaleString() : '尚未同步'}</div>
|
||||||
<div>最近错误:{webdavState.lastError || '无'}</div>
|
<div>最近错误:{webdavState.lastError || '无'}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user