getMessage(); error_log($errorMsg); @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND); return false; } if (!isset($config['s3'])) { $errorMsg = "downloadS3File: S3 config not found in config file"; error_log($errorMsg); @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND); return false; } $s3Config = $config['s3']; @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - S3 config loaded, endpoint: " . ($s3Config['endpoint'] ?? 'NULL') . "\n", FILE_APPEND); // Проверяем наличие обязательных полей if (empty($s3Config['key']) || empty($s3Config['secret']) || empty($s3Config['endpoint'])) { $errorMsg = "downloadS3File: Missing required S3 config fields"; error_log($errorMsg); @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND); return false; } @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Creating S3Client...\n", FILE_APPEND); $awsClient = new \Aws\S3\S3Client([ 'version' => $s3Config['version'], 'region' => $s3Config['region'], 'endpoint' => $s3Config['endpoint'], 'use_path_style_endpoint' => $s3Config['use_path_style_endpoint'], 'credentials' => [ 'key' => $s3Config['key'], 'secret' => $s3Config['secret'], ], ]); @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - S3Client created\n", FILE_APPEND); // Используем bucket из параметра, а не из конфига // Используем только расширение файла для имени временного файла, чтобы избежать "File name too long" $extension = ''; if (!empty($fileName)) { // Декодируем URL-encoded имя файла, если это URL $decodedFileName = urldecode($fileName); // Извлекаем расширение из оригинального s3_key, если filename - это URL if (strpos($decodedFileName, '/') !== false) { // Если filename содержит путь, используем s3_key для расширения $extension = pathinfo($s3Key, PATHINFO_EXTENSION); } else { $extension = pathinfo($decodedFileName, PATHINFO_EXTENSION); } } if (empty($extension) && !empty($s3Key)) { $extension = pathinfo($s3Key, PATHINFO_EXTENSION); } // Создаем короткое имя файла с расширением $tempFileName = uniqid('s3_') . (!empty($extension) ? '.' . $extension : ''); $tempFile = sys_get_temp_dir() . '/' . $tempFileName; error_log("downloadS3File: Temp file path: {$tempFile}"); @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Temp file path: {$tempFile}\n", FILE_APPEND); // Скачиваем файл @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Calling getObject() - Bucket: {$s3Bucket}, Key: {$s3Key}\n", FILE_APPEND); $result = $awsClient->getObject([ 'Bucket' => $s3Bucket, 'Key' => $s3Key, 'SaveAs' => $tempFile ]); error_log("downloadS3File: getObject() completed successfully"); @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - getObject() completed successfully\n", FILE_APPEND); if (!file_exists($tempFile)) { error_log("downloadS3File: File was not created: {$tempFile}"); return false; } $fileSize = filesize($tempFile); if ($fileSize == 0) { error_log("downloadS3File: WARNING - File size is 0 bytes: {$tempFile}"); // Не возвращаем false для пустого файла - возможно, это нормально } error_log("downloadS3File: Success - file size: {$fileSize} bytes"); // Сохраняем путь для последующей очистки self::$tempFiles[] = $tempFile; return $tempFile; } catch (\Aws\Exception\AwsException $e) { $errorMsg = "downloadS3File: AWS Exception - " . $e->getMessage(); $errorMsg .= " | Error Code: " . $e->getAwsErrorCode(); $errorMsg .= " | Request ID: " . $e->getAwsRequestId(); $errorMsg .= " | Bucket: {$s3Bucket} | Key: {$s3Key}"; error_log($errorMsg); @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - AWS EXCEPTION: {$errorMsg}\n", FILE_APPEND); @file_put_contents('/tmp/s3_download_errors.log', date('Y-m-d H:i:s') . ' - ' . $errorMsg . "\n", FILE_APPEND); return false; } catch (Exception $e) { $errorMsg = "downloadS3File: Exception - " . $e->getMessage(); $errorMsg .= " | Bucket: {$s3Bucket} | Key: {$s3Key}"; error_log($errorMsg); error_log("downloadS3File: Stack trace - " . $e->getTraceAsString()); @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - EXCEPTION: {$errorMsg}\n", FILE_APPEND); @file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Stack trace: " . $e->getTraceAsString() . "\n", FILE_APPEND); @file_put_contents('/tmp/s3_download_errors.log', date('Y-m-d H:i:s') . ' - ' . $errorMsg . "\n", FILE_APPEND); return false; } } /** * Очистка временных файлов */ private static function cleanupTempFiles() { foreach (self::$tempFiles as $tempFile) { if (file_exists($tempFile)) { @unlink($tempFile); } } self::$tempFiles = []; } public static function getDocs($record) { $module = 'Documents'; $relation = Vtiger_RelationListView_Model::getInstance( $record, $module ); $pager = new Vtiger_Paging_Model(); $pager->set('limit', 1000); return $relation->getEntries($pager); } /** * Получение документов из связанных сущностей (для проектов) */ public static function getRelatedDocs($projectId) { $adb = PearDatabase::getInstance(); $docs = []; // Получаем информацию о проекте и связанных контрагентах $query = 'SELECT p.linktoaccountscontacts as contactid, pcf.cf_1994 as accountid, pcf.cf_2274 as acc1, pcf.cf_2276 as acc2 FROM vtiger_project p LEFT JOIN vtiger_projectcf pcf ON pcf.projectid = p.projectid LEFT JOIN vtiger_crmentity e ON e.crmid = p.projectid WHERE e.deleted = 0 AND p.projectid = ?'; $result = $adb->pquery($query, array($projectId)); if ($adb->num_rows($result) == 0) { return $docs; } $row = $adb->query_result_rowdata($result, 0); $contactId = $row['contactid']; $accountId = $row['accountid']; $acc1 = $row['acc1']; $acc2 = $row['acc2']; // Собираем ID всех связанных сущностей $relatedIds = array_filter([$projectId, $contactId, $accountId, $acc1, $acc2]); if (empty($relatedIds)) { return $docs; } // Получаем все документы из связанных сущностей $placeholders = str_repeat('?,', count($relatedIds) - 1) . '?'; $query = "SELECT n.notesid, n.title, n.filename, n.filelocationtype, n.s3_bucket, n.s3_key, r.crmid as related_to_id, CASE WHEN r.crmid = ? THEN 'Project' WHEN r.crmid = ? THEN 'Contact' WHEN r.crmid IN (?, ?, ?) THEN 'Account' ELSE 'Unknown' END as source_type FROM vtiger_senotesrel r LEFT JOIN vtiger_notes n ON n.notesid = r.notesid LEFT JOIN vtiger_crmentity e ON e.crmid = r.notesid WHERE r.crmid IN ($placeholders) AND e.deleted = 0 AND n.filename IS NOT NULL ORDER BY r.crmid, n.title"; $params = array_merge([$projectId, $contactId, $accountId, $acc1, $acc2], $relatedIds); $result = $adb->pquery($query, $params); while ($row = $adb->fetchByAssoc($result)) { $docs[] = $row; } return $docs; } public static function getPaths($docs = []) { $archived = 0; $errors = []; $files = []; // Отладочное логирование error_log("========================================"); error_log("getPaths: Processing " . count($docs) . " documents"); foreach ($docs as $x) { // Поддержка как Record Model, так и массива (для связанных документов) if (is_object($x)) { $filename = $x->get('filename'); $filelocationtype = $x->get('filelocationtype'); $title = $x->get('title'); $notesid = $x->getId(); // ВСЕГДА получаем s3_bucket и s3_key напрямую из БД для Record Models, // так как эти поля могут отсутствовать в Record Model $adb = PearDatabase::getInstance(); $dbResult = $adb->pquery( "SELECT s3_bucket, s3_key, filelocationtype FROM vtiger_notes WHERE notesid = ?", array($notesid) ); if ($adb->num_rows($dbResult) > 0) { $dbRow = $adb->fetchByAssoc($dbResult); $s3Bucket = $dbRow['s3_bucket'] ?? null; $s3Key = $dbRow['s3_key'] ?? null; // Используем filelocationtype из БД, если он есть if (!empty($dbRow['filelocationtype'])) { $filelocationtype = $dbRow['filelocationtype']; } } else { $s3Bucket = null; $s3Key = null; } } else { // Массив из getRelatedDocs $filename = $x['filename'] ?? null; $filelocationtype = $x['filelocationtype'] ?? null; $s3Bucket = $x['s3_bucket'] ?? null; $s3Key = $x['s3_key'] ?? null; $title = $x['title'] ?? ''; $notesid = $x['notesid'] ?? null; } $logMsg = "getPaths: Processing doc notesid={$notesid}, filename=" . ($filename ?? 'NULL') . ", filelocationtype=" . ($filelocationtype ?? 'NULL') . ", s3_bucket=" . ($s3Bucket ?? 'NULL') . ", s3_key=" . ($s3Key ?? 'NULL'); error_log($logMsg); // Для S3 файлов filename может быть URL, это нормально // Проверяем только что filename не пустой ИЛИ есть s3_key if (empty($filename) && empty($s3Key)) { $errors[] = 'skip non-file docs (notesid=' . ($notesid ?? 'unknown') . ')'; error_log("getPaths: SKIP - empty filename and s3_key for notesid=" . ($notesid ?? 'unknown')); continue; } // Проверяем условия для S3 $isS3File = ($filelocationtype == 'E' && !empty($s3Bucket) && !empty($s3Key)); error_log("getPaths: CHECK S3 - filelocationtype='{$filelocationtype}' == 'E': " . (($filelocationtype == 'E') ? 'YES' : 'NO') . ", s3Bucket empty: " . (empty($s3Bucket) ? 'YES' : 'NO') . ", s3Key empty: " . (empty($s3Key) ? 'YES' : 'NO') . ", isS3File: " . ($isS3File ? 'YES' : 'NO')); // Проверяем, файл ли это в S3 if ($isS3File) { // Файл в S3 - скачиваем во временную папку // Определяем расширение файла $extension = ''; if (!empty($filename)) { $extension = pathinfo($filename, PATHINFO_EXTENSION); } if (empty($extension) && !empty($s3Key)) { $extension = pathinfo($s3Key, PATHINFO_EXTENSION); } $displayName = !empty($title) ? $title . (!empty($extension) ? '.' . $extension : '') : basename($s3Key); $tempPath = self::downloadS3File($s3Bucket, $s3Key, $displayName); if ($tempPath && file_exists($tempPath)) { $archived++; $files[] = [ 'name' => $displayName, 'path' => $tempPath, 'is_temp' => true ]; } else { $errors[] = "S3 file download failed: {$s3Key}"; } } else { // Локальный файл - используем старую логику // НО: если это массив из getRelatedDocs и у него filelocationtype != 'E', // значит это не S3 файл, но и не локальный (возможно, внешняя ссылка) // Пропускаем такие файлы или пытаемся обработать как локальные if (is_object($x)) { // Record Model - получаем детали файла $details = $x->getFileDetails(); if (empty($details) || empty($details['path'])) { $errors[] = "Cannot get file details for Record Model: {$notesid}"; error_log("getPaths: Cannot get file details for notesid={$notesid}"); continue; } $name = $details['attachmentsid'] . '_' . $details['storedname']; $fullPath = $details['path'] . $name; } else { // Массив из getRelatedDocs - если это не S3, значит локальный файл // Пытаемся создать Record Model для получения пути if (!empty($x['notesid'])) { try { $docRecord = Vtiger_Record_Model::getInstanceById($x['notesid'], 'Documents'); if ($docRecord) { $details = $docRecord->getFileDetails(); if (empty($details) || empty($details['path'])) { $errors[] = "Cannot get file details for document: {$x['notesid']}"; error_log("getPaths: Cannot get file details for notesid={$x['notesid']}"); continue; } $name = $details['attachmentsid'] . '_' . $details['storedname']; $fullPath = $details['path'] . $name; } else { $errors[] = "Cannot create Record Model for document: {$x['notesid']}"; error_log("getPaths: Cannot create Record Model for notesid={$x['notesid']}"); continue; } } catch (Exception $e) { $errors[] = "Error creating Record Model: {$e->getMessage()}"; error_log("getPaths: Exception creating Record Model: " . $e->getMessage()); continue; } } else { $errors[] = "Local file without Record Model and notesid: {$filename}"; error_log("getPaths: Local file without notesid: {$filename}"); continue; } } if (empty($fullPath)) { $errors[] = "Empty file path for notesid: {$notesid}"; error_log("getPaths: Empty file path for notesid={$notesid}"); continue; } if (!file_exists($fullPath)) { $errors[] = "{$fullPath} is missing!"; error_log("getPaths: File not found: {$fullPath}"); continue; } $archived++; $files[] = [ 'name' => $name, 'path' => $fullPath, 'is_temp' => false ]; error_log("getPaths: Added local file: {$name}"); } } $resultMsg = "getPaths: Result - archived={$archived}, files=" . count($files) . ", errors=" . count($errors); error_log($resultMsg); if (count($errors) > 0) { $errorsMsg = "getPaths: Errors: " . implode('; ', array_slice($errors, 0, 10)); error_log($errorsMsg); } return compact( 'files', 'errors', 'archived' ); } public static function createArchive($id) { $record = Vtiger_Record_Model::getInstanceById($id); if (! $record) { return false; } $docs = self::getDocs($record); if (count($docs) == 0) { return false; } $files = self::getPaths($docs); if ($files['archived'] == 0) { self::cleanupTempFiles(); return false; } $ts = date('Ymd_His_') . array_pop(explode('.', microtime(1))); $zipFile = "cache/{$id}_documents_{$ts}.zip"; $zip = new ZipArchive(); $result = $zip->open($zipFile, ZipArchive::CREATE); if (!$result) { self::cleanupTempFiles(); return false; } foreach ($files['files'] as $x) { $zip->addFile($x['path'], $x['name']); } $zip->close(); $size = filesize($zipFile); if ($size == 0) { self::cleanupTempFiles(); return false; } // Очищаем временные файлы после успешного создания архива self::cleanupTempFiles(); return [ 'total' => count($docs), 'archived' => $files['archived'], 'path' => $zipFile, 'size' => $size, 'errors' => $files['errors'], ]; } public static function getArchive($id) { // Логирование через error_log (более надежно) error_log("========================================"); error_log("getArchive: START for project ID={$id}"); try { $record = Vtiger_Record_Model::getInstanceById($id); if (! $record) { error_log("getArchive: Record not found for ID={$id}"); return self::response('Record not found'); } $moduleName = $record->getModuleName(); error_log("getArchive: Module name={$moduleName}"); $allDocs = []; // Получаем документы из самой записи $docs = self::getDocs($record); $docsCount = count($docs); error_log("getArchive: Found {$docsCount} docs from getDocs()"); foreach ($docs as $doc) { $allDocs[] = $doc; } // Для проектов - добавляем документы из связанных сущностей if ($moduleName == 'Project') { error_log("getArchive: Getting related docs for Project"); $relatedDocs = self::getRelatedDocs($id); $relatedCount = count($relatedDocs); error_log("getArchive: Found {$relatedCount} related docs"); // Собираем notesid уже добавленных документов, чтобы избежать дубликатов $addedNotesIds = []; foreach ($allDocs as $doc) { if (is_object($doc)) { $addedNotesIds[] = $doc->getId(); } } // Добавляем только те документы, которых еще нет foreach ($relatedDocs as $relatedDoc) { if (!in_array($relatedDoc['notesid'], $addedNotesIds)) { $allDocs[] = $relatedDoc; $addedNotesIds[] = $relatedDoc['notesid']; } } } $totalDocs = count($allDocs); error_log("getArchive: Total docs to process: {$totalDocs}"); if ($totalDocs == 0) { error_log("getArchive: No documents found, returning error"); return self::response('Record has no documents'); } error_log("getArchive: Calling getPaths() with {$totalDocs} docs"); $files = self::getPaths($allDocs); $archivedCount = $files['archived']; $errorsCount = count($files['errors']); error_log("getArchive: getPaths returned archived={$archivedCount}, errors={$errorsCount}"); // Выводим первые несколько ошибок if ($errorsCount > 0) { $firstErrors = array_slice($files['errors'], 0, 5); error_log("getArchive: First errors: " . implode('; ', $firstErrors)); } if ($files['archived'] == 0) { // Очищаем временные файлы перед выходом self::cleanupTempFiles(); $errorDetails = implode('; ', array_slice($files['errors'], 0, 10)); error_log("getArchive: Nothing to archive - errors: " . $errorDetails); error_log("getArchive: Total docs processed: {$totalDocs}, archived: {$archivedCount}, errors: {$errorsCount}"); // Возвращаем детальную информацию об ошибках для отладки return self::response([ 'message' => 'Nothing to archive', 'total_docs' => $totalDocs, 'archived' => $archivedCount, 'errors_count' => $errorsCount, 'errors' => array_slice($files['errors'], 0, 10) ]); } $ts = date('Ymd_His_') . array_pop(explode('.', microtime(1))); $archive = "{$id}_documents_{$ts}.zip"; $zipFile = "cache/{$archive}"; $zip = new ZipArchive(); $result = $zip->open($zipFile, ZipArchive::CREATE|ZipArchive::OVERWRITE); if (! $result) { self::cleanupTempFiles(); return self::response('Unable to create file'); } foreach ($files['files'] as $x) { $zip->addFile($x['path'], $x['name']); } $result = $zip->close(); if (! $result) { self::cleanupTempFiles(); return self::response('Unable to write file'); } $size = filesize($zipFile); if ($size == 0) { self::cleanupTempFiles(); return self::response('Error creating archive'); } // Очищаем временные файлы после успешного создания архива self::cleanupTempFiles(); header('Content-disposition: attachment; filename='.$archive); header('Content-type: application/zip'); readfile($zipFile); //unlink($zipFile); // Можно оставить для отладки или удалить сразу exit(); } catch (Exception $e) { error_log("getArchive: Exception - " . $e->getMessage()); error_log("getArchive: Stack trace - " . $e->getTraceAsString()); self::cleanupTempFiles(); return self::response('Error: ' . $e->getMessage()); } } public static function response($data) { $response = new Vtiger_Response(); $response->setResult($data); return $response->emit(); } }