9270438d7255919b8fa859bbe955708c97c805f9
[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
23 private $body = null;
24 private $multipart = null;
25
26 protected function __construct($data = null)
27 {
28 if ($data instanceof BananaMimePart) {
29 foreach ($this as $key=>$value) {
30 $this->$key = $data->$key;
31 }
32 } elseif (!is_null($data)) {
33 $this->fromRaw($data);
34 }
35 }
36
37 protected function makeTextPart($body, $content_type, $encoding, $charset = null, $format = 'fixed')
38 {
39 $this->body = $body;
40 $this->charset = $charset;
41 $this->encoding = $encoding;
42 $this->content_type = $content_type;
43 $this->format = strtolower($format);
44 $this->parse();
45 }
46
47 protected function makeDataPart($body, $content_type, $encoding, $filename, $disposition, $id = null)
48 {
49 $this->body = $body;
50 $this->content_type = $content_type;
51 $this->encoding = $encoding;
52 $this->filename = $filename;
53 $this->disposition = $disposition;
54 $this->id = $id;
55 if (is_null($content_type) || $content_type == 'application/octet-stream') {
56 $this->decodeContent();
57 $this->content_type = BananaMimePart::getMimeType($this->body, false);
58 }
59 }
60
61 protected function makeFilePart(array $file, $content_type = null, $disposition = 'attachment')
62 {
63 $body = file_get_contents($file['tmp_name']);
64 if ($body === false || strlen($body) != $file['size']) {
65 return false;
66 }
67 if (is_null($content_type) || $content_type == 'application/octet-stream') {
68 $content_type = BananaMimePart::getMimeType($file['tmp_name']);
69 }
70 if (substr($content_type, 0, 5) == 'text/') {
71 $encoding = '8bit';
72 } else {
73 $encoding = 'base64';
74 $body = chunk_split(base64_encode($body));
75 }
76 $this->filename = $file['name'];
77 $this->content_type = $content_type;
78 $this->disposition = $disposition;
79 $this->body = $body;
80 $this->encoding = $encoding;
81 return true;
82 }
83
84 protected function makeMultiPart($body, $content_type, $encoding, $boundary)
85 {
86 $this->body = $body;
87 $this->content_type = $content_type;
88 $this->encoding = $encoding;
89 $this->boundary = $boundary;
90 $this->parse();
91 }
92
93 protected function convertToMultiPart()
94 {
95 if (!$this->isType('multipart', 'mixed')) {
96 $newpart = new BananaMimePart($this);
97 $this->content_type = 'multipart/mixed';
98 $this->encoding = '8bit';
99 $this->multipart = array($newpart);
100 $this->headers = null;
101 $this->charset = null;
102 $this->disposition = null;
103 $this->filename = null;
104 $this->boundary = null;
105 $this->body = null;
106 $this->format = null;
107 $this->id = null;
108 }
109 }
110
111 public function addAttachment(array $file, $content_type = null, $disposition = 'attachment')
112 {
113 if (!is_uploaded_file($file['tmp_name'])) {
114 return false;
115 }
116 $newpart = new BananaMimePart;
117 if ($newpart->makeFilePart($file, $content_type, $disposition)) {
118 $this->convertToMultiPart();
119 $this->multipart[] = $newpart;
120 return true;
121 }
122 return false;
123 }
124
125 protected function getHeader($title, $filter = null)
126 {
127 if (!isset($this->headers[$title])) {
128 return null;
129 }
130 $header =& $this->headers[$title];
131 if (is_null($filter)) {
132 return trim($header);
133 } elseif (preg_match($filter, $header, $matches)) {
134 return trim($matches[1]);
135 }
136 return null;
137 }
138
139 protected function fromRaw($data)
140 {
141 if (is_array($data)) {
142 if (array_key_exists('From', $data)) {
143 $this->headers = array_map(array($this, 'decodeHeader'), array_change_key_case($data));
144 return;
145 } else {
146 $lines = $data;
147 }
148 } else {
149 $lines = explode("\n", $data);
150 }
151 $headers = BananaMimePart::parseHeaders($lines);
152 $this->headers =& $headers;
153 if (empty($headers) || empty($lines)) {
154 return;
155 }
156 $content = join("\n", $lines);
157 $test = trim($content);
158 if (empty($test)) {
159 return;
160 }
161
162 $content_type = strtolower($this->getHeader('content-type', '/^\s*([^ ;]+?)(;|$)/'));
163 if (empty($content_type)) {
164 $encoding = '8bit';
165 $charset = 'CP1252';
166 $content_type = 'text/plain';
167 $format = strtolower($this->getHeader('x-rfc2646', '/format="?([^ w@"]+?)"?\s*(;|$)/i'));
168 } else {
169 $encoding = strtolower($this->getHeader('content-transfer-encoding'));
170 $disposition = $this->getHeader('content-disposition', '/(inline|attachment)/i');
171 $boundary = $this->getHeader('content-type', '/boundary="?([^ "]+?)"?\s*(;|$)/i');
172 $charset = strtolower($this->getHeader('content-type', '/charset="?([^ "]+?)"?\s*(;|$)/i'));
173 $filename = $this->getHeader('content-disposition', '/filename="?([^ "]+?)"?\s*(;|$)/i');
174 $format = strtolower($this->getHeader('content-type', '/format="?([^ "]+?)"?\s*(;|$)/i'));
175 $id = $this->getHeader('content-id', '/<(.*?)>/');
176 if (empty($filename)) {
177 $filename = $this->getHeader('content-type', '/name="?([^"]+)"?/');
178 }
179 }
180 list($type, $subtype) = explode('/', $content_type);
181 switch ($type) {
182 case 'text': case 'message':
183 $this->makeTextPart($content, $content_type, $encoding, $charset, $format);
184 break;
185 case 'multipart':
186 $this->makeMultiPart($content, $content_type, $encoding, $boundary);
187 break;
188 default:
189 $this->makeDataPart($content, $content_type, $encoding, $filename, $disposition, $id);
190 }
191 }
192
193 private function parse()
194 {
195 if ($this->isType('multipart')) {
196 $this->splitMultipart();
197 } else {
198 $parts = $this->findUUEncoded();
199 if (count($parts)) {
200 $this->convertToMultiPart();
201 $this->multipart = array_merge(array($textpart), $parts);
202 }
203 }
204 }
205
206 private function splitMultipart()
207 {
208 $this->decodeContent();
209 if (is_null($this->multipart)) {
210 $this->multipart = array();
211 }
212 $boundary =& $this->boundary;
213 $parts = preg_split("/\n--" . preg_quote($boundary, '/') . "(--|\n)/", $this->body, -1, PREG_SPLIT_NO_EMPTY);
214 foreach ($parts as &$part) {
215 $newpart = new BananaMimePart($part);
216 if (!is_null($newpart->content_type)) {
217 $this->multipart[] = $newpart;
218 }
219 }
220 $this->body = null;
221 }
222
223 public static function getMimeType($data, $is_filename = true)
224 {
225 if ($is_filename) {
226 $type = mime_content_type($data);
227 } else {
228 $arg = escapeshellarg($data);
229 $type = preg_replace('/;.*/', '', trim(shell_exec("echo $arg | file -bi -")));
230 }
231 return empty($type) ? 'application/octet-stream' : $type;
232 }
233
234 private function findUUEncoded()
235 {
236 $this->decodeContent();
237 $parts = array();
238 if (preg_match_all("/\n(begin \d+ ([^\r\n]+)\r?(?:\n(?!end)[^\n]*)*\nend)/",
239 $this->body, $matches, PREG_SET_ORDER)) {
240 foreach ($matches as &$match) {
241 $data = convert_uudecode($match[1]);
242 $mime = BananaMimePart::getMimeType($data, false);
243 if ($mime != 'application/x-empty') {
244 $this->body = trim(str_replace($match[0], '', $this->body));
245 $newpart = new BananaMimePart;
246 $newpart->makeDataPart($data, $mime, '8bit', $match[2], 'attachment');
247 $parts[] = $newpart;
248 }
249 }
250 }
251 return $parts;
252 }
253
254 static private function _decodeHeader($charset, $c, $str)
255 {
256 $s = ($c == 'Q' || $c == 'q') ? quoted_printable_decode($str) : base64_decode($str);
257 $s = @iconv($charset, 'UTF-8', $s);
258 return str_replace('_', ' ', $s);
259 }
260
261 static public function decodeHeader(&$val, $key)
262 {
263 if (preg_match('/[\x80-\xff]/', $val)) {
264 if (!is_utf8($val)) {
265 $val = utf8_encode($val);
266 }
267 } elseif (strpos($val, '=') !== false) {
268 $val = preg_replace('/(=\?.*?\?[bq]\?.*?\?=) (=\?.*?\?[bq]\?.*?\?=)/i', '\1\2', $val);
269 $val = preg_replace('/=\?(.*?)\?([bq])\?(.*?)\?=/ie', 'BananaMimePart::_decodeHeader("\1", "\2", "\3")', $val);
270 }
271 }
272
273 static public function &parseHeaders(array &$lines)
274 {
275 $headers = array();
276 while ($lines) {
277 $line = array_shift($lines);
278 if (isset($hdr) && $line && ctype_space($line{0})) {
279 $headers[$hdr] .= ' ' . trim($line);
280 } elseif (!empty($line)) {
281 if (strpos($line, ':') !== false) {
282 list($hdr, $val) = explode(":", $line, 2);
283 $hdr = strtolower($hdr);
284 if (in_array($hdr, Banana::$msgparse_headers)) {
285 $headers[$hdr] = ltrim($val);
286 } else {
287 unset($hdr);
288 }
289 }
290 } else {
291 break;
292 }
293 }
294 array_walk($headers, array('BananaMimePart', 'decodeHeader'));
295 return $headers;
296 }
297
298 static public function encodeHeader($value, $trim = 0)
299 {
300 if ($trim) {
301 if (strlen($value) > $trim) {
302 $value = substr($value, 0, $trim);
303 }
304 }
305 $value = preg_replace('/([\x80-\xff]+)/e', '"=?UTF-8?B?" . base64_encode("\1") . "?="', $value);
306 return $value;
307 }
308
309 private function decodeContent()
310 {
311 $encodings = Array('quoted-printable' => 'quoted_printable_decode',
312 'base64' => 'base64_decode',
313 'x-uuencode' => 'convert_uudecode');
314 foreach ($encodings as $encoding => $callback) {
315 if ($this->encoding == $encoding) {
316 $this->body = $callback($this->body);
317 $this->encoding = '8bit';
318 break;
319 }
320 }
321 if (!$this->isType('text')) {
322 return;
323 }
324
325 if (!is_null($this->charset)) {
326 $body = iconv($this->charset, 'UTF-8//IGNORE', $this->body);
327 if (empty($body)) {
328 return;
329 }
330 $this->body = $body;
331 } else {
332 $this->body = utf8_encode($this->body);
333 }
334 $this->charset = 'utf-8';
335 }
336
337 public function send($force_inline = false)
338 {
339 $this->decodeContent();
340 if ($force_inline) {
341 $dispostion = $this->disposition;
342 $this->disposition = 'inline';
343 }
344 $headers = $this->getHeaders();
345 foreach ($headers as $key => $value) {
346 header("$key: $value");
347 }
348 if ($force_inline) {
349 $this->disposition = $disposition;
350 }
351 echo $this->body;
352 exit;
353 }
354
355 private function setBoundary()
356 {
357 if ($this->isType('multipart') && is_null($this->boundary)) {
358 $this->boundary = '--banana-bound-' . time() . rand(0, 255) . '-';
359 }
360 }
361
362 public function getHeaders()
363 {
364 $headers = array();
365 $this->setBoundary();
366 $headers['Content-Type'] = $this->content_type . ";"
367 . ($this->filename ? " name=\"{$this->filename}\";" : '')
368 . ($this->charset ? " charset=\"{$this->charset}\";" : '')
369 . ($this->boundary ? " boundary=\"{$this->boundary}\";" : "")
370 . ($this->format ? " format={$this->format}" : "");
371 if ($this->encoding) {
372 $headers['Content-Transfer-Encoding'] = $this->encoding;
373 }
374 if ($this->disposition) {
375 $headers['Content-Disposition'] = $this->disposition
376 . ($this->filename ? "; filename=\"{$this->filename}\"" : '');
377 }
378 return array_map(array($this, 'encodeHeader'), $headers);
379 }
380
381 public function hasBody()
382 {
383 if (is_null($this->content) && !$this->isType('multipart')) {
384 return false;
385 }
386 return true;
387 }
388
389 public function get($with_headers = false)
390 {
391 $content = "";
392 if ($with_headers) {
393 foreach ($this->getHeaders() as $key => $value) {
394 $line = "$key: $value";
395 $line = explode("\n", wordwrap($line, Banana::$msgshow_wrap));
396 for ($i = 1 ; $i < count($line) ; $i++) {
397 $line[$i] = "\t" . $line[$i];
398 }
399 $content .= implode("\n", $line) . "\n";
400 }
401 $content .= "\n";
402 }
403 if ($this->isType('multipart')) {
404 $this->setBoundary();
405 foreach ($this->multipart as &$part) {
406 $content .= "\n--{$this->boundary}\n" . $part->get(true);
407 }
408 $content .= "\n--{$this->boundary}--";
409 } elseif ($this->isType('text', 'plain')) {
410 $content .= banana_flow($this->body);
411 } else {
412 $content .= banana_wordwrap($this->body);
413 }
414 return $content;
415 }
416
417 public function getText()
418 {
419 $this->decodeContent();
420 return $this->body;
421 }
422
423 public function toHtml()
424 {
425 @list($type, $subtype) = $this->getType();
426 if ($type == 'image') {
427 $part = $this->id ? $this->id : $this->filename;
428 return '<img class="multipart" src="'
429 . banana_htmlentities(Banana::$page->makeUrl(array('group' => Banana::$group,
430 'artid' => Banana::$artid,
431 'part' => $part)))
432 . '" alt="' . banana_htmlentities($this->filename) . '" />';
433 } elseif ((!in_array($type, Banana::$msgshow_mimeparts)
434 && !in_array($this->content_type, Banana::$msgshow_mimeparts))
435 || $this->disposition == 'attachment') {
436 $part = $this->id ? $this->id : $this->filename;
437 if (!$part) {
438 $part = $this->content_type;
439 }
440 return '[' . Banana::$page->makeImgLink(array('group' => Banana::$group,
441 'artid' => Banana::$artid,
442 'part' => $part,
443 'text' => $this->filename ? $this->filename : $this->content_type,
444 'img' => 'save')) . ']';
445 } elseif ($type == 'multipart' && ($subtype == 'mixed' || $subtype == 'report')) {
446 $text = '';
447 foreach ($this->multipart as &$part) {
448 $text .= $part->toHtml();
449 }
450 return $text;
451 } else {
452 switch ($subtype) {
453 case 'html': return banana_formatHtml($this);
454 case 'enriched': case 'richtext': return banana_formatRichText($this);
455 default:
456 if ($type == 'message') { // we have a raw source of data (no specific pre-formatting)
457 return '<hr />' . utf8_encode(banana_formatPlainText($this));
458 }
459 return banana_formatPlainText($this);
460 }
461 }
462 return null;
463 }
464
465 public function quote()
466 {
467 list($type, $subtype) = $this->getType();
468 if (in_array($type, Banana::$msgedit_mimeparts) || in_array($this->content_type, Banana::$msgedit_mimeparts)) {
469 if ($type == 'multipart' && ($subtype == 'mixed' || $subtype == 'report')) {
470 $text = '';
471 foreach ($this->multipart as &$part) {
472 $qt = $part->quote();
473 $qt = rtrim($qt);
474 if (!empty($text)) {
475 $text .= "\n" . banana_quote("", 1) . "\n";
476 }
477 $text .= $qt;
478 }
479 return $text;
480 }
481 switch ($subtype) {
482 case 'html': return banana_quoteHtml($this);
483 case 'enriched': case 'richtext': return banana_quoteRichText($this);
484 default: return banana_quotePlainText($this);
485 }
486 }
487 }
488
489 protected function getType()
490 {
491 return explode('/', $this->content_type);
492 }
493
494 protected function isType($type, $subtype = null)
495 {
496 list($mytype, $mysub) = $this->getType();
497 return ($mytype == $type) && (is_null($subtype) || $mysub == $subtype);
498 }
499
500 public function isFlowed()
501 {
502 return $this->format == 'flowed';
503 }
504
505 public function getFilename()
506 {
507 return $this->filename;
508 }
509
510 protected function getParts($type, $subtype = null)
511 {
512 $parts = array();
513 if ($this->isType($type, $subtype)) {
514 return array($this);
515 } elseif ($this->isType('multipart')) {
516 foreach ($this->multipart as &$part) {
517 $parts = array_merge($parts, $part->getParts($type, $subtype));
518 }
519 }
520 return $parts;
521 }
522
523 public function getFile($filename)
524 {
525 if ($this->filename == $filename) {
526 return $this;
527 } elseif ($this->isType('multipart')) {
528 foreach ($this->multipart as &$part) {
529 $file = $part->getFile($filename);
530 if (!is_null($file)) {
531 return $file;
532 }
533 }
534 }
535 return null;
536 }
537
538 public function getAttachments()
539 {
540 if (!is_null($this->filename)) {
541 return array($this);
542 } elseif ($this->isType('multipart')) {
543 $parts = array();
544 foreach ($this->multipart as &$part) {
545 $parts = array_merge($parts, $part->getAttachments());
546 }
547 return $parts;
548 }
549 return array();
550 }
551
552 public function getAlternatives()
553 {
554 $types =& Banana::$msgshow_mimeparts;
555 $names =& Banana::$mimeparts;
556 $source = null;
557 if (in_array('source', $types)) {
558 $source = @$names['source'] ? $names['source'] : 'source';
559 }
560 if (!$this->isType('multipart', 'alternative') && !$this->isType('multipart', 'related')) {
561 if ($source) {
562 $parts = array($this);
563 } else {
564 return array();
565 }
566 } else {
567 $parts =& $this->multipart;
568 }
569 $alt = array();
570 foreach ($parts as &$part) {
571 list($type, $subtype) = $part->getType();
572 $ct = $type . '/' . $subtype;
573 if (in_array($ct, $types) || in_array($type, $types)) {
574 if (isset($names[$ct])) {
575 $alt[$ct] = $names[$ct];
576 } elseif (isset($names[$type])) {
577 $alt[$ct] = $names[$type];
578 } else {
579 $alt[$ct] = $ct;
580 }
581 }
582 }
583 if ($source) {
584 $alt['source'] = $source;
585 }
586 return $alt;
587 }
588
589 public function getPartById($id)
590 {
591 if ($this->id == $id) {
592 return $this;
593 } elseif ($this->isType('multipart')) {
594 foreach ($this->multipart as &$part) {
595 $res = $part->getPartById($id);
596 if (!is_null($res)) {
597 return $res;
598 }
599 }
600 }
601 return null;
602 }
603 }
604
605 // vim:set et sw=4 sts=4 ts=4 enc=utf-8:
606 ?>