diff --git a/client/src/components/dashboard/BatchCreateDialog.tsx b/client/src/components/dashboard/BatchCreateDialog.tsx index 2019aea..f3c3b9e 100644 --- a/client/src/components/dashboard/BatchCreateDialog.tsx +++ b/client/src/components/dashboard/BatchCreateDialog.tsx @@ -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); } diff --git a/client/src/hooks/useOfflineSync.ts b/client/src/hooks/useOfflineSync.ts index 1906ced..99606ba 100644 --- a/client/src/hooks/useOfflineSync.ts +++ b/client/src/hooks/useOfflineSync.ts @@ -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 || 'Ошибка отправки сообщения'}`); } } diff --git a/server/utils/upload.ts b/server/utils/upload.ts index 8fe3275..f819090 100644 --- a/server/utils/upload.ts +++ b/server/utils/upload.ts @@ -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 }; }