1<?php
2
3/**
4 * A Swift Mailer plugin to download remote images and stylesheets then embed them.
5 * This also embeds local files from disk.
6 * Please read the LICENSE file
7 * @package Swift_Plugin
8 * @author Chris Corbyn <chris@w3style.co.uk>
9 * @license GNU Lesser General Public License
10 */
11
12require_once dirname(__FILE__) . "/../ClassLoader.php";
13Swift_ClassLoader::load("Swift_Events_BeforeSendListener");
14
15/**
16 * Swift FileEmbedder Plugin to embed remote files.
17 * Scans a Swift_Message instance for remote files and then embeds them before sending.
18 * This also embeds local files from disk.
19 * @package Swift_Plugin
20 * @author Chris Corbyn <chris@w3style.co.uk>
21 */
22class Swift_Plugin_FileEmbedder implements Swift_Events_BeforeSendListener
23{
24  /**
25   * True if remote files will be embedded.
26   * @var boolean
27   */
28  protected $embedRemoteFiles = true;
29  /**
30   * True if local files will be embedded.
31   * @var boolean
32   */
33  protected $embedLocalFiles = true;
34  /**
35   * (X)HTML tag defintions listing allowed attributes and extensions.
36   * @var array
37   */
38  protected $definitions = array(
39    "img" => array(
40      "attributes" => array("src"),
41      "extensions" => array("gif", "png", "jpg",  "jpeg", "pjpeg")
42    ),
43    "link" => array(
44      "attributes" => array("href"),
45      "extensions" => array("css")
46    ),
47    "script" => array(
48      "attributes" => array("src"),
49      "extensions" => array("js")
50    ));
51  /**
52   * Protocols which may be used to download a remote file.
53   * @var array
54   */
55  protected $protocols = array(
56    "http" => "http",
57    "https" => "https",
58    "ftp" => "ftp"
59  );
60  /**
61   * A PCRE regexp which will be passed via sprintf() to produce a complete pattern.
62   * @var string
63   */
64  protected $remoteFilePatternFormat = "~
65    (<(?:%s)\\s+[^>]*?                   #Opening tag followed by (possible) attributes
66    (?:%s)=((?:\"|')?))                  #Permitted attributes followed by (possible) quotation marks
67    ((?:%s)://[\\x01-\\x7F]*?(?:%s)?)    #Remote URL (matching a permitted protocol)
68    (\\2[^>]*>)                          #Remaining attributes followed by end of tag
69    ~isx";
70  /**
71   * A PCRE regexp which will be passed via sprintf() to produce a complete pattern.
72   * @var string
73   */
74  protected $localFilePatternFormat = "~
75    (<(?:%s)\\s+[^>]*?                               #Opening tag followed by (possible) attributes
76    (?:%s)=((?:\"|')?))                              #Permitted attributes followed by (possible) quotation marks
77    ((?:/|[a-z]:\\\\|[a-z]:/)[\\x01-\\x7F]*?(?:%s)?) #Local, absolute path
78    (\\2[^>]*>)                                      #Remaining attributes followed by end of tag
79    ~isx";
80  /**
81   * A list of extensions mapping to their usual MIME types.
82   * @var array
83   */
84  protected $mimeTypes = array(
85    "gif" => "image/gif",
86    "png" => "image/png",
87    "jpeg" => "image/jpeg",
88    "jpg" => "image/jpeg",
89    "pjpeg" => "image/pjpeg",
90    "js" => "text/javascript",
91    "css" => "text/css");
92  /**
93   * Child IDs of files already embedded.
94   * @var array
95   */
96  protected $registeredFiles = array();
97
98  /**
99   * Get the MIME type based upon the extension.
100   * @param string The extension (sans the dot).
101   * @return string
102   */
103  public function getType($ext)
104  {
105    $ext = strtolower($ext);
106    if (isset($this->mimeTypes[$ext]))
107    {
108      return $this->mimeTypes[$ext];
109    }
110    else return null;
111  }
112  /**
113   * Add a new MIME type defintion (or overwrite an existing one).
114   * @param string The extension (sans the dot)
115   * @param string The MIME type (e.g. image/jpeg)
116   */
117  public function addType($ext, $type)
118  {
119    $this->mimeTypes[strtolower($ext)] = strtolower($type);
120  }
121  /**
122   * Set the PCRE pattern which finds -full- HTML tags and copies the path for a local file into a backreference.
123   * The pattern contains three %s replacements for sprintf().
124   * First replacement is the tag name (e.g. img)
125   * Second replacement is the attribute name (e.g. src)
126   * Third replacement is the file extension (e.g. jpg)
127   * This pattern should contain the full URL in backreference index 3.
128   * @param string sprintf() format string containing a PCRE regexp.
129   */
130  public function setLocalFilePatternFormat($format)
131  {
132    $this->localFilePatternFormat = $format;
133  }
134  /**
135   * Gets the sprintf() format string for the PCRE pattern to scan for remote files.
136   * @return string
137   */
138  public function getLocalFilePatternFormat()
139  {
140    return $this->localFilePatternFormat;
141  }
142  /**
143   * Set the PCRE pattern which finds -full- HTML tags and copies the URL for the remote file into a backreference.
144   * The pattern contains four %s replacements for sprintf().
145   * First replacement is the tag name (e.g. img)
146   * Second replacement is the attribute name (e.g. src)
147   * Third replacement is the protocol (e.g. http)
148   * Fourth replacement is the file extension (e.g. jpg)
149   * This pattern should contain the full URL in backreference index 3.
150   * @param string sprintf() format string containing a PCRE regexp.
151   */
152  public function setRemoteFilePatternFormat($format)
153  {
154    $this->remoteFilePatternFormat = $format;
155  }
156  /**
157   * Gets the sprintf() format string for the PCRE pattern to scan for remote files.
158   * @return string
159   */
160  public function getRemoteFilePatternFormat()
161  {
162    return $this->remoteFilePatternFormat;
163  }
164  /**
165   * Add a new protocol which can be used to download files.
166   * Protocols should not include the "://" portion.  This method expects alphanumeric characters only.
167   * @param string The protocol name (e.g. http or ftp)
168   */
169  public function addProtocol($prot)
170  {
171    $prot = strtolower($prot);
172    $this->protocols[$prot] = $prot;
173  }
174  /**
175   * Remove a protocol from the list of allowed protocols once added.
176   * @param string The name of the protocol (e.g. http)
177   */
178  public function removeProtocol($prot)
179  {
180    unset($this->protocols[strtolower($prot)]);
181  }
182  /**
183   * Get a list of all registered protocols.
184   * @return array
185   */
186  public function getProtocols()
187  {
188    return array_values($this->protocols);
189  }
190  /**
191   * Add, or modify a tag definition.
192   * This affects how the plugins scans for files to download.
193   * @param string The name of a tag to search for (e.g. img)
194   * @param string The name of attributes to look for (e.g. src).  You can pass an array if there are multiple possibilities.
195   * @param array A list of extensions to allow (sans dot). If there's only one you can just pass a string.
196   */
197  public function setTagDefinition($tag, $attributes, $extensions)
198  {
199    $tag = strtolower($tag);
200    $attributes = (array)$attributes;
201    $extensions = (array)$extensions;
202
203    if (empty($tag) || empty($attributes) || empty($extensions))
204    {
205      return null;
206    }
207
208    $this->definitions[$tag] = array("attributes" => $attributes, "extensions" => $extensions);
209    return true;
210  }
211  /**
212   * Remove a tag definition for remote files.
213   * @param string The name of the tag
214   */
215  public function removeTagDefinition($tag)
216  {
217    unset($this->definitions[strtolower($tag)]);
218  }
219  /**
220   * Get a tag definition.
221   * Returns an array with indexes "attributes" and "extensions".
222   * Each element is an array listing the values within it.
223   * @param string The name of the tag
224   * @return array
225   */
226  public function getTagDefinition($tag)
227  {
228    $tag = strtolower($tag);
229    if (isset($this->definitions[$tag])) return $this->definitions[$tag];
230    else return null;
231  }
232  /**
233   * Get the PCRE pattern for a remote file based on the tag name.
234   * @param string The name of the tag
235   * @return string
236   */
237  public function getRemoteFilePattern($tag_name)
238  {
239    $tag_name = strtolower($tag_name);
240    $pattern_format = $this->getRemoteFilePatternFormat();
241    if ($def = $this->getTagDefinition($tag_name))
242    {
243      $pattern = sprintf($pattern_format, $tag_name, implode("|", $def["attributes"]),
244        implode("|", $this->getProtocols()), implode("|", $def["extensions"]));
245      return $pattern;
246    }
247    else return null;
248  }
249  /**
250   * Get the PCRE pattern for a local file based on the tag name.
251   * @param string The name of the tag
252   * @return string
253   */
254  public function getLocalFilePattern($tag_name)
255  {
256    $tag_name = strtolower($tag_name);
257    $pattern_format = $this->getLocalFilePatternFormat();
258    if ($def = $this->getTagDefinition($tag_name))
259    {
260      $pattern = sprintf($pattern_format, $tag_name, implode("|", $def["attributes"]),
261        implode("|", $def["extensions"]));
262      return $pattern;
263    }
264    else return null;
265  }
266  /**
267   * Register a file which has been downloaded so it doesn't need to be downloaded twice.
268   * @param string The remote URL
269   * @param string The ID as attached in the message
270   * @param Swift_Message_EmbeddedFile The file object itself
271   */
272  public function registerFile($url, $cid, $file)
273  {
274    $url = strtolower($url);
275    if (!isset($this->registeredFiles[$url])) $this->registeredFiles[$url] = array("cids" => array(), "obj" => null);
276    $this->registeredFiles[$url]["cids"][] = $cid;
277    if (empty($this->registeredFiles[$url]["obj"])) $this->registeredFiles[$url]["obj"] = $file;
278  }
279  /**
280   * Turn on or off remote file embedding.
281   * @param boolean
282   */
283  public function setEmbedRemoteFiles($set)
284  {
285    $this->embedRemoteFiles = (bool)$set;
286  }
287  /**
288   * Returns true if remote files can be embedded, or false if not.
289   * @return boolean
290   */
291  public function getEmbedRemoteFiles()
292  {
293    return $this->embedRemoteFiles;
294  }
295  /**
296   * Turn on or off local file embedding.
297   * @param boolean
298   */
299  public function setEmbedLocalFiles($set)
300  {
301    $this->embedLocalFiles = (bool)$set;
302  }
303  /**
304   * Returns true if local files can be embedded, or false if not.
305   * @return boolean
306   */
307  public function getEmbedLocalFiles()
308  {
309    return $this->embedLocalFiles;
310  }
311  /**
312   * Callback method for preg_replace().
313   * Embeds files which have been found during scanning.
314   * @param array Backreferences from preg_replace()
315   * @return string The tag with it's URL replaced with a CID
316   */
317  protected function embedRemoteFile($matches)
318  {
319    $url = preg_replace("~^([^#]+)#.*\$~s", "\$1", $matches[3]);
320    $bits = parse_url($url);
321    $ext = preg_replace("~^.*?\\.([^\\.]+)\$~s", "\$1", $bits["path"]);
322
323    $lower_url = strtolower($url);
324    if (array_key_exists($lower_url, $this->registeredFiles))
325    {
326      $registered = $this->registeredFiles[$lower_url];
327      foreach ($registered["cids"] as $cid)
328      {
329        if ($this->message->hasChild($cid))
330        {
331          return $matches[1] . $cid . $matches[4];
332        }
333      }
334      //If we get here the file is downloaded, but not embedded
335      $cid = $this->message->attach($registered["obj"]);
336      $this->registerFile($url, $cid, $registered["obj"]);
337      return $matches[1] . $cid . $matches[4];
338    }
339    $magic_quotes = get_magic_quotes_runtime();
340    set_magic_quotes_runtime(0);
341    $filedata = @file_get_contents($url);
342    set_magic_quotes_runtime($magic_quotes);
343    if (!$filedata)
344    {
345      return $matches[1] . $matches[3] . $matches[4];
346    }
347    $filename = preg_replace("~^.*/([^/]+)\$~s", "\$1", $url);
348    $att = new Swift_Message_EmbeddedFile($filedata, $filename, $this->getType($ext));
349    $id = $this->message->attach($att);
350    $this->registerFile($url, $id, $att);
351    return $matches[1] . $id . $matches[4];
352  }
353  /**
354   * Callback method for preg_replace().
355   * Embeds files which have been found during scanning.
356   * @param array Backreferences from preg_replace()
357   * @return string The tag with it's path replaced with a CID
358   */
359  protected function embedLocalFile($matches)
360  {
361    $path = realpath($matches[3]);
362    if (!$path)
363    {
364      return $matches[1] . $matches[3] . $matches[4];
365    }
366    $ext = preg_replace("~^.*?\\.([^\\.]+)\$~s", "\$1", $path);
367
368    $lower_path = strtolower($path);
369    if (array_key_exists($lower_path, $this->registeredFiles))
370    {
371      $registered = $this->registeredFiles[$lower_path];
372      foreach ($registered["cids"] as $cid)
373      {
374        if ($this->message->hasChild($cid))
375        {
376          return $matches[1] . $cid . $matches[4];
377        }
378      }
379      //If we get here the file is downloaded, but not embedded
380      $cid = $this->message->attach($registered["obj"]);
381      $this->registerFile($path, $cid, $registered["obj"]);
382      return $matches[1] . $cid . $matches[4];
383    }
384    $filename = basename($path);
385    $att = new Swift_Message_EmbeddedFile(new Swift_File($path), $filename, $this->getType($ext));
386    $id = $this->message->attach($att);
387    $this->registerFile($path, $id, $att);
388    return $matches[1] . $id . $matches[4];
389  }
390  /**
391   * Empty out the cache of registered files.
392   */
393  public function clearCache()
394  {
395    $this->registeredFiles = null;
396    $this->registeredFiles = array();
397  }
398  /**
399   * Swift's BeforeSendListener required method.
400   * Runs just before Swift sends a message.  Here is where we do all the replacements.
401   * @param Swift_Events_SendEvent
402   */
403  public function beforeSendPerformed(Swift_Events_SendEvent $e)
404  {
405    $this->message = $e->getMessage();
406
407    foreach ($this->message->listChildren() as $id)
408    {
409      $part = $this->message->getChild($id);
410      $body = $part->getData();
411      if (!is_string($body) || substr(strtolower($part->getContentType()), 0, 5) != "text/") continue;
412
413      foreach ($this->definitions as $tag_name => $def)
414      {
415        if ($this->getEmbedRemoteFiles())
416        {
417          $re = $this->getRemoteFilePattern($tag_name);
418          $body = preg_replace_callback($re, array($this, "embedRemoteFile"), $body);
419        }
420
421        if ($this->getEmbedLocalFiles())
422        {
423          $re = $this->getLocalFilePattern($tag_name);
424          $body = preg_replace_callback($re, array($this, "embedLocalFile"), $body);
425        }
426      }
427
428      $part->setData($body);
429    }
430  }
431}
432