diff --git a/.vscode/ftp-kr.diff._tmp_mail_name_check.php b/.vscode/ftp-kr.diff._tmp_mail_name_check.php new file mode 100644 index 0000000..9088476 --- /dev/null +++ b/.vscode/ftp-kr.diff._tmp_mail_name_check.php @@ -0,0 +1 @@ +c:\visual studio code\projekty\crmPRO\_tmp_mail_name_check.php \ No newline at end of file diff --git a/VIDOK_Instrukcja Montażu I Uruchomienia - Wyroby Elektryczne.pdf b/VIDOK_Instrukcja Montażu I Uruchomienia - Wyroby Elektryczne.pdf new file mode 100644 index 0000000..066c4aa Binary files /dev/null and b/VIDOK_Instrukcja Montażu I Uruchomienia - Wyroby Elektryczne.pdf differ diff --git a/autoload/Domain/Tasks/MailToTaskImporter.php b/autoload/Domain/Tasks/MailToTaskImporter.php index 7973888..ce491a2 100644 --- a/autoload/Domain/Tasks/MailToTaskImporter.php +++ b/autoload/Domain/Tasks/MailToTaskImporter.php @@ -204,7 +204,11 @@ class MailToTaskImporter // Zapisz normalne załączniki foreach ( $content['attachments'] as $attachment ) { - $att_name = $attachment['name']; + $att_name = self::normalizeImportedAttachmentName( + isset( $attachment['name'] ) ? $attachment['name'] : '', + isset( $attachment['mime'] ) ? $attachment['mime'] : '', + isset( $attachment['content'] ) ? $attachment['content'] : null + ); $att_mime = isset( $attachment['mime'] ) ? $attachment['mime'] : ''; // Jeśli nazwa nie ma rozszerzenia, dodaj je z MIME type @@ -359,17 +363,82 @@ class MailToTaskImporter if ( $value === '' ) return ''; + if ( !function_exists( 'imap_mime_header_decode' ) ) + return trim( $this -> convertTextToUtf8( $value ) ); + $chunks = @imap_mime_header_decode( $value ); if ( !is_array( $chunks ) or !count( $chunks ) ) - return trim( $value ); + return trim( $this -> convertTextToUtf8( $value ) ); $decoded = ''; foreach ( $chunks as $chunk ) - $decoded .= isset( $chunk -> text ) ? $chunk -> text : ''; + { + $chunk_text = isset( $chunk -> text ) ? (string)$chunk -> text : ''; + $chunk_charset = isset( $chunk -> charset ) ? (string)$chunk -> charset : ''; + $decoded .= $this -> convertTextToUtf8( $chunk_text, $chunk_charset ); + } return trim( $decoded ); } + private function convertTextToUtf8( $text, $source_charset = '' ) + { + $text = (string)$text; + if ( $text === '' ) + return ''; + + if ( preg_match( '//u', $text ) ) + return $text; + + $source_charset = strtoupper( trim( (string)$source_charset ) ); + $aliases = [ + 'DEFAULT' => '', + 'UNKNOWN-8BIT' => '', + 'X-UNKNOWN' => '', + 'UTF8' => 'UTF-8', + 'UTF-8' => 'UTF-8', + 'ISO-8859-2' => 'ISO-8859-2', + 'ISO8859-2' => 'ISO-8859-2', + 'WINDOWS-1250' => 'Windows-1250', + 'CP1250' => 'Windows-1250', + 'WINDOWS-1252' => 'Windows-1252', + 'CP1252' => 'Windows-1252' + ]; + + $candidates = []; + if ( isset( $aliases[$source_charset] ) && $aliases[$source_charset] !== '' ) + $candidates[] = $aliases[$source_charset]; + + $candidates = array_merge( $candidates, [ 'Windows-1250', 'ISO-8859-2', 'Windows-1252', 'ISO-8859-1' ] ); + $candidates = array_values( array_unique( $candidates ) ); + + foreach ( $candidates as $encoding ) + { + if ( function_exists( 'mb_convert_encoding' ) ) + { + $converted = @mb_convert_encoding( $text, 'UTF-8', $encoding ); + if ( is_string( $converted ) && $converted !== '' && preg_match( '//u', $converted ) ) + return $converted; + } + + if ( function_exists( 'iconv' ) ) + { + $converted = @iconv( $encoding, 'UTF-8//IGNORE', $text ); + if ( is_string( $converted ) && $converted !== '' && preg_match( '//u', $converted ) ) + return $converted; + } + } + + if ( function_exists( 'mb_convert_encoding' ) ) + { + $converted = @mb_convert_encoding( $text, 'UTF-8', 'UTF-8' ); + if ( is_string( $converted ) && $converted !== '' ) + return $converted; + } + + return @iconv( 'UTF-8', 'UTF-8//IGNORE', $text ) ?: $text; + } + private function messageKey( $header, $sender, $subject, $message_no ) { $message_id = ''; @@ -434,7 +503,7 @@ class MailToTaskImporter $structure = @imap_fetchstructure( $imap, $message_no ); $parts = []; - $this -> flattenParts( $imap, $message_no, $structure, '', $parts ); + $this -> flattenParts( $imap, $message_no, $structure, '', $parts, [] ); $plain = ''; $html = ''; @@ -472,7 +541,9 @@ class MailToTaskImporter continue; } - if ( !$this -> shouldImportAttachment( $part, $cid_refs ) ) + $should_import = $this -> shouldImportAttachment( $part, $cid_refs ); + + if ( !$should_import ) continue; $attachments[] = [ @@ -493,7 +564,7 @@ class MailToTaskImporter ]; } - private function flattenParts( $imap, $message_no, $structure, $prefix, array &$parts ) + private function flattenParts( $imap, $message_no, $structure, $prefix, array &$parts, array $parent_context = [] ) { if ( !$structure ) { @@ -510,21 +581,24 @@ class MailToTaskImporter if ( !isset( $structure -> parts ) or !is_array( $structure -> parts ) or !count( $structure -> parts ) ) { $part_number = $prefix !== '' ? $prefix : null; - $parts[] = $this -> parseSinglePart( $imap, $message_no, $structure, $part_number ); + $parts[] = $this -> parseSinglePart( $imap, $message_no, $structure, $part_number, $parent_context ); return; } + $current_context = $this -> mergePartContexts( $parent_context, $this -> extractPartContext( $structure ) ); + foreach ( $structure -> parts as $index => $subpart ) { $part_number = $prefix === '' ? (string)( $index + 1 ) : $prefix . '.' . ( $index + 1 ); + $child_context = $this -> mergePartContexts( $current_context, $this -> extractPartContext( $subpart ) ); if ( isset( $subpart -> parts ) and is_array( $subpart -> parts ) and count( $subpart -> parts ) ) - $this -> flattenParts( $imap, $message_no, $subpart, $part_number, $parts ); + $this -> flattenParts( $imap, $message_no, $subpart, $part_number, $parts, $child_context ); else - $parts[] = $this -> parseSinglePart( $imap, $message_no, $subpart, $part_number ); + $parts[] = $this -> parseSinglePart( $imap, $message_no, $subpart, $part_number, $child_context ); } } - private function parseSinglePart( $imap, $message_no, $part, $part_number ) + private function parseSinglePart( $imap, $message_no, $part, $part_number, array $context = [] ) { $raw = ''; if ( $part_number === null ) @@ -539,31 +613,44 @@ class MailToTaskImporter $params = $this -> partParams( $part ); - $name = ''; - if ( isset( $params['filename'] ) ) - $name = $params['filename']; - elseif ( isset( $params['name'] ) ) - $name = $params['name']; + $filename_param = isset( $params['filename'] ) ? $this -> decodeHeaderValue( $params['filename'] ) : ''; + $name_param = isset( $params['name'] ) ? $this -> decodeHeaderValue( $params['name'] ) : ''; + $description_param = isset( $params['description'] ) ? $this -> decodeHeaderValue( $params['description'] ) : ''; + $name = $this -> pickBetterAttachmentName( $filename_param, $name_param ); + $name = $this -> pickBetterAttachmentName( $name, $description_param ); - $name = $this -> decodeHeaderValue( $name ); + if ( isset( $part -> description ) && trim( (string)$part -> description ) !== '' ) + $name = $this -> pickBetterAttachmentName( $name, $this -> decodeHeaderValue( (string)$part -> description ) ); - // Fallback: jeśli brak nazwy lub brak rozszerzenia, spróbuj z surowych nagłówków MIME - if ( $part_number !== null && ( trim( $name ) === '' || pathinfo( $name, PATHINFO_EXTENSION ) === '' ) ) + if ( isset( $context['name'] ) && trim( (string)$context['name'] ) !== '' ) + $name = $this -> pickBetterAttachmentName( $name, (string)$context['name'] ); + + // Fallback: jeśli brak nazwy, brak rozszerzenia lub nazwa techniczna, spróbuj z surowych nagłówków MIME + if ( $part_number !== null && ( trim( $name ) === '' || pathinfo( $name, PATHINFO_EXTENSION ) === '' || self::isTechnicalAttachmentName( $name ) ) ) { $mime_name = $this -> extractNameFromRawMime( $imap, $message_no, $part_number ); if ( $mime_name !== '' ) $name = $mime_name; } + $name = self::normalizeImportedAttachmentName( $name, $mime, $decoded ); + $disposition = isset( $part -> disposition ) ? strtoupper( trim( (string)$part -> disposition ) ) : ''; + if ( $disposition === '' && isset( $context['disposition'] ) ) + $disposition = strtoupper( trim( (string)$context['disposition'] ) ); + $content_id = ''; if ( isset( $part -> id ) and trim( (string)$part -> id ) !== '' ) $content_id = $this -> normalizeContentId( (string)$part -> id ); elseif ( isset( $params['content-id'] ) and trim( (string)$params['content-id'] ) !== '' ) $content_id = $this -> normalizeContentId( (string)$params['content-id'] ); + elseif ( isset( $context['content_id'] ) ) + $content_id = $this -> normalizeContentId( (string)$context['content_id'] ); $is_inline = $disposition === 'INLINE' or $content_id !== ''; - $is_attachment = trim( $name ) !== '' or $disposition === 'ATTACHMENT'; + $is_attachment = trim( $name ) !== '' + or $disposition === 'ATTACHMENT' + or $this -> isLikelyBinaryAttachmentWithoutName( $mime, $decoded, $disposition, $content_id ); return [ 'mime' => $mime, @@ -641,10 +728,22 @@ class MailToTaskImporter private function extractNameFromRawMime( $imap, $message_no, $part_number ) { - $headers = (string)@imap_fetchmime( $imap, $message_no, $part_number, FT_PEEK ); + $headers = $this -> fetchRawMimeHeaders( $imap, $message_no, $part_number ); + if ( trim( $headers ) === '' ) return ''; + return $this -> extractNameFromMimeHeaders( $headers ); + } + + private function extractNameFromMimeHeaders( $headers ) + { + $headers = (string)$headers; + if ( trim( $headers ) === '' ) + return ''; + + $best_name = ''; + // Szukaj filename w Content-Disposition, potem name w Content-Type foreach ( [ 'filename', 'name' ] as $param_name ) { @@ -667,8 +766,8 @@ class MailToTaskImporter $decoded = @mb_convert_encoding( $decoded, 'UTF-8', $charset ); $combined = $decoded; } - if ( trim( $combined ) !== '' && pathinfo( $combined, PATHINFO_EXTENSION ) !== '' ) - return trim( $combined ); + if ( trim( $combined ) !== '' ) + $best_name = $this -> pickBetterAttachmentName( $best_name, trim( $combined ) ); } } @@ -684,27 +783,109 @@ class MailToTaskImporter $decoded = @mb_convert_encoding( $decoded, 'UTF-8', $charset ); $val = $decoded; } - if ( trim( $val ) !== '' && pathinfo( $val, PATHINFO_EXTENSION ) !== '' ) - return trim( $val ); + if ( trim( $val ) !== '' ) + $best_name = $this -> pickBetterAttachmentName( $best_name, trim( $val ) ); } // Standardowy: param="value" lub param=value if ( preg_match( '/' . preg_quote( $param_name, '/' ) . '\s*=\s*"([^"]+)"/i', $headers, $m ) ) { $val = $this -> decodeHeaderValue( trim( $m[1] ) ); - if ( trim( $val ) !== '' && pathinfo( $val, PATHINFO_EXTENSION ) !== '' ) - return trim( $val ); + if ( trim( $val ) !== '' ) + $best_name = $this -> pickBetterAttachmentName( $best_name, trim( $val ) ); } if ( preg_match( '/' . preg_quote( $param_name, '/' ) . '\s*=\s*([^\s;]+)/i', $headers, $m ) ) { $val = $this -> decodeHeaderValue( trim( $m[1], " \t\"'" ) ); - if ( trim( $val ) !== '' && pathinfo( $val, PATHINFO_EXTENSION ) !== '' ) - return trim( $val ); + if ( trim( $val ) !== '' ) + $best_name = $this -> pickBetterAttachmentName( $best_name, trim( $val ) ); + } + + // Wartość bez cudzysłowów, ale ze spacjami aż do końca linii lub średnika + if ( preg_match( '/' . preg_quote( $param_name, '/' ) . '\s*=\s*([^;\r\n]+)/i', $headers, $m ) ) + { + $val = $this -> decodeHeaderValue( trim( $m[1], " \t\"'" ) ); + if ( trim( $val ) !== '' ) + $best_name = $this -> pickBetterAttachmentName( $best_name, trim( $val ) ); } } - return ''; + if ( preg_match( '/^\s*content-description\s*:\s*(.+)$/im', $headers, $m ) ) + { + $val = $this -> decodeHeaderValue( trim( $m[1] ) ); + if ( trim( $val ) !== '' ) + $best_name = $this -> pickBetterAttachmentName( $best_name, trim( $val ) ); + } + + return trim( $best_name ); + } + + public function debugExtractNameFromMimeHeaders( $headers ) + { + return $this -> extractNameFromMimeHeaders( $headers ); + } + + private function extractPartContext( $part ) + { + if ( !$part ) + return []; + + $params = $this -> partParams( $part ); + $filename_param = isset( $params['filename'] ) ? $this -> decodeHeaderValue( $params['filename'] ) : ''; + $name_param = isset( $params['name'] ) ? $this -> decodeHeaderValue( $params['name'] ) : ''; + $description_param = isset( $params['description'] ) ? $this -> decodeHeaderValue( $params['description'] ) : ''; + + $name = $this -> pickBetterAttachmentName( $filename_param, $name_param ); + $name = $this -> pickBetterAttachmentName( $name, $description_param ); + if ( isset( $part -> description ) && trim( (string)$part -> description ) !== '' ) + $name = $this -> pickBetterAttachmentName( $name, $this -> decodeHeaderValue( (string)$part -> description ) ); + $disposition = isset( $part -> disposition ) ? strtoupper( trim( (string)$part -> disposition ) ) : ''; + + $content_id = ''; + if ( isset( $part -> id ) and trim( (string)$part -> id ) !== '' ) + $content_id = $this -> normalizeContentId( (string)$part -> id ); + elseif ( isset( $params['content-id'] ) and trim( (string)$params['content-id'] ) !== '' ) + $content_id = $this -> normalizeContentId( (string)$params['content-id'] ); + + return [ + 'name' => $name, + 'disposition' => $disposition, + 'content_id' => $content_id + ]; + } + + private function mergePartContexts( array $parent_context, array $child_context ) + { + $merged = $parent_context; + + $parent_name = isset( $parent_context['name'] ) ? (string)$parent_context['name'] : ''; + $child_name = isset( $child_context['name'] ) ? (string)$child_context['name'] : ''; + $merged['name'] = $this -> pickBetterAttachmentName( $parent_name, $child_name ); + + if ( isset( $child_context['disposition'] ) && trim( (string)$child_context['disposition'] ) !== '' ) + $merged['disposition'] = (string)$child_context['disposition']; + elseif ( isset( $parent_context['disposition'] ) ) + $merged['disposition'] = (string)$parent_context['disposition']; + + if ( isset( $child_context['content_id'] ) && trim( (string)$child_context['content_id'] ) !== '' ) + $merged['content_id'] = (string)$child_context['content_id']; + elseif ( isset( $parent_context['content_id'] ) ) + $merged['content_id'] = (string)$parent_context['content_id']; + + return $merged; + } + + private function fetchRawMimeHeaders( $imap, $message_no, $part_number ) + { + if ( $part_number === null ) + return ''; + + $headers = (string)@imap_fetchmime( $imap, $message_no, $part_number, FT_PEEK ); + if ( trim( $headers ) === '' ) + $headers = (string)@imap_fetchbody( $imap, $message_no, $part_number . '.MIME', FT_PEEK ); + + return (string)$headers; } private function decodePartBody( $raw, $encoding ) @@ -838,12 +1019,10 @@ class MailToTaskImporter private function shouldImportAttachment( array $part, array $cid_refs ) { $name = trim( isset( $part['name'] ) ? (string)$part['name'] : '' ); - if ( $name === '' ) - return false; - $mime = strtolower( isset( $part['mime'] ) ? (string)$part['mime'] : '' ); $disposition = strtoupper( isset( $part['disposition'] ) ? (string)$part['disposition'] : '' ); $content_id = $this -> normalizeContentId( isset( $part['content_id'] ) ? (string)$part['content_id'] : '' ); + $body = isset( $part['body'] ) ? (string)$part['body'] : ''; if ( !empty( $part['is_inline'] ) ) return false; @@ -857,7 +1036,51 @@ class MailToTaskImporter if ( strpos( $mime, 'image/' ) === 0 and $disposition !== 'ATTACHMENT' ) return false; - return true; + if ( $name !== '' ) + return true; + + if ( $disposition === 'ATTACHMENT' ) + return true; + + if ( $mime === 'application/pdf' or $mime === 'application/x-pdf' ) + return true; + + if ( $mime === 'application/octet-stream' and self::contentLooksLikePdf( $body ) ) + return true; + + return false; + } + + private function isLikelyBinaryAttachmentWithoutName( $mime, $content, $disposition, $content_id ) + { + $mime = strtolower( trim( (string)$mime ) ); + $disposition = strtoupper( trim( (string)$disposition ) ); + $content_id = trim( (string)$content_id ); + + if ( strpos( $mime, 'text/' ) === 0 ) + return false; + + if ( $content_id !== '' ) + return false; + + if ( strpos( $mime, 'image/' ) === 0 && $disposition !== 'ATTACHMENT' ) + return false; + + if ( $mime === 'application/pdf' || $mime === 'application/x-pdf' ) + return true; + + if ( $mime === 'application/octet-stream' && self::contentLooksLikePdf( $content ) ) + return true; + + return false; + } + + private static function contentLooksLikePdf( $content ) + { + if ( !is_string( $content ) || $content === '' ) + return false; + + return strpos( substr( $content, 0, 1024 ), '%PDF-' ) !== false; } private function extractReferencedCidValues( $html ) @@ -1213,6 +1436,136 @@ class MailToTaskImporter return isset( $map[$mime] ) ? $map[$mime] : ''; } + private function pickBetterAttachmentName( $primary, $secondary ) + { + $primary = trim( (string)$primary ); + $secondary = trim( (string)$secondary ); + + if ( $primary === '' ) + return $secondary; + + if ( $secondary === '' ) + return $primary; + + $primary_score = self::attachmentNameScore( $primary ); + $secondary_score = self::attachmentNameScore( $secondary ); + + return $secondary_score > $primary_score ? $secondary : $primary; + } + + public static function normalizeImportedAttachmentName( $name, $mime = '', $content = null ) + { + $name = trim( (string)$name ); + $technical_ext = ''; + + if ( self::isTechnicalAttachmentName( $name ) ) + { + $technical_ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ); + $name = ''; + } + + if ( $name === '' ) + $name = 'zalacznik'; + + $ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ); + if ( $ext === '' ) + { + $mime_ext = self::extensionFromMimeStatic( $mime ); + if ( $mime_ext === '' && $technical_ext !== '' ) + $mime_ext = $technical_ext; + if ( $mime_ext === '' ) + $mime_ext = self::extensionFromContent( $content ); + if ( $mime_ext !== '' ) + $name .= '.' . $mime_ext; + } + + return $name; + } + + private static function attachmentNameScore( $name ) + { + $name = trim( (string)$name ); + if ( $name === '' ) + return -1; + + $score = 1; + if ( pathinfo( $name, PATHINFO_EXTENSION ) !== '' ) + $score += 2; + + if ( self::isTechnicalAttachmentName( $name ) ) + $score -= 3; + + return $score; + } + + private static function isTechnicalAttachmentName( $name ) + { + $name = trim( (string)$name ); + if ( $name === '' ) + return false; + + return (bool)preg_match( '/^att_[a-f0-9]{8,}(?:\.[a-z0-9]{2,6})?$/i', $name ); + } + + private static function extensionFromMimeStatic( $mime ) + { + $map = [ + 'application/pdf' => 'pdf', + 'application/zip' => 'zip', + 'application/x-rar-compressed' => 'rar', + 'application/vnd.rar' => 'rar', + 'application/x-7z-compressed' => '7z', + 'application/msword' => 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/vnd.ms-excel' => 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'application/vnd.ms-powerpoint' => 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', + 'application/xml' => 'xml', + 'application/json' => 'json', + 'application/rtf' => 'rtf', + 'application/octet-stream' => '', + 'text/plain' => 'txt', + 'text/html' => 'html', + 'text/csv' => 'csv', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/bmp' => 'bmp', + 'image/svg+xml' => 'svg', + 'image/tiff' => 'tiff' + ]; + + $mime = strtolower( trim( (string)$mime ) ); + return isset( $map[$mime] ) ? $map[$mime] : ''; + } + + private static function extensionFromContent( $content ) + { + if ( !is_string( $content ) || $content === '' ) + return ''; + + $prefix = substr( $content, 0, 16 ); + + if ( substr( $prefix, 0, 4 ) === '%PDF' ) + return 'pdf'; + + if ( substr( $prefix, 0, 2 ) === 'PK' ) + return 'zip'; + + if ( substr( $prefix, 0, 3 ) === "\xFF\xD8\xFF" ) + return 'jpg'; + + if ( substr( $prefix, 0, 8 ) === "\x89PNG\r\n\x1A\n" ) + return 'png'; + + if ( substr( $prefix, 0, 6 ) === 'GIF87a' || substr( $prefix, 0, 6 ) === 'GIF89a' ) + return 'gif'; + + return ''; + } + private function replaceCidReferences( $html, array $cid_to_url ) { if ( trim( (string)$html ) === '' || empty( $cid_to_url ) ) diff --git a/autoload/Domain/Tasks/TaskAttachmentRepository.php b/autoload/Domain/Tasks/TaskAttachmentRepository.php index 0bdf184..eeefcf1 100644 --- a/autoload/Domain/Tasks/TaskAttachmentRepository.php +++ b/autoload/Domain/Tasks/TaskAttachmentRepository.php @@ -35,7 +35,12 @@ class TaskAttachmentRepository foreach ( $rows as &$row ) { - $row['title_effective'] = self::effectiveTitle( $row['title'], $row['original_name'] ); + $row['title_effective'] = self::effectiveTitleWithExtension( + isset( $row['title'] ) ? $row['title'] : null, + isset( $row['original_name'] ) ? $row['original_name'] : '', + isset( $row['file_ext'] ) ? $row['file_ext'] : '', + isset( $row['stored_name'] ) ? $row['stored_name'] : '' + ); $row['url'] = $this -> buildPublicUrl( $row['relative_path'], $row['stored_name'] ); $row['size_human'] = $this -> formatSize( (int)$row['file_size'] ); } @@ -59,6 +64,15 @@ class TaskAttachmentRepository $safe_original_name = self::sanitizeFileName( $original_name ); $ext = strtolower( pathinfo( $safe_original_name, PATHINFO_EXTENSION ) ); + if ( $ext === '' && isset( $file['tmp_name'] ) && is_string( $file['tmp_name'] ) && file_exists( $file['tmp_name'] ) ) + { + $detected_ext = self::detectExtensionFromContent( @file_get_contents( $file['tmp_name'] ) ); + if ( $detected_ext !== '' ) + { + $ext = $detected_ext; + $safe_original_name .= '.' . $ext; + } + } $stored_name = uniqid( 'att_', true ) . ( $ext ? '.' . $ext : '' ); $relative_path = date( 'Y' ) . '/' . date( 'm' ); @@ -83,6 +97,15 @@ class TaskAttachmentRepository $safe_original_name = self::sanitizeFileName( $original_name ); $ext = strtolower( pathinfo( $safe_original_name, PATHINFO_EXTENSION ) ); + if ( $ext === '' ) + { + $detected_ext = self::detectExtensionFromContent( (string)$content ); + if ( $detected_ext !== '' ) + { + $ext = $detected_ext; + $safe_original_name .= '.' . $ext; + } + } $stored_name = uniqid( 'att_', true ) . ( $ext ? '.' . $ext : '' ); $relative_path = date( 'Y' ) . '/' . date( 'm' ); @@ -162,14 +185,89 @@ class TaskAttachmentRepository return $title !== '' ? $title : (string)$fallback; } + public static function effectiveTitleWithExtension( $title, $fallback, $file_ext = '', $stored_name = '' ) + { + $title = trim( (string)$title ); + $fallback = trim( (string)$fallback ); + + if ( self::isGenericAttachmentTitle( $title ) && $fallback !== '' && !self::isGenericAttachmentTitle( $fallback ) ) + $effective = $fallback; + else + $effective = self::effectiveTitle( $title, $fallback ); + + if ( pathinfo( $effective, PATHINFO_EXTENSION ) !== '' ) + return $effective; + + $file_ext = strtolower( trim( (string)$file_ext ) ); + if ( $file_ext === '' ) + $file_ext = strtolower( pathinfo( (string)$stored_name, PATHINFO_EXTENSION ) ); + + if ( $file_ext !== '' ) + return $effective . '.' . $file_ext; + + return $effective; + } + + private static function isGenericAttachmentTitle( $name ) + { + $name = strtolower( trim( (string)$name ) ); + if ( $name === '' ) + return false; + + return (bool)preg_match( '/^zalacznik(?:\.[a-z0-9]{2,6})?$/i', $name ); + } + public static function sanitizeFileName( $name ) { + $name = (string)$name; + if ( $name !== '' && !preg_match( '//u', $name ) ) + { + if ( function_exists( 'mb_convert_encoding' ) ) + { + $converted = @mb_convert_encoding( $name, 'UTF-8', 'Windows-1250,ISO-8859-2,Windows-1252,ISO-8859-1' ); + if ( is_string( $converted ) && $converted !== '' ) + $name = $converted; + } + + if ( !preg_match( '//u', $name ) && function_exists( 'iconv' ) ) + { + $converted = @iconv( 'Windows-1250', 'UTF-8//IGNORE', $name ); + if ( is_string( $converted ) && $converted !== '' ) + $name = $converted; + } + } + $name = preg_replace( '/[^\p{L}\p{N}\s\.\-_]+/u', '_', (string)$name ); $name = preg_replace( '/\s+/', '_', $name ); $name = trim( $name, '._-' ); return $name !== '' ? $name : 'zalacznik'; } + public static function detectExtensionFromContent( $content ) + { + if ( !is_string( $content ) || $content === '' ) + return ''; + + $sample = substr( $content, 0, 4096 ); + + if ( strpos( $sample, '%PDF-' ) !== false ) + return 'pdf'; + + if ( substr( $sample, 0, 8 ) === "\x89PNG\r\n\x1A\n" ) + return 'png'; + + if ( substr( $sample, 0, 3 ) === "\xFF\xD8\xFF" ) + return 'jpg'; + + if ( substr( $sample, 0, 6 ) === 'GIF87a' || substr( $sample, 0, 6 ) === 'GIF89a' ) + return 'gif'; + + if ( substr( $sample, 0, 2 ) === 'PK' ) + return 'zip'; + + return ''; + } + private function ensureStorage() { if ( !is_dir( $this -> upload_dir ) ) diff --git a/tests/Domain/Tasks/MailToTaskImporterTest.php b/tests/Domain/Tasks/MailToTaskImporterTest.php new file mode 100644 index 0000000..a7e6638 --- /dev/null +++ b/tests/Domain/Tasks/MailToTaskImporterTest.php @@ -0,0 +1,85 @@ + debugExtractNameFromMimeHeaders( $raw_headers ); + assert_true( + $parsed_name === 'VIDOK_Instrukcja Montażu I Uruchomienia - Wyroby Elektryczne.pdf', + 'Expected parser to keep full unquoted file name with spaces and prefer it over technical att_* name.' + ); + + $raw_headers_with_description = "Content-Type: application/octet-stream\r\n" + . "Content-Disposition: attachment; filename=att_6995b8ad9d4567\r\n" + . "Content-Description: VIDOK_Instrukcja Montażu I Uruchomienia - Wyroby Elektryczne.pdf\r\n"; + + $parsed_name_from_description = $importer -> debugExtractNameFromMimeHeaders( $raw_headers_with_description ); + assert_true( + $parsed_name_from_description === 'VIDOK_Instrukcja Montażu I Uruchomienia - Wyroby Elektryczne.pdf', + 'Expected parser to use Content-Description when filename is only technical att_*.' + ); + + $normalized_octet_pdf = MailToTaskImporter::normalizeImportedAttachmentName( + '', + 'application/octet-stream', + "%PDF-1.6\n..." + ); + assert_true( + $normalized_octet_pdf === 'zalacznik.pdf', + 'Expected unnamed octet-stream with PDF signature to get pdf extension.' + ); +} diff --git a/tests/Domain/Tasks/TaskAttachmentRepositoryTest.php b/tests/Domain/Tasks/TaskAttachmentRepositoryTest.php index 2dd1b00..7b0e21f 100644 --- a/tests/Domain/Tasks/TaskAttachmentRepositoryTest.php +++ b/tests/Domain/Tasks/TaskAttachmentRepositoryTest.php @@ -25,4 +25,24 @@ function run_task_attachment_repository_tests() && substr( $sanitized, -4 ) === '.pdf', 'Expected sanitized file name to remove unsupported characters and keep extension.' ); + + assert_true( + TaskAttachmentRepository::detectExtensionFromContent( "%PDF-1.7\nexample" ) === 'pdf', + 'Expected PDF extension to be detected from binary content.' + ); + + assert_true( + TaskAttachmentRepository::effectiveTitleWithExtension( '', 'zalacznik', 'pdf', '' ) === 'zalacznik.pdf', + 'Expected display title fallback to include file extension.' + ); + + assert_true( + TaskAttachmentRepository::effectiveTitleWithExtension( + 'zalacznik.pdf', + 'VIDOK_Instrukcja Montażu I Uruchomienia - Wyroby Elektryczne.pdf', + 'pdf', + '' + ) === 'VIDOK_Instrukcja Montażu I Uruchomienia - Wyroby Elektryczne.pdf', + 'Expected generic title to be ignored when original file name is available.' + ); } diff --git a/tests/run.php b/tests/run.php index e0a7937..3f8d6f1 100644 --- a/tests/run.php +++ b/tests/run.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/Domain/Tasks/WorkTimeRepositoryTest.php'; require_once __DIR__ . '/Domain/Tasks/TaskAttachmentRepositoryTest.php'; +require_once __DIR__ . '/Domain/Tasks/MailToTaskImporterTest.php'; require_once __DIR__ . '/Domain/Users/UserRepositoryTest.php'; require_once __DIR__ . '/Controllers/TasksControllerTest.php'; require_once __DIR__ . '/Controllers/UsersControllerTest.php'; @@ -9,6 +10,7 @@ require_once __DIR__ . '/Controllers/UsersControllerTest.php'; $tests = [ 'run_work_time_repository_tests', 'run_task_attachment_repository_tests', + 'run_mail_to_task_importer_tests', 'run_user_repository_tests', 'run_tasks_controller_tests', 'run_users_controller_tests'