Fix misdetection of filenames containing spaces.
[banana.git] / banana / mimepart.inc.php
1 <?php
2 /********************************************************************************
3 * banana/mimepart.inc.php : class for MIME parts
4 * ------------------------
5 *
6 * This file is part of the banana distribution
7 * Copyright: See COPYING files that comes with this distribution
8 ********************************************************************************/
9
10 class BananaMimePart
11 {
12 public $headers = null; /* Should be protected */
13
14 private $id = null;
15 private $content_type = null;
16 private $charset = null;
17 private $encoding = null;
18 private $disposition = null;
19 private $boundary = null;
20 private $filename = null;
21 private $format = null;
22 private $signature = array();
23
24 private $body = null;
25 private $multipart = null;
26
27 protected function __construct($data = null)
28 {
29 if ($data instanceof BananaMimePart) {
30 foreach ($this as $key=>$value) {
31 $this->$key = $data->$key;
32 }
33 } elseif (!is_null($data)) {
34 $this->fromRaw($data);
35 }
36 }
37
38 protected function makeTextPart($body, $content_type, $encoding, $charset = null, $format = 'fixed')
39 {
40 $this->body = $body;
41 $this->charset = $charset;
42 $this->encoding = $encoding;
43 $this->content_type = $content_type;
44 $this->format = strtolower($format);
45 $this->parse();
46 }
47
48 protected function makeDataPart($body, $content_type, $encoding, $filename, $disposition, $id = null)
49 {
50 $this->body = $body;
51 $this->content_type = $content_type;
52 $this->encoding = $encoding;
53 $this->filename = $filename;
54 $this->disposition = $disposition;
55 $this->id = $id;
56 if (is_null($content_type) || $content_type == 'application/octet-stream') {
57 $this->decodeContent();
58 $this->content_type = BananaMimePart::getMimeType($this->body, false);
59 }
60 }
61
62 protected function makeFilePart(array $file, $content_type = null, $disposition = 'attachment')
63 {
64 $body = file_get_contents($file['tmp_name']);
65 if ($body === false || strlen($body) != $file['size']) {
66 return false;
67 }
68 if (is_null($content_type) || $content_type == 'application/octet-stream') {
69 $content_type = BananaMimePart::getMimeType($file['tmp_name']);
70 }
71 if (substr($content_type, 0, 5) == 'text/') {
72 $encoding = '8bit';
73 } else {
74 $encoding = 'base64';
75 $body = chunk_split(base64_encode($body));
76 }
77 $this->filename = $file['name'];
78 $this->content_type = $content_type;
79 $this->disposition = $disposition;
80 $this->body = $body;
81 $this->encoding = $encoding;
82 return true;
83 }
84
85 protected function makeMultiPart($body, $content_type, $encoding, $boundary, $sign_protocole)
86 {
87 $this->body = $body;
88 $this->content_type = $content_type;
89 $this->encoding = $encoding;
90 $this->boundary = $boundary;
91 $this->signature['protocole'] = $sign_protocole;
92 $this->parse();
93 }
94
95 protected function convertToMultiPart()
96 {
97 if (!$this->isType('multipart', 'mixed')) {
98 $newpart = new BananaMimePart($this);
99 $this->content_type = 'multipart/mixed';
100 $this->encoding = '8bit';
101 $this->multipart = array($newpart);
102 $this->headers = null;
103 $this->charset = null;
104 $this->disposition = null;
105 $this->filename = null;
106 $this->boundary = null;
107 $this->body = null;
108 $this->format = null;
109 $this->id = null;
110 }
111 }
112
113 public function addAttachment(array $file, $content_type = null, $disposition = 'attachment')
114 {
115 if (!is_uploaded_file($file['tmp_name'])) {
116 return false;
117 }
118 $newpart = new BananaMimePart;
119 if ($newpart->makeFilePart($file, $content_type, $disposition)) {
120 $this->convertToMultiPart();
121 $this->multipart[] = $newpart;
122 return true;
123 }
124 return false;
125 }
126
127 public function getHeader($title, $filter = null)
128 {
129 if (!isset($this->headers[$title])) {
130 return null;
131 }
132 $header =& $this->headers[$title];
133 if (is_null($filter)) {
134 return trim($header);
135 } elseif (preg_match($filter, $header, $matches)) {
136 return trim($matches[1]);
137 }
138 return null;
139 }
140
141 protected function fromRaw($data)
142 {
143 if (is_array($data)) {
144 if (array_key_exists('From', $data)) {
145 $this->headers = array_map(array($this, 'decodeHeader'), array_change_key_case($data));
146 return;
147 } else {
148 $lines = $data;
149 }
150 } else {
151 $lines = explode("\n", $data);
152 }
153 $headers = BananaMimePart::parseHeaders($lines);
154 $this->headers =& $headers;
155 if (empty($headers) || empty($lines)) {
156 return;
157 }
158 $content = join("\n", $lines);
159 unset($lines);
160 $test = trim($content);
161 if (empty($test)) {
162 return;
163 }
164 unset($test);
165
166 $content_type = strtolower($this->getHeader('content-type', '/^\s*([^ ;]+?)(;|$)/'));
167 if (empty($content_type)) {
168 $encoding = '8bit';
169 $charset = 'CP1252';
170 $content_type = 'text/plain';
171 $format = strtolower($this->getHeader('x-rfc2646', '/format="?([^ w@"]+?)"?\s*(;|$)/i'));
172 } else {
173 $encoding = strtolower($this->getHeader('content-transfer-encoding'));
174 $disposition = $this->getHeader('content-disposition', '/(inline|attachment)/i');
175 $boundary = $this->getHeader('content-type', '/boundary="?([^ "]+?)"?\s*(;|$)/i');
176 $charset = strtolower($this->getHeader('content-type', '/charset="?([^ "]+?)"?\s*(;|$)/i'));
177 $filename = $this->getHeader('content-disposition', '/filename="?([^"]+?)"?\s*(;|$)/i');
178 $format = strtolower($this->getHeader('content-type', '/format="?([^ "]+?)"?\s*(;|$)/i'));
179 $id = $this->getHeader('content-id', '/<(.*?)>/');
180 $sign_protocole = strtolower($this->getHeader('content-type', '/protocol="?([^ "]+?)"?\s*(;|$)/i'));
181 if (empty($filename)) {
182 $filename = $this->getHeader('content-type', '/name="?([^"]+)"?/');
183 }
184 }
185 list($type, $subtype) = explode('/', $content_type);
186 if ($disposition == 'attachment') {
187 $this->makeDataPart($content, $content_type, $encoding, $filename, $disposition, $id);
188 return;
189 }
190 switch ($type) {
191 case 'text': case 'message':
192 $this->makeTextPart($content, $content_type, $encoding, $charset, $format);
193 break;
194 case 'multipart':
195 $this->makeMultiPart($content, $content_type, $encoding, $boundary, $sign_protocole);
196 break;
197 default:
198 $this->makeDataPart($content, $content_type, $encoding, $filename, $disposition, $id);
199 }
200 }
201
202 private function parse()
203 {
204 if ($this->isType('multipart')) {
205 $this->splitMultipart();
206 } else {
207 $parts = $this->findUUEncoded();
208 if (count($parts)) {
209 $this->convertToMultiPart();
210 $this->multipart = array_merge($this->multipart, $parts);
211 // Restore "message" headers to the previous level"
212 $this->headers = array();
213 foreach (Banana::$msgshow_headers as $hdr) {
214 if (isset($this->multipart[0]->headers[$hdr])) {
215 $this->headers[$hdr] = $this->multipart[0]->headers[$hdr];
216 }
217 }
218 }
219 }
220 }
221
222 private function splitMultipart()
223 {
224 $this->decodeContent();
225 if (is_null($this->multipart)) {
226 $this->multipart = array();
227 }
228 $boundary =& $this->boundary;
229 $parts = preg_split("/(^|\n)--" . preg_quote($boundary, '/') . "(--|\n)/", $this->body, -1, PREG_SPLIT_NO_EMPTY);
230 $signed = $this->isType('multipart', 'signed');
231 $signature = null;
232 $signed_message = null;
233 foreach ($parts as &$part) {
234 $newpart = new BananaMimePart($part);
235 if (!is_null($newpart->content_type)) {
236 if ($signed && $newpart->content_type == $this->signature['protocole']) {
237 $signature = $newpart->body;
238 } elseif ($signed) {
239 $signed_message = $part;
240 }
241 $this->multipart[] = $newpart;
242 }
243 }
244 if ($signed) {
245 $this->checkPGPSignature($signature, $signed_message);
246 }
247 $this->body = null;
248 }
249
250 public static function getMimeType($data, $is_filename = true)
251 {
252 if ($is_filename) {
253 $type = mime_content_type($data);
254 if ($type == 'text/plain') { // XXX Workaround a bug of php 5.2.0+etch10 (fallback for mime_content_type is text/plain)
255 $type = preg_replace('/;.*/', '', trim(shell_exec('file -bi ' . escapeshellarg($data))));
256 }
257 } else {
258 $arg = escapeshellarg($data);
259 $type = preg_replace('/;.*/', '', trim(shell_exec("echo $arg | file -bi -")));
260 }
261 return empty($type) ? 'application/octet-stream' : $type;
262 }
263
264 private function findUUEncoded()
265 {
266 $this->decodeContent();
267 $parts = array();
268 if (preg_match_all("/\n(begin \d+ ([^\r\n]+)\r?(?:\n(?!end)[^\n]*)*\nend)/",
269 $this->body, $matches, PREG_SET_ORDER)) {
270 foreach ($matches as &$match) {
271 $data = convert_uudecode($match[1]);
272 $mime = BananaMimePart::getMimeType($data, false);
273 if ($mime != 'application/x-empty') {
274 $this->body = trim(str_replace($match[0], '', $this->body));
275 $newpart = new BananaMimePart;
276 self::decodeHeader($match[2]);
277 $newpart->makeDataPart($data, $mime, '8bit', $match[2], 'attachment');
278 $parts[] = $newpart;
279 }
280 }
281 }
282 return $parts;
283 }
284
285 static private function _decodeHeader($charset, $c, $str)
286 {
287 $s = ($c == 'Q' || $c == 'q') ? quoted_printable_decode($str) : base64_decode($str);
288 $s = @iconv($charset, 'UTF-8', $s);
289 return str_replace('_', ' ', $s);
290 }
291
292 static public function decodeHeader(&$val, $key = null)
293 {
294 if (preg_match('/[\x80-\xff]/', $val)) {
295 if (!is_utf8($val)) {
296 $val = utf8_encode($val);
297 }
298 } elseif (strpos($val, '=') !== false) {
299 $val = preg_replace('/(=\?.*?\?[bq]\?.*?\?=) (=\?.*?\?[bq]\?.*?\?=)/i', '\1\2', $val);
300 $val = preg_replace('/=\?(.*?)\?([bq])\?(.*?)\?=/ie', 'BananaMimePart::_decodeHeader("\1", "\2", "\3")', $val);
301 }
302 }
303
304 static public function &parseHeaders(array &$lines)
305 {
306 $headers = array();
307 while ($lines) {
308 $line = array_shift($lines);
309 if (isset($hdr) && $line && ctype_space($line{0})) {
310 $headers[$hdr] .= ' ' . trim($line);
311 } elseif (!empty($line)) {
312 if (strpos($line, ':') !== false) {
313 list($hdr, $val) = explode(":", $line, 2);
314 $hdr = strtolower($hdr);
315 if (in_array($hdr, Banana::$msgparse_headers)) {
316 $headers[$hdr] = ltrim($val);
317 } else {
318 unset($hdr);
319 }
320 }
321 } else {
322 break;
323 }
324 }
325 array_walk($headers, array('BananaMimePart', 'decodeHeader'));
326 return $headers;
327 }
328
329 static public function encodeHeader($value, $trim = 0)
330 {
331 if ($trim) {
332 if (strlen($value) > $trim) {
333 $value = substr($value, 0, $trim);
334 }
335 }
336 $value = preg_replace('/([\x80-\xff]+)/e', '"=?UTF-8?B?" . base64_encode("\1") . "?="', $value);
337 return $value;
338 }
339
340 private function decodeContent()
341 {
342 $encodings = Array('quoted-printable' => 'quoted_printable_decode',
343 'base64' => 'base64_decode',
344 'x-uuencode' => 'convert_uudecode');
345 foreach ($encodings as $encoding => $callback) {
346 if ($this->encoding == $encoding) {
347 $this->body = $callback($this->body);
348 $this->encoding = '8bit';
349 break;
350 }
351 }
352 if (!$this->isType('text')) {
353 return;
354 }
355
356 if (!is_null($this->charset)) {
357 $body = @iconv($this->charset, 'UTF-8//IGNORE', $this->body);
358 if (empty($body)) {
359 return;
360 }
361 $this->body = $body;
362 } else {
363 $this->body = utf8_encode($this->body);
364 }
365 $this->charset = 'utf-8';
366 }
367
368 public function send($force_inline = false)
369 {
370 $this->decodeContent();
371 if ($force_inline) {
372 $dispostion = $this->disposition;
373 $this->disposition = 'inline';
374 }
375 $headers = $this->getHeaders();
376 foreach ($headers as $key => $value) {
377 header("$key: $value");
378 }
379 if ($force_inline) {
380 $this->disposition = $disposition;
381 }
382 echo $this->body;
383 exit;
384 }
385
386 private function setBoundary()
387 {
388 if ($this->isType('multipart') && is_null($this->boundary)) {
389 $this->boundary = '--banana-bound-' . time() . rand(0, 255) . '-';
390 }
391 }
392
393 public function getHeaders()
394 {
395 $headers = array();
396 $this->setBoundary();
397 $headers['Content-Type'] = $this->content_type . ";"
398 . ($this->filename ? " name=\"{$this->filename}\";" : '')
399 . ($this->charset ? " charset=\"{$this->charset}\";" : '')
400 . ($this->boundary ? " boundary=\"{$this->boundary}\";" : "")
401 . ($this->format ? " format={$this->format}" : "");
402 if ($this->encoding) {
403 $headers['Content-Transfer-Encoding'] = $this->encoding;
404 }
405 if ($this->disposition) {
406 $headers['Content-Disposition'] = $this->disposition
407 . ($this->filename ? "; filename=\"{$this->filename}\"" : '');
408 }
409 return array_map(array($this, 'encodeHeader'), $headers);
410 }
411
412 public function hasBody()
413 {
414 if (is_null($this->content) && !$this->isType('multipart')) {
415 return false;
416 }
417 return true;
418 }
419
420 public function get($with_headers = false)
421 {
422 $content = "";
423 if ($with_headers) {
424 foreach ($this->getHeaders() as $key => $value) {
425 $line = "$key: $value";
426 $line = explode("\n", wordwrap($line, Banana::$msgshow_wrap));
427 for ($i = 1 ; $i < count($line) ; $i++) {
428 $line[$i] = "\t" . $line[$i];
429 }
430 $content .= implode("\n", $line) . "\n";
431 }
432 $content .= "\n";
433 }
434 if ($this->isType('multipart')) {
435 $this->setBoundary();
436 foreach ($this->multipart as &$part) {
437 $content .= "\n--{$this->boundary}\n" . $part->get(true);
438 }
439 $content .= "\n--{$this->boundary}--";
440 } elseif ($this->isType('text', 'plain')) {
441 $content .= banana_flow($this->body);
442 } else {
443 $content .= banana_wordwrap($this->body);
444 }
445 return $content;
446 }
447
448 public function getText()
449 {
450 $signed =& $this->getSignedPart();
451 if ($signed !== $this) {
452 return $signed->getText();
453 }
454 $this->decodeContent();
455 return $this->body;
456 }
457
458 public function toHtml()
459 {
460 $signed =& $this->getSignedPart();
461 if ($signed !== $this) {
462 return $signed->toHtml();
463 }
464 @list($type, $subtype) = $this->getType();
465 if ($type == 'image') {
466 $part = $this->id ? $this->id : $this->filename;
467 return '<img class="multipart" src="'
468 . banana_htmlentities(Banana::$page->makeUrl(array('group' => Banana::$group,
469 'artid' => Banana::$artid,
470 'part' => $part)))
471 . '" alt="' . banana_htmlentities($this->filename) . '" />';
472 } else if ($type == 'multipart' && $subtype == 'alternative') {
473 $types =& Banana::$msgshow_mimeparts;
474 foreach ($types as $type) {
475 @list($type, $subtype) = explode('/', $type);
476 $part = $this->getParts($type, $subtype);
477 if (count($part) > 0) {
478 return $part[0]->toHtml();
479 }
480 }
481 } elseif ((!in_array($type, Banana::$msgshow_mimeparts)
482 && !in_array($this->content_type, Banana::$msgshow_mimeparts))
483 || $this->disposition == 'attachment') {
484 $part = $this->id ? $this->id : $this->filename;
485 if (!$part) {
486 $part = $this->content_type;
487 }
488 return '<span>[' . Banana::$page->makeImgLink(array('group' => Banana::$group,
489 'artid' => Banana::$artid,
490 'part' => $part,
491 'text' => $this->filename ? $this->filename : $this->content_type,
492 'img' => 'save')) . ']</span>';
493 } elseif ($type == 'multipart' && ($subtype == 'mixed' || $subtype == 'report')) {
494 $text = '';
495 foreach ($this->multipart as &$part) {
496 $text .= $part->toHtml();
497 }
498 return $text;
499 } else {
500 switch ($subtype) {
501 case 'html': return banana_formatHtml($this);
502 case 'enriched': case 'richtext': return banana_formatRichText($this);
503 default:
504 if ($type == 'message') { // we have a raw source of data (no specific pre-formatting)
505 return '<hr />' . utf8_encode(banana_formatPlainText($this));
506 }
507 return banana_formatPlainText($this);
508 }
509 }
510 return null;
511 }
512
513 public function quote()
514 {
515 $signed =& $this->getSignedPart();
516 if ($signed !== $this) {
517 return $signed->quote();
518 }
519 list($type, $subtype) = $this->getType();
520 if (in_array($type, Banana::$msgedit_mimeparts) || in_array($this->content_type, Banana::$msgedit_mimeparts)) {
521 if ($type == 'multipart' && ($subtype == 'mixed' || $subtype == 'report')) {
522 $text = '';
523 foreach ($this->multipart as &$part) {
524 $qt = $part->quote();
525 $qt = rtrim($qt);
526 if (!empty($text)) {
527 $text .= "\n" . banana_quote("", 1) . "\n";
528 }
529 $text .= $qt;
530 }
531 return $text;
532 }
533 switch ($subtype) {
534 case 'html': return banana_quoteHtml($this);
535 case 'enriched': case 'richtext': return banana_quoteRichText($this);
536 default: return banana_quotePlainText($this);
537 }
538 }
539 }
540
541 protected function getType()
542 {
543 return explode('/', $this->content_type);
544 }
545
546 protected function isType($type, $subtype = null)
547 {
548 list($mytype, $mysub) = $this->getType();
549 return ($mytype == $type) && (is_null($subtype) || $mysub == $subtype);
550 }
551
552 public function isFlowed()
553 {
554 return $this->format == 'flowed';
555 }
556
557 public function getFilename()
558 {
559 return $this->filename;
560 }
561
562 protected function getParts($type, $subtype = null)
563 {
564 $parts = array();
565 if ($this->isType($type, $subtype)) {
566 return array($this);
567 } elseif ($this->isType('multipart')) {
568 foreach ($this->multipart as &$part) {
569 $parts = array_merge($parts, $part->getParts($type, $subtype));
570 }
571 }
572 return $parts;
573 }
574
575 public function getFile($filename)
576 {
577 if ($this->filename == $filename) {
578 return $this;
579 } elseif ($this->isType('multipart')) {
580 foreach ($this->multipart as &$part) {
581 $file = $part->getFile($filename);
582 if (!is_null($file)) {
583 return $file;
584 }
585 }
586 }
587 return null;
588 }
589
590 public function getAttachments()
591 {
592 if (!is_null($this->filename)) {
593 return array($this);
594 } elseif ($this->isType('multipart')) {
595 $parts = array();
596 foreach ($this->multipart as &$part) {
597 $parts = array_merge($parts, $part->getAttachments());
598 }
599 return $parts;
600 }
601 return array();
602 }
603
604 public function getAlternatives()
605 {
606 $types =& Banana::$msgshow_mimeparts;
607 $names =& Banana::$mimeparts;
608 $source = null;
609 if (in_array('source', $types)) {
610 $source = @$names['source'] ? $names['source'] : 'source';
611 }
612 if ($this->isType('multipart', 'signed')) {
613 $parts = array($this->getSignedPart());
614 } else if (!$this->isType('multipart', 'alternative') && !$this->isType('multipart', 'related')) {
615 if ($source) {
616 $parts = array($this);
617 } else {
618 return array();
619 }
620 } else {
621 $parts =& $this->multipart;
622 }
623 $alt = array();
624 foreach ($parts as &$part) {
625 list($type, $subtype) = $part->getType();
626 $ct = $type . '/' . $subtype;
627 if (in_array($ct, $types) || in_array($type, $types)) {
628 if (isset($names[$ct])) {
629 $alt[$ct] = $names[$ct];
630 } elseif (isset($names[$type])) {
631 $alt[$ct] = $names[$type];
632 } else {
633 $alt[$ct] = $ct;
634 }
635 }
636 }
637 if ($source) {
638 $alt['source'] = $source;
639 }
640 return $alt;
641 }
642
643 public function getPartById($id)
644 {
645 if ($this->id == $id) {
646 return $this;
647 } elseif ($this->isType('multipart')) {
648 foreach ($this->multipart as &$part) {
649 $res = $part->getPartById($id);
650 if (!is_null($res)) {
651 return $res;
652 }
653 }
654 }
655 return null;
656 }
657
658 protected function &getSignedPart()
659 {
660 if ($this->isType('multipart', 'signed')) {
661 foreach ($this->multipart as &$part) {
662 if ($part->content_type != $this->signature['protocole']) {
663 return $part;
664 }
665 }
666 }
667 return $this;
668 }
669
670 private function checkPGPSignature($signature, $message = null)
671 {
672 if (!Banana::$msgshow_pgpcheck) {
673 return true;
674 }
675 $signname = tempnam(Banana::$spool_root, 'banana_pgp_');
676 $gpg = 'LC_ALL="en_US" ' . Banana::$msgshow_pgppath . ' ' . Banana::$msgshow_pgpoptions . ' --verify '
677 . $signname . '.asc ';
678 file_put_contents($signname. '.asc', $signature);
679 $gpg_check = array();
680 if (!is_null($message)) {
681 file_put_contents($signname, str_replace(array("\r\n", "\n"), array("\n", "\r\n"), $message));
682 exec($gpg . $signname . ' 2>&1', $gpg_check, $result);
683 unlink($signname);
684 } else {
685 exec($gpg . '2&>1', $gpg_check, $result);
686 }
687 unlink("$signname.asc");
688 if (preg_match('/Signature made (.+) using (.+) key ID (.+)/', array_shift($gpg_check), $matches)) {
689 $this->signature['date'] = strtotime($matches[1]);
690 $this->signature['key'] = array('format' => $matches[2],
691 'id' => $matches[3]);
692 } else {
693 return false;
694 }
695 $signature = array_shift($gpg_check);
696 if (preg_match('/Good signature from "(.+)"/', $signature, $matches)) {
697 $this->signature['verify'] = true;
698 $this->signature['identity'] = array($matches[1]);
699 $this->signature['certified'] = true;
700 } elseif (preg_match('/BAD signature from "(.+)"/', $signature, $matches)) {
701 $this->signature['verify'] = false;
702 $this->signature['identity'] = array($matches[1]);
703 $this->signature['certified'] = false;
704 } else {
705 return false;
706 }
707 foreach ($gpg_check as $aka) {
708 if (preg_match('/aka "(.+)"/', $aka, $matches)) {
709 $this->signature['identity'][] = $matches[1];
710 }
711 if (preg_match('/This key is not certified with a trusted signature!/', $aka)) {
712 $this->signature['certified'] = false;
713 $this->signature['certification_error'] = _b_("identité non confirmée");
714 }
715 }
716 return true;
717 }
718
719 public function getSignature()
720 {
721 return $this->signature;
722 }
723 }
724
725 // vim:set et sw=4 sts=4 ts=4 enc=utf-8:
726 ?>