fix: бесконечный ретрай upload 400, скобки в именах файлов, тост batch-create

- useOfflineSync: tryUploadFile/trySendAttachmentMessage бросали ошибку без
  кода статуса — постоянная 400 не распознавалась regex \b400\b и вложение
  ретраилось бесконечно (спам POST /api/upload каждые ~30 сек).
- validateFilename: разрешены скобки () в именах (типично для фото с
  телефонов; сохраняемое имя всё равно генерируется), в текст 400 добавлено
  само имя файла для диагностики.
- BatchCreateDialog: тост ошибки показывает текст сервера (из BadRequestError
  или JSON в message 5xx) вместо общего «Ошибка создания задач».
This commit is contained in:
2026-08-03 15:25:11 +03:00
parent 1d5321ed0c
commit 9a387c5db0
3 changed files with 30 additions and 7 deletions

View File

@@ -67,6 +67,22 @@ function getRemainingLockMinutes(startedAt: number): number {
return Math.ceil(remaining / 60_000);
}
// Достаём читабельный текст ошибки: apiRequest бросает BadRequestError/ConflictError
// с текстом сервера в message, а для 5xx message имеет вид "500: {json}".
function extractErrorMessage(e: unknown, fallback: string): string {
if (!(e instanceof Error) || e instanceof TypeError) return fallback;
const m = e.message || '';
const jsonStart = m.indexOf('{');
if (jsonStart >= 0) {
try {
const parsed = JSON.parse(m.slice(jsonStart));
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
} catch { /* не JSON — показываем как есть */ }
}
if (m && m !== 'bad_request' && m !== 'conflict' && m !== 'billing_blocked') return m;
return fallback;
}
export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: BatchCreateDialogProps) {
const { toast } = useToast();
const [selectedType, setSelectedType] = useState('');
@@ -264,7 +280,11 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat
onSuccess?.();
queryClient.invalidateQueries({ queryKey: ['/api/dashboard'] });
} catch (e) {
toast({ title: 'Ошибка создания задач', variant: 'destructive' });
toast({
title: 'Ошибка создания задач',
description: extractErrorMessage(e, 'Проверьте соединение и попробуйте ещё раз'),
variant: 'destructive',
});
} finally {
setCreating(false);
}

View File

@@ -229,7 +229,9 @@ async function tryUploadFile(
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Ошибка загрузки: ${file.name}`);
// Код статуса в сообщении обязателен: вызывающий код отличает постоянные
// ошибки (400/401/403 → не ретраить) от временных по паттерну `\b400\b` и т.п.
throw new Error(`${res.status}: ${err.error || `Ошибка загрузки: ${file.name}`}`);
}
const data = await res.json();
return {
@@ -293,7 +295,7 @@ async function trySendAttachmentMessage(
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка отправки сообщения');
throw new Error(`${res.status}: ${err.error || 'Ошибка отправки сообщения'}`);
}
}

View File

@@ -69,7 +69,7 @@ export function getFileExt(originalname: string): string {
// Validates original filename:
// - extension must be in EXT_TO_MIME (whitelist)
// - name part blocks path traversal, shell metacharacters and control characters
// - Unicode letters (including Cyrillic), digits, spaces, dots, underscores and hyphens are allowed
// - Unicode letters (including Cyrillic), digits, spaces, dots, underscores, hyphens and parentheses are allowed
export function validateFilename(originalname: string): { valid: boolean; reason?: string } {
const basename = path.basename(originalname);
const ext = path.extname(basename).slice(1).toLowerCase();
@@ -81,9 +81,10 @@ export function validateFilename(originalname: string): { valid: boolean; reason
return { valid: false, reason: 'Пустое имя файла' };
}
// Block path traversal, shell metacharacters, and control characters
// Allow Unicode letters (including Cyrillic), digits, spaces, dots, underscores, hyphens
if (/[\/\\&|;$()<>\`\"'\x00-\x1f]/.test(namePart) || namePart.includes('..')) {
return { valid: false, reason: 'Имя файла содержит недопустимые символы' };
// Allow Unicode letters (including Cyrillic), digits, spaces, dots, underscores, hyphens, parentheses
// (скобки безопасны: сохраняемое имя файла генерируется, оригинальное используется только для отображения)
if (/[\/\\&|;$<>\`\"'\x00-\x1f]/.test(namePart) || namePart.includes('..')) {
return { valid: false, reason: `Имя файла содержит недопустимые символы: «${namePart.slice(0, 100)}»` };
}
return { valid: true };
}