Skip to content
94 changes: 83 additions & 11 deletions server/src/routes/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ router.post('/api/configs/ai', (req, res) => {
// PUT /api/configs/ai/bulk — replace all AI configs (for sync)
// MUST be registered before :id route to avoid matching 'bulk' as an id
router.put('/api/configs/ai/bulk', (req, res) => {
// Shared between transaction, response, and error handler
const syncResult = { inserted: 0, skipped: [] as Array<{ id: string; name: string; reason: string }> };

try {
const db = getDb();
const configs = req.body.configs as Array<{
Expand Down Expand Up @@ -138,18 +141,25 @@ router.put('/api/configs/ai/bulk', (req, res) => {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);

const skippedConfigs: Array<{ id: string; name: string; reason: string }> = [];

for (const c of configs) {
let encryptedKey = '';
if (c.apiKey && !c.apiKey.startsWith('***')) {
encryptedKey = encrypt(c.apiKey, config.encryptionKey);
try {
encryptedKey = encrypt(String(c.apiKey), config.encryptionKey);
} catch (encErr) {
console.error(`[configs] Failed to encrypt API key for config "${c.name}" (${c.id}), falling back to existing key:`, encErr);
encryptedKey = existingKeys.get(String(c.id)) ?? '';
if (!encryptedKey) {
syncResult.skipped.push({ id: c.id, name: c.name ?? '', reason: 'encrypt_failed' });
continue;
}
}
} else {
encryptedKey = existingKeys.get(String(c.id)) ?? '';
}

if (!encryptedKey) {
skippedConfigs.push({
syncResult.skipped.push({
id: c.id,
name: c.name ?? '',
reason: c.apiKey?.startsWith('***')
Expand All @@ -164,18 +174,35 @@ router.put('/api/configs/ai/bulk', (req, res) => {
encryptedKey, c.model ?? '', c.isActive ? 1 : 0,
c.customPrompt ?? null, c.useCustomPrompt ? 1 : 0, c.concurrency ?? 1, c.reasoningEffort ?? null
);
syncResult.inserted++;
}

if (skippedConfigs.length > 0) {
console.warn('[configs] Skipped AI configs with missing keys:', skippedConfigs);
if (syncResult.skipped.length > 0) {
console.warn('[configs] Skipped AI configs with missing keys:', syncResult.skipped);
}

// Safety guard: prevent committing an empty database when all configs were skipped
if (syncResult.inserted === 0 && configs.length > 0) {
throw new Error('ALL_CONFIGS_SKIPPED');
}
});

bulkSync();
res.json({ synced: configs.length });
res.json({ synced: syncResult.inserted, skipped: syncResult.skipped.length, errors: syncResult.skipped });
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error('PUT /api/configs/ai/bulk error:', err);
res.status(500).json({ error: 'Failed to sync AI configs', code: 'SYNC_AI_CONFIGS_FAILED' });
if (errMsg === 'ALL_CONFIGS_SKIPPED') {
res.status(422).json({
error: 'All AI configs were skipped — check the errors field for per-config reasons',
code: 'SYNC_AI_CONFIGS_ALL_SKIPPED',
synced: 0,
skipped: syncResult.skipped.length,
errors: syncResult.skipped,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
res.status(500).json({ error: 'Failed to sync AI configs', code: 'SYNC_AI_CONFIGS_FAILED' });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
});

Expand Down Expand Up @@ -297,6 +324,9 @@ router.post('/api/configs/webdav', (req, res) => {
// PUT /api/configs/webdav/bulk — replace all WebDAV configs (for sync)
// MUST be registered before :id route to avoid matching 'bulk' as an id
router.put('/api/configs/webdav/bulk', (req, res) => {
// Shared between transaction, response, and error handler
const syncResult = { inserted: 0, skipped: [] as Array<{ id: string; name: string; reason: string }> };

try {
const db = getDb();
const configs = req.body.configs as Array<{
Expand Down Expand Up @@ -332,22 +362,64 @@ router.put('/api/configs/webdav/bulk', (req, res) => {
for (const c of configs) {
let encryptedPwd = '';
if (c.password && !c.password.startsWith('***')) {
encryptedPwd = encrypt(c.password, config.encryptionKey);
try {
encryptedPwd = encrypt(String(c.password), config.encryptionKey);
} catch (encErr) {
console.error(`[configs] Failed to encrypt WebDAV password for "${c.name}" (${c.id}), falling back to existing:`, encErr);
encryptedPwd = existingPwds.get(String(c.id)) ?? '';
if (!encryptedPwd) {
syncResult.skipped.push({ id: c.id, name: c.name ?? '', reason: 'encrypt_failed' });
continue;
}
}
} else {
encryptedPwd = existingPwds.get(String(c.id)) ?? '';
}

if (!encryptedPwd) {
syncResult.skipped.push({
id: c.id,
name: c.name ?? '',
reason: c.password?.startsWith('***')
? 'Password is masked and no existing password found'
: 'Password is empty',
});
continue;
}

stmt.run(
c.id, c.name ?? '', c.url ?? '', c.username ?? '',
encryptedPwd, c.path ?? '/', c.isActive ? 1 : 0
);
syncResult.inserted++;
}

if (syncResult.skipped.length > 0) {
console.warn('[configs] Skipped WebDAV configs with missing passwords:', syncResult.skipped);
}

// Safety guard: prevent committing an empty database when all configs were skipped
if (syncResult.inserted === 0 && configs.length > 0) {
throw new Error('ALL_CONFIGS_SKIPPED');
}
});

bulkSync();
res.json({ synced: configs.length });
res.json({ synced: syncResult.inserted, skipped: syncResult.skipped.length, errors: syncResult.skipped });
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error('PUT /api/configs/webdav/bulk error:', err);
res.status(500).json({ error: 'Failed to sync WebDAV configs', code: 'SYNC_WEBDAV_CONFIGS_FAILED' });
if (errMsg === 'ALL_CONFIGS_SKIPPED') {
res.status(422).json({
error: 'All WebDAV configs were skipped — check the errors field for per-config reasons',
code: 'SYNC_WEBDAV_CONFIGS_ALL_SKIPPED',
synced: 0,
skipped: syncResult.skipped.length,
errors: syncResult.skipped,
});
} else {
res.status(500).json({ error: 'Failed to sync WebDAV configs', code: 'SYNC_WEBDAV_CONFIGS_FAILED' });
}
}
});

Expand Down
50 changes: 41 additions & 9 deletions src/components/settings/BackendPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ export const BackendPanel: React.FC<BackendPanelProps> = ({ t }) => {
releases,
aiConfigs,
webdavConfigs,
activeAIConfig,
activeWebDAVConfig,
hiddenDefaultCategoryIds,
categoryOrder,
customCategories,
assetFilters,
collapsedSidebarCategoryCount,
backendApiSecret,
setBackendApiSecret,
setRepositories,
Expand Down Expand Up @@ -90,15 +96,41 @@ export const BackendPanel: React.FC<BackendPanelProps> = ({ t }) => {
}
setIsSyncingToBackend(true);
try {
await backend.syncRepositories(repositories);
await backend.syncReleases(releases);
await backend.syncAIConfigs(aiConfigs);
await backend.syncWebDAVConfigs(webdavConfigs);
await backend.syncSettings({ hiddenDefaultCategoryIds });
toast(t(
`已同步到后端:仓库 ${repositories.length},发布 ${releases.length},AI配置 ${aiConfigs.length},WebDAV配置 ${webdavConfigs.length}`,
`Synced to backend: repos ${repositories.length}, releases ${releases.length}, AI configs ${aiConfigs.length}, WebDAV configs ${webdavConfigs.length}`
), 'success');
// Use allSettled so that one failure doesn't block other syncs
const results = await Promise.allSettled([
backend.syncRepositories(repositories),
backend.syncReleases(releases),
backend.syncAIConfigs(aiConfigs),
backend.syncWebDAVConfigs(webdavConfigs),
backend.syncSettings({
activeAIConfig,
activeWebDAVConfig,
hiddenDefaultCategoryIds,
categoryOrder,
customCategories,
assetFilters,
collapsedSidebarCategoryCount,
}),
]);

const failures = results.filter(r => r.status === 'rejected');
const successes = results.filter(r => r.status === 'fulfilled');

if (failures.length > 0) {
console.warn('Some syncs failed:', failures.map(f => (f as PromiseRejectedResult).reason));
toast(
t(
`同步部分失败:${failures.length} 项失败,${successes.length} 项成功`,
`Partial sync failure: ${failures.length} failed, ${successes.length} succeeded`
),
'error'
);
} else {
toast(t(
`已同步到后端:仓库 ${repositories.length},发布 ${releases.length},AI配置 ${aiConfigs.length},WebDAV配置 ${webdavConfigs.length}`,
`Synced to backend: repos ${repositories.length}, releases ${releases.length}, AI configs ${aiConfigs.length}, WebDAV configs ${webdavConfigs.length}`
), 'success');
}
} catch (error) {
console.error('Sync to backend failed:', error);
toast(`${t('同步失败', 'Sync failed')}: ${(error as Error).message}`, 'error');
Expand Down
35 changes: 33 additions & 2 deletions src/services/autoSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,42 @@ export async function syncFromBackend(): Promise<void> {
_lastHash.releases = hashes.releases;
}
if (changed.ai && aiResult.status === 'fulfilled') {
state.setAIConfigs(aiResult.value);
// Filter out configs with decrypt_failed status — preserve local apiKey values
// to prevent backend decryption failures from overwriting valid local data.
const backendConfigs = aiResult.value;
const localConfigs = state.aiConfigs;
const mergedConfigs = backendConfigs.map(bc => {
if (bc.apiKeyStatus === 'decrypt_failed' || !bc.apiKey) {
const local = localConfigs.find(lc => lc.id === bc.id);
if (local && local.apiKey) {
console.warn(`[sync] Backend decrypt_failed for AI config "${bc.name}", preserving local apiKey`);
return { ...bc, apiKey: local.apiKey, apiKeyStatus: 'ok' as const };
}
}
return bc;
});
state.setAIConfigs(mergedConfigs);
// Store raw backend hash so change detection compares against the same payload.
// Using mergedConfigs would cause a mismatch and re-trigger on every poll.
_lastHash.ai = hashes.ai;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (changed.webdav && webdavResult.status === 'fulfilled') {
state.setWebDAVConfigs(webdavResult.value);
// Filter out configs with decrypt_failed status — preserve local password values
// to prevent backend decryption failures from overwriting valid local data.
const backendConfigs = webdavResult.value;
const localConfigs = state.webdavConfigs;
const mergedConfigs = backendConfigs.map(bc => {
if (bc.passwordStatus === 'decrypt_failed' || !bc.password) {
const local = localConfigs.find(lc => lc.id === bc.id);
if (local && local.password) {
console.warn(`[sync] Backend decrypt_failed for WebDAV config "${bc.name}", preserving local password`);
return { ...bc, password: local.password, passwordStatus: 'ok' as const };
}
}
return bc;
});
state.setWebDAVConfigs(mergedConfigs);
// Store raw backend hash for consistent change detection
_lastHash.webdav = hashes.webdav;
}
// Sync active selections from settings
Expand Down
46 changes: 42 additions & 4 deletions src/services/backendAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,12 +340,31 @@ class BackendAdapter {
async syncAIConfigs(configs: AIConfig[]): Promise<void> {
if (!this._backendUrl) return;

const res = await this.fetchWithTimeout(`${this._backendUrl}/configs/ai/bulk`, {
// Pre-sync validation: warn about configs that will likely be skipped
for (const c of configs) {
if (!c.apiKey) {
console.warn(`[sync] AI config "${c.name}" (${c.id}) has empty apiKey, will be skipped if no existing key on backend`);
}
}

const res = await this.fetchWithRetry(`${this._backendUrl}/configs/ai/bulk`, {
method: 'PUT',
headers: this.getAuthHeaders(),
body: JSON.stringify({ configs })
});
}, 30000, 3);
if (!res.ok) await this.throwTranslatedError(res, 'Sync AI configs error');

// Parse response and throw on partial failure so callers don't clear pending changes
try {
const data = await res.json() as { synced?: number; skipped?: number; errors?: Array<{ id: string; name: string; reason: string }> };
if (data.skipped && data.skipped > 0) {
const reasons = data.errors?.map(e => `${e.name}: ${e.reason}`).join('; ') ?? '';
throw new Error(`Sync AI configs partial failure: ${data.skipped} skipped${reasons ? ` (${reasons})` : ''}`);
}
} catch (err) {
// Re-throw our own errors; ignore JSON parse errors from empty responses
if (err instanceof Error && err.message.startsWith('Sync AI configs partial failure')) throw err;
}
}

async fetchAIConfigs(): Promise<AIConfig[]> {
Expand All @@ -361,12 +380,31 @@ class BackendAdapter {
async syncWebDAVConfigs(configs: WebDAVConfig[]): Promise<void> {
if (!this._backendUrl) return;

const res = await this.fetchWithTimeout(`${this._backendUrl}/configs/webdav/bulk`, {
// Pre-sync validation: warn about configs that will likely be skipped
for (const c of configs) {
if (!c.password) {
console.warn(`[sync] WebDAV config "${c.name}" (${c.id}) has empty password, will be skipped if no existing password on backend`);
}
}

const res = await this.fetchWithRetry(`${this._backendUrl}/configs/webdav/bulk`, {
method: 'PUT',
headers: this.getAuthHeaders(),
body: JSON.stringify({ configs })
});
}, 30000, 3);
if (!res.ok) await this.throwTranslatedError(res, 'Sync WebDAV configs error');

// Parse response and throw on partial failure so callers don't clear pending changes
try {
const data = await res.json() as { synced?: number; skipped?: number; errors?: Array<{ id: string; name: string; reason: string }> };
if (data.skipped && data.skipped > 0) {
const reasons = data.errors?.map(e => `${e.name}: ${e.reason}`).join('; ') ?? '';
throw new Error(`Sync WebDAV configs partial failure: ${data.skipped} skipped${reasons ? ` (${reasons})` : ''}`);
}
} catch (err) {
// Re-throw our own errors; ignore JSON parse errors from empty responses
if (err instanceof Error && err.message.startsWith('Sync WebDAV configs partial failure')) throw err;
}
}

async fetchWebDAVConfigs(): Promise<WebDAVConfig[]> {
Expand Down
2 changes: 2 additions & 0 deletions src/utils/backendErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ const ERROR_MESSAGES: Record<string, { zh: string; en: string }> = {
UPDATE_WEBDAV_CONFIG_FAILED: { zh: '更新 WebDAV 配置失败', en: 'Failed to update WebDAV config' },
DELETE_WEBDAV_CONFIG_FAILED: { zh: '删除 WebDAV 配置失败', en: 'Failed to delete WebDAV config' },
SYNC_AI_CONFIGS_FAILED: { zh: '同步 AI 配置失败', en: 'Failed to sync AI configs' },
SYNC_AI_CONFIGS_ALL_SKIPPED: { zh: '同步 AI 配置失败:所有配置均被跳过,请查看错误详情', en: 'Failed to sync AI configs: all configs skipped, see errors for details' },
SYNC_WEBDAV_CONFIGS_FAILED: { zh: '同步 WebDAV 配置失败', en: 'Failed to sync WebDAV configs' },
SYNC_WEBDAV_CONFIGS_ALL_SKIPPED: { zh: '同步 WebDAV 配置失败:所有配置均被跳过,请查看错误详情', en: 'Failed to sync WebDAV configs: all configs skipped, see errors for details' },
INVALID_REQUEST: { zh: '无效的请求', en: 'Invalid request' },
FETCH_SETTINGS_FAILED: { zh: '获取设置失败', en: 'Failed to fetch settings' },
UPDATE_SETTINGS_FAILED: { zh: '更新设置失败', en: 'Failed to update settings' },
Expand Down
Loading