Fix parsing of messages containing UUEncoded data.
[banana.git] / banana / mimepart.inc.php
index 9f039eb..577af46 100644 (file)
@@ -19,15 +19,20 @@ class BananaMimePart
     private $boundary     = null;
     private $filename     = null;
     private $format       = null;
+    private $signature    = array();
 
     private $body         = null;
     private $multipart    = null;
 
     protected function __construct($data = null)
     {
-        if (!is_null($data)) {
+        if ($data instanceof BananaMimePart) {
+            foreach ($this as $key=>$value) {
+                $this->$key = $data->$key;
+            }
+        } elseif (!is_null($data)) {
             $this->fromRaw($data);
-        }   
+        }
     }
 
     protected function makeTextPart($body, $content_type, $encoding, $charset = null, $format = 'fixed')
@@ -50,11 +55,11 @@ class BananaMimePart
         $this->id           = $id;
         if (is_null($content_type) || $content_type == 'application/octet-stream') {
             $this->decodeContent();
-            $this->content_type = BananaMimePart::getMimeType($body, false);
-        }   
+            $this->content_type = BananaMimePart::getMimeType($this->body, false);
+        }
     }
 
-    protected function makeFilePart($file, $content_type =null, $disposition = 'attachment')
+    protected function makeFilePart(array $file, $content_type = null, $disposition = 'attachment')
     {
         $body = file_get_contents($file['tmp_name']);
         if ($body === false || strlen($body) != $file['size']) {
@@ -77,19 +82,20 @@ class BananaMimePart
         return true;
     }
 
-    protected function makeMultiPart($body, $content_type, $encoding, $boundary)
+    protected function makeMultiPart($body, $content_type, $encoding, $boundary, $sign_protocole)
     {
         $this->body         = $body;
         $this->content_type = $content_type;
         $this->encoding     = $encoding;
         $this->boundary     = $boundary;
+        $this->signature['protocole'] = $sign_protocole;
         $this->parse();
     }
 
     protected function convertToMultiPart()
     {
         if (!$this->isType('multipart', 'mixed')) {
-            $newpart = $this;
+            $newpart = new BananaMimePart($this);
             $this->content_type = 'multipart/mixed';
             $this->encoding     = '8bit';
             $this->multipart    = array($newpart);
@@ -106,10 +112,10 @@ class BananaMimePart
 
     public function addAttachment(array $file, $content_type = null, $disposition = 'attachment')
     {
-        $newpart = new BananaMimePart;
         if (!is_uploaded_file($file['tmp_name'])) {
             return false;
         }
+        $newpart = new BananaMimePart;
         if ($newpart->makeFilePart($file, $content_type, $disposition)) {
             $this->convertToMultiPart();
             $this->multipart[] = $newpart;
@@ -160,26 +166,27 @@ class BananaMimePart
             $encoding     = '8bit';
             $charset      = 'CP1252';
             $content_type = 'text/plain';
-            $format       = strtolower($this->getHeader('x-rfc2646', '/format="?([^"]+?)"?\s*(;|$)/i'));
+            $format       = strtolower($this->getHeader('x-rfc2646', '/format="?([^ w@"]+?)"?\s*(;|$)/i'));
         } else {
             $encoding     = strtolower($this->getHeader('content-transfer-encoding'));
             $disposition  = $this->getHeader('content-disposition', '/(inline|attachment)/i');
-            $boundary     = $this->getHeader('content-type', '/boundary="?([^"]+?)"?\s*(;|$)/i');
-            $charset      = strtolower($this->getHeader('content-type', '/charset="?([^"]+?)"?\s*(;|$)/i'));
-            $filename     = $this->getHeader('content-disposition', '/filename="?([^"]+?)"?\s*(;|$)/i');
-            $format       = strtolower($this->getHeader('content-type', '/format="?([^"]+?)"?\s*(;|$)/i'));
+            $boundary     = $this->getHeader('content-type', '/boundary="?([^ "]+?)"?\s*(;|$)/i');
+            $charset      = strtolower($this->getHeader('content-type', '/charset="?([^ "]+?)"?\s*(;|$)/i'));
+            $filename     = $this->getHeader('content-disposition', '/filename="?([^ "]+?)"?\s*(;|$)/i');
+            $format       = strtolower($this->getHeader('content-type', '/format="?([^ "]+?)"?\s*(;|$)/i'));
             $id           = $this->getHeader('content-id', '/<(.*?)>/');
+            $sign_protocole = strtolower($this->getHeader('content-type', '/protocol="?([^ "]+?)"?\s*(;|$)/i'));
             if (empty($filename)) {
                 $filename = $this->getHeader('content-type', '/name="?([^"]+)"?/');
             }
-        } 
+        }
         list($type, $subtype) = explode('/', $content_type);
         switch ($type) {
           case 'text': case 'message':
             $this->makeTextPart($content, $content_type, $encoding, $charset, $format);
             break;
           case 'multipart':
-            $this->makeMultiPart($content, $content_type, $encoding, $boundary);
+            $this->makeMultiPart($content, $content_type, $encoding, $boundary, $sign_protocole);
             break;
           default:
             $this->makeDataPart($content, $content_type, $encoding, $filename, $disposition, $id);
@@ -194,7 +201,14 @@ class BananaMimePart
             $parts = $this->findUUEncoded();
             if (count($parts)) {
                 $this->convertToMultiPart();
-                $this->multipart    = array_merge(array($textpart), $parts);
+                $this->multipart = array_merge($this->multipart, $parts);
+                // Restore "message" headers to the previous level"
+                $this->headers = array();
+                foreach (Banana::$msgshow_headers as $hdr) {
+                    if (isset($this->multipart[0]->headers[$hdr])) {
+                        $this->headers[$hdr] = $this->multipart[0]->headers[$hdr];
+                    }
+                }
             }
         }
     }
@@ -206,20 +220,34 @@ class BananaMimePart
             $this->multipart = array();
         }
         $boundary =& $this->boundary;
-        $parts = preg_split("/\n--" . preg_quote($boundary, '/') . "(--|\n)/", $this->body, -1, PREG_SPLIT_NO_EMPTY);
+        $parts = preg_split("/(^|\n)--" . preg_quote($boundary, '/') . "(--|\n)/", $this->body, -1, PREG_SPLIT_NO_EMPTY);
+        $signed = $this->isType('multipart', 'signed');
+        $signature = null;
+        $signed_message = null;
         foreach ($parts as &$part) {
             $newpart = new BananaMimePart($part);
             if (!is_null($newpart->content_type)) {
+                if ($signed && $newpart->content_type == $this->signature['protocole']) { 
+                    $signature = $newpart->body;
+                } elseif ($signed) { 
+                    $signed_message = $part; 
+                } 
                 $this->multipart[] = $newpart;
             }
         }
+        if ($signed) {
+            $this->checkPGPSignature($signature, $signed_message);
+        }
         $this->body = null;
     }
 
     public static function getMimeType($data, $is_filename = true)
     {
         if ($is_filename) {
-            $type = mime_content_type($arg);
+            $type = mime_content_type($data);
+            if ($type == 'text/plain') { // XXX Workaround a bug of php 5.2.0+etch10 (fallback for mime_content_type is text/plain)
+                $type = preg_replace('/;.*/', '', trim(shell_exec('file -bi ' . escapeshellarg($data))));
+            }
         } else {
             $arg = escapeshellarg($data);
             $type = preg_replace('/;.*/', '', trim(shell_exec("echo $arg | file -bi -")));
@@ -239,11 +267,12 @@ class BananaMimePart
                 if ($mime != 'application/x-empty') {
                     $this->body = trim(str_replace($match[0], '', $this->body));
                     $newpart = new BananaMimePart;
+                    self::decodeHeader($match[2]);
                     $newpart->makeDataPart($data, $mime, '8bit', $match[2], 'attachment');
                     $parts[] = $newpart;
                 }
-            }   
-        } 
+            }
+        }
         return $parts;
     }
 
@@ -254,31 +283,31 @@ class BananaMimePart
         return str_replace('_', ' ', $s);
     }
 
-    static public function decodeHeader(&$val, $key)
+    static public function decodeHeader(&$val, $key = null)
     {
         if (preg_match('/[\x80-\xff]/', $val)) {
             if (!is_utf8($val)) {
                 $val = utf8_encode($val);
             }
-        } else {
+        } elseif (strpos($val, '=') !== false) {
             $val = preg_replace('/(=\?.*?\?[bq]\?.*?\?=) (=\?.*?\?[bq]\?.*?\?=)/i', '\1\2', $val);
             $val = preg_replace('/=\?(.*?)\?([bq])\?(.*?)\?=/ie', 'BananaMimePart::_decodeHeader("\1", "\2", "\3")', $val);
-        }    
+        }
     }
 
     static public function &parseHeaders(array &$lines)
     {
         $headers = array();
-        while (count($lines)) {
+        while ($lines) {
             $line = array_shift($lines);
-            if (preg_match('/^[\t\r ]+/', $line) && isset($hdr)) {
+            if (isset($hdr) && $line && ctype_space($line{0})) {
                 $headers[$hdr] .= ' ' . trim($line);
             } elseif (!empty($line)) {
-                if (preg_match("/:[ \t\r]*/", $line)) {
-                    list($hdr, $val) = split(":[ \t\r]*", $line, 2);
+                if (strpos($line, ':') !== false) {
+                    list($hdr, $val) = explode(":", $line, 2);
                     $hdr = strtolower($hdr);
                     if (in_array($hdr, Banana::$msgparse_headers)) {  
-                        $headers[$hdr] = $val;
+                        $headers[$hdr] = ltrim($val);
                     } else {
                         unset($hdr);
                     }
@@ -298,9 +327,7 @@ class BananaMimePart
                 $value = substr($value, 0, $trim);
             }
         }
-        if (preg_match('/[\x80-\xff]/', $value)) {
-            return '=?UTF-8?B?' . base64_encode($value) . '?=';
-        }
+        $value = preg_replace('/([\x80-\xff]+)/e', '"=?UTF-8?B?" . base64_encode("\1") . "?="', $value);
         return $value;
     }
 
@@ -321,7 +348,7 @@ class BananaMimePart
         }
 
         if (!is_null($this->charset)) {
-            $body = iconv($this->charset, 'UTF-8//IGNORE', $this->body);
+            $body = @iconv($this->charset, 'UTF-8//IGNORE', $this->body);
             if (empty($body)) {
                 return;
             }
@@ -364,7 +391,8 @@ class BananaMimePart
         $headers['Content-Type'] = $this->content_type . ";"
             . ($this->filename ? " name=\"{$this->filename}\";" : '')
             . ($this->charset ? " charset=\"{$this->charset}\";" : '')
-            . ($this->boundary ? " boundary=\"{$this->boundary}\";" : "");
+            . ($this->boundary ? " boundary=\"{$this->boundary}\";" : "")
+            . ($this->format ? " format={$this->format}" : "");
         if ($this->encoding) {
             $headers['Content-Transfer-Encoding'] = $this->encoding;
         }
@@ -388,8 +416,13 @@ class BananaMimePart
         $content = "";
         if ($with_headers) {
             foreach ($this->getHeaders() as $key => $value) {
-                $content .= "$key: $value\n"; 
-            }   
+                $line = "$key: $value"; 
+                $line = explode("\n", wordwrap($line, Banana::$msgshow_wrap));
+                for ($i = 1 ; $i < count($line) ; $i++) {
+                    $line[$i] = "\t" . $line[$i];
+                }
+                $content .= implode("\n", $line) . "\n";
+            } 
             $content .= "\n";
         } 
         if ($this->isType('multipart')) {
@@ -398,30 +431,50 @@ class BananaMimePart
                 $content .= "\n--{$this->boundary}\n" . $part->get(true);
             }
             $content .= "\n--{$this->boundary}--";
+        } elseif ($this->isType('text', 'plain')) {
+            $content .= banana_flow($this->body);
         } else {
-            $content .= $this->body;
+            $content .= banana_wordwrap($this->body);
         }
         return $content;
     }
 
     public function getText()
     {
+        $signed =& $this->getSignedPart(); 
+        if ($signed !== $this) { 
+            return $signed->getText(); 
+        }
         $this->decodeContent();
         return $this->body;
     }
 
     public function toHtml()
     {
-        list($type, $subtype) = $this->getType();
+        $signed =& $this->getSignedPart(); 
+        if ($signed !== $this) { 
+            return $signed->toHtml(); 
+        }
+        @list($type, $subtype) = $this->getType();
         if ($type == 'image') {
             $part = $this->id ? $this->id : $this->filename;
-            return '<img src="'
+            return '<img class="multipart" src="'
                  . banana_htmlentities(Banana::$page->makeUrl(array('group' => Banana::$group,
                                                                     'artid' => Banana::$artid,
                                                                     'part'  => $part)))
                  . '" alt="' . banana_htmlentities($this->filename) . '" />';
-        } elseif (!in_array($type, Banana::$msgshow_mimeparts)
-                  && !in_array($this->content_type, Banana::$msgshow_mimeparts)) {
+        } else if ($type == 'multipart' && $subtype == 'alternative') {
+            $types =& Banana::$msgshow_mimeparts;
+            foreach ($types as $type) {
+                @list($type, $subtype) = explode('/', $type);
+                $part = $this->getParts($type, $subtype);
+                if (count($part) > 0) {
+                    return $part[0]->toHtml();
+                }
+            }
+        } elseif ((!in_array($type, Banana::$msgshow_mimeparts)
+                  && !in_array($this->content_type, Banana::$msgshow_mimeparts))
+                  || $this->disposition == 'attachment') {
             $part = $this->id ? $this->id : $this->filename;
             if (!$part) {
                 $part = $this->content_type;
@@ -431,20 +484,19 @@ class BananaMimePart
                                                  'part'  => $part,
                                                  'text'  => $this->filename ? $this->filename : $this->content_type,
                                                  'img'   => 'save')) . ']';
-        } else {
-            if ($type == 'multipart' && ($subtype == 'mixed' || $subtype == 'report')) {
-                $text = '';
-                foreach ($this->multipart as &$part) {
-                    $text .= $part->toHtml();
-                }
-                return $text;
+        } elseif ($type == 'multipart' && ($subtype == 'mixed' || $subtype == 'report')) {
+            $text = '';
+            foreach ($this->multipart as &$part) {
+                $text .= $part->toHtml();
             }
+            return $text;
+        } else {
             switch ($subtype) {
               case 'html': return banana_formatHtml($this);
               case 'enriched': case 'richtext': return banana_formatRichText($this);
               default:
-                if ($type == 'message') {
-                    return '<hr />' . banana_formatPlainText($this);
+                if ($type == 'message') { // we have a raw source of data (no specific pre-formatting)
+                    return '<hr />' . utf8_encode(banana_formatPlainText($this));
                 }
                 return banana_formatPlainText($this);
             }
@@ -454,6 +506,10 @@ class BananaMimePart
 
     public function quote()
     {
+        $signed =& $this->getSignedPart();
+        if ($signed !== $this) {
+            return $signed->quote();
+        }
         list($type, $subtype) = $this->getType();
         if (in_array($type, Banana::$msgedit_mimeparts) || in_array($this->content_type, Banana::$msgedit_mimeparts)) {
             if ($type == 'multipart' && ($subtype == 'mixed' || $subtype == 'report')) {
@@ -539,6 +595,45 @@ class BananaMimePart
         return array();
     }
 
+    public function getAlternatives()
+    {
+        $types =& Banana::$msgshow_mimeparts;
+        $names =& Banana::$mimeparts;
+        $source = null;
+        if (in_array('source', $types)) {
+            $source = @$names['source'] ? $names['source'] : 'source';
+        }
+        if ($this->isType('multipart', 'signed')) {
+            $parts = array($this->getSignedPart());
+        } else if (!$this->isType('multipart', 'alternative') && !$this->isType('multipart', 'related')) {
+            if ($source) {
+                $parts = array($this);
+            } else {
+                return array();
+            }
+        } else {
+            $parts =& $this->multipart;
+        }
+        $alt = array();
+        foreach ($parts as &$part) {
+            list($type, $subtype) = $part->getType();
+            $ct = $type . '/' . $subtype;
+            if (in_array($ct, $types) || in_array($type, $types)) {
+                if (isset($names[$ct])) {
+                    $alt[$ct] = $names[$ct];
+                } elseif (isset($names[$type])) {
+                    $alt[$ct] = $names[$type];
+                } else {
+                    $alt[$ct] = $ct;
+                }
+            }
+        }
+        if ($source) {
+            $alt['source'] = $source;
+        }
+        return $alt;
+    }
+
     public function getPartById($id)
     {
         if ($this->id == $id) {
@@ -553,6 +648,72 @@ class BananaMimePart
         }
         return null;
     }
+
+    protected function &getSignedPart()
+    {
+        if ($this->isType('multipart', 'signed')) {
+            foreach ($this->multipart as &$part) {
+                if ($part->content_type != $this->signature['protocole']) {
+                    return $part;
+                }
+            }
+        }
+        return $this;
+    }
+
+    private function checkPGPSignature($signature, $message = null)
+    {
+        if (!Banana::$msgshow_pgpcheck) {
+            return true;
+        }
+        $signname = tempnam(Banana::$spool_root, 'banana_pgp_');
+        $gpg = 'LC_ALL="en_US" ' . Banana::$msgshow_pgppath . ' ' . Banana::$msgshow_pgpoptions . ' --verify '
+                .  $signname . '.asc ';
+        file_put_contents($signname. '.asc', $signature);
+        $gpg_check = array();
+        if (!is_null($message)) {
+            file_put_contents($signname, str_replace(array("\r\n", "\n"), array("\n", "\r\n"), $message));
+            exec($gpg . $signname . ' 2>&1', $gpg_check, $result);
+            unlink($signname);
+        } else {
+            exec($gpg . '2&>1', $gpg_check, $result);
+        }
+        unlink("$signname.asc");
+        if (preg_match('/Signature made (.+) using (.+) key ID (.+)/', array_shift($gpg_check), $matches)) {
+            $this->signature['date'] = strtotime($matches[1]);
+            $this->signature['key'] = array('format' => $matches[2],
+                                            'id'     => $matches[3]);
+        } else {
+            return false;
+        }
+        $signature = array_shift($gpg_check);
+        if (preg_match('/Good signature from "(.+)"/', $signature, $matches)) {
+            $this->signature['verify'] = true;
+            $this->signature['identity'] = array($matches[1]);
+            $this->signature['certified'] = true;
+        } elseif (preg_match('/BAD signature from "(.+)"/', $signature, $matches)) {
+            $this->signature['verify'] = false;
+            $this->signature['identity'] = array($matches[1]);
+            $this->signature['certified'] = false;
+        } else {
+            return false;
+        }
+        foreach ($gpg_check as $aka) {
+            if (preg_match('/aka "(.+)"/', $aka, $matches)) {
+                $this->signature['identity'][] = $matches[1];
+            }
+            if (preg_match('/This key is not certified with a trusted signature!/', $aka)) {
+                $this->signature['certified'] = false;
+                $this->signature['certification_error'] = _b_("identité non confirmée");
+            }
+        }
+        return true;
+    }
+
+    public function getSignature()
+    {
+        return $this->signature;
+    }
 }
 
 // vim:set et sw=4 sts=4 ts=4 enc=utf-8: