CVE-2017-20174 (PHP, published 2023-01-19T08:15Z)
Description: value: A vulnerability was found in bastianallgeier Kirby Webmentions Plugin and classified as problematic. Affected by this issue is some unknown functionality. The manipulation leads to injection. The attack may be launched remotely. The complexity of an attack is rather high. The exploitation is known to be difficult. The patch is identified as 55bedea78ae9af916a9a41497bd9996417851502. It is recommended to apply a patch to fix this issue. VDB-218894 is the identifier assigned to this vulnerability.
Severity: nan (CVSS v3 base score: 9.8)
Weakness: CWE-74 \u2014 Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')
The product constructs all or part of a command, data structure, or record using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify how it is parsed or interpreted when it is sent to a downstream component.
Repository: https://github.com/bastianallgeier/kirby-webmentions
Files changed:
helpers.php
lib/author.php
lib/endpoint.php
lib/mention.php
lib/mentions.php
readme.md
snippets/author.php
snippets/mentions.php
snippets/types/like.php
snippets/types/mention.php
snippets/types/reply.php
snippets/types/repost.php
vendor/comments.php
vendor/mf2.php
webmentions.php
Commit message: Fixed remote image vulnerability

Vulnerable code:

Fixed code:
<?php 

function webmentions($options = array()) {

  $webmentions = new Kirby\Webmentions\Mentions($options); 
  $webmentions->ping();

  return $webmentions;

}

// like tag
kirbytext::$tags['like'] = array(

  'attr' => array(
    'text',
    'title',
    'rel',
    'target',
    'popup'
  ),
  'html' => function($tag) {

    return kirbytag(array(
      'link'   => $tag->attr('like'),
      'text'   => $tag->attr('text'),
      'title'  => $tag->attr('title'),
      'rel'    => $tag->attr('rel'),
      'target' => $tag->attr('target'),
      'popup'  => $tag->attr('popup'),
      'class'  => 'u-like-of',
    ));

  }
);
<?php

namespace Kirby\Webmentions;

use A;
use Field;
use Obj;
use F;
use Media;
use Str;
use Url;
use C;
use Remote;
use Tpl;
use V;

class Author extends Obj {

  public $mention = null;
  public $data    = array();
  public $page    = null;
  public $name    = null;
  public $url     = null;
  public $photo   = null;

  public function __construct($mention) {

    $this->mention = $mention;
    $this->data    = $mention->data['author'];
    $this->page    = $mention->page;

    if(empty($this->data['url'])) {
      $this->data['url'] = $this->mention->data['url'];
    }

    if(empty($this->data['name'])) {
      $this->data['name'] = url::short(url::base($this->data['url']));
    }

    $this->field('url');
    $this->field('name');

  }

  public function field($key, $field = null) {
    if(is_null($field)) $field = $key;

    $value = a::get($this->data, $field);

    if($key == 'url' and !v::url($value)) {
      $value = null;
    }

    $this->$key = new Field($this->page, $key, esc($value));
  }

  public function photo() {

    if(!is_null($this->photo)) return $this->photo;

    $extension = f::extension($this->data['photo']);
    $filename  = rtrim(sha1($this->url) . '.' . $extension, '.');    
    $path      = c::get('webmentions.images', 'assets/images/mentions');
    $root      = kirby()->roots()->index() . DS . str_replace('/', DS, $path) . DS . $filename;
    $url       = kirby()->urls()->index() . '/' . $path . '/' . $filename;
    $photo     = new Media($root, $url);
    
    // only allow images with a correct extension
    if(in_array(strtolower($extension), ['jpg', 'jpeg', 'png', 'gif']) && !$photo->exists()) {    

      $image   = remote::get($this->data['photo']);
      $mime    = a::get($image->headers, 'Content-Type');      
      $allowed = array('image/jpeg', 'image/png', 'image/gif');
      
      // check the mime type of the remote request response
      if(in_array($mime, $allowed)) {

        // write the file to disk
        f::write($root, $image->content());
  
        // check the mime type and file size again to make sure it's all ok
        if(!in_array($photo->mime(), $allowed) or $photo->size() == 0) {
          $photo->delete();
        }        
      
      }

    }

    if(!$photo->exists() || $photo->type() !== 'image') {
      $photo = new Obj(array(
        'url'    => $this->data['photo'],
        'exists' => false
      ));
    }

    return $this->photo = $photo;    

  }

  public function toHtml() {

    $snippet = kirby()->roots()->snippets() . DS . 'webmentions' . DS . 'author.php';

    if(!file_exists($snippet)) {
      $snippet = dirname(__DIR__) . DS . 'snippets' . DS . 'author.php';
    }

    return tpl::load($snippet, array(
      'author'  => $this,
      'mention' => $this->mention
    ));

  }

  public function __toString() {
    return (string)$this->toHtml();
  }

}
<?php

namespace Kirby\Webmentions;

use Header;
use V;
use Str;
use Response;
use F;
use R;
use Exception;
use Tpl;

class Endpoint {

  public function __construct() {

    $endpoint = $this;

    if($page = page('webmention') and kirby()->path() == $page->uri()) {

      if(r::is('post')) {

        try {
          $endpoint->start();
          header::status(202);        
          tpl::set('status', 'success');
          tpl::set('alert', null);
        } catch(Exception $e) {
          header::status(400);        
          tpl::set('status', 'error');
          tpl::set('alert', $e->getMessage());
        }

      } else {
        tpl::set('status', 'idle');        
      }

    } else {

      kirby()->routes(array(
        array(
          'pattern' => 'webmention', 
          'method'  => 'GET|POST', 
          'action'  => function() use($endpoint) {        

            try {
              $endpoint->start();        
              echo response::success('Yay', 202);
            } catch(Exception $e) {
              echo response::error($e->getMessage());
            }
          }
        )
      ));

    }

  }

  public function start() {

    $src    = get('source');
    $target = get('target');

    if(!v::url($src)) {
      throw new Exception('Invalid source');
    }

    if(!v::url($target)) {
      throw new Exception('Invalid target');
    }

    if(!str::contains($target, site()->url())) {
      throw new Exception('Invalid target');
    }

    require_once(dirname(__DIR__) . DS . 'vendor' . DS . 'mf2.php');
    require_once(dirname(__DIR__) . DS . 'vendor' . DS . 'comments.php');

    $data   = \Mf2\fetch($src);
    $result = \IndieWeb\comments\parse($data['items'][0], $src);

    if(empty($result)) {
      throw new Exception('Probably spam');
    }

    $path = ltrim(str_replace(site()->url(), '', $target), '/');

    if(!empty($path) and $page = page($path)) {

      if(!empty($result['published'])) {
        $time = strtotime($result['published']);
      } else {
        $time = time();
        $result['published'] = date('c');
      }

      $json = json_encode($result);
      $hash = sha1($json);
      $file = $page->root() . DS . '.webmentions' . DS . $time . '-' . $hash . '.json';

      f::write($file, $json);

      return true;

    } else {
      throw new Exception('Invalid page');
    }

  }

}
<?php

namespace Kirby\Webmentions;

use A;
use Obj;
use Data;
use Field;
use Tpl;
use V;
use Exception;

class Mention extends Obj {

  public $data   = null;
  public $id     = null;
  public $page   = null;
  public $file   = null;
  public $author = null;
  public $title  = null;
  public $text   = null;
  public $date   = null;
  public $url    = null;

  public function __construct($page, $file) {

    $data = data::read($file);

    if(!is_array($data) or empty($data)) {
      throw new Exception('Invalid web mention');
    }

    if(empty($data['url'])) {
      throw new Exception('No url found');
    }


    $this->data   = $data;
    $this->page   = $page;
    $this->file   = $file;
    $this->author = new Author($this);
    $this->id     = sha1($file);

    $this->convertTwitterFavorite();
    $this->convertTwitterRepost();

    $this->field('title', 'name');
    $this->field('text');
    $this->field('url');
    $this->field('type');
    $this->field('rsvp');

    $this->date = new Field($this->page, 'date', strtotime($data['published']));      

  } 

  public function convertTwitterFavorite() {

    if(!empty($this->data['url']) and preg_match('!https:\/\/twitter.com\/(.*?)\/status!', $this->data['url'])) {
      
      if(!empty($this->data['name']) and $this->data['name'] == 'likes this.') {
        $this->data['type'] = 'like';        
      } 

    }

  }

  public function convertTwitterRepost() {

    if(!empty($this->data['url']) and preg_match('!https:\/\/twitter.com\/(.*?)\/status!', $this->data['url'])) {
      
      if(!empty($this->data['name']) and $this->data['name'] == 'reposts this.') {
        $this->data['type'] = 'repost';        
      } 

    }

  }


  public function field($key, $field = null) {

    if(is_null($field)) $field = $key;

    $value = a::get($this->data, $field);

    if($key == 'url' and !v::url($value)) {
      $value = null;
    }

    $this->$key = new Field($this->page, $key, esc($value));
  }


  public function is($type) {
    return $this->type->value == $type;
  }

  public function date($format = null) {
    if($format) {
      $handler = kirby()->option('date.handler', 'date');
      return $handler($format, $this->date->value);
    } else { 
      return $this->published;
    }
  }

  public function toHtml() {

    $snippet = kirby()->roots()->snippets() . DS . 'webmentions' . DS . 'types' . DS . $this->type() . '.php';

    if(!file_exists($snippet)) {
      $snippet = dirname(__DIR__) . DS . 'snippets' . DS . 'types' . DS . $this->type() . '.php';
    }

    return tpl::load($snippet, array(
      'mention' => $this,
      'author'  => $this->author
    ));

  }

  public function __toString() {
    return (string)$this->toHtml();
  }

}

<?php

namespace Kirby\Webmentions;

use Field;
use Collection;
use Dir;
use Page;
use Data;
use Str;
use V;
use Remote;
use Tpl;
use Exception;

class Mentions extends Collection {

  public $options  = array();
  public $page     = null;
  public $root     = null;
  public $headline = null;

  public function __construct($params = array()) {

    $defaults = array(
      'page'     => page(),
      'headline' => 'Mentions'
    );

    if(is_a($params, 'Page')) {
      $params = array('page' => $params);
    } else if(is_string($params)) {
      $params = array('headline' => $params);
    }

    $this->options  = array_merge($defaults, $params);
    $this->page     = $this->options['page'];
    $this->root     = $this->page->root() . DS . '.webmentions';
    $this->headline = new Field($this->page, 'headline', $this->options['headline']);

    if(!is_dir($this->root)) return;

    $files = dir::read($this->root); 

    // flip direction
    rsort($files);

    foreach($files as $file) {

      // skip the pings cache
      if($file == 'pings.json') continue;

      // register a new webmention

      try {
        $mention = new Mention($this->page, $this->root . DS . $file);
        $this->append($mention->id(), $mention);        
      } catch(Exception $e) {

      }

    }

  }

  public function ping() {

    // check for an existing ping cache
    $cache = $this->root . DS . 'pings.json';

    if(file_exists($cache)) return;

    // The Regular Expression filter
    $expression = "/(http|https|ftp|ftps)\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(\/\S*)?/";
    $triggered  = array();
    $endpoints  = array();

    // Check if there is a url in the text
    if(preg_match_all($expression, (string)$this->page->text(), $urls)) {
      foreach($urls[0] as $url) {      
        if(!in_array($url, $triggered)) {
          if($endpoint = $this->trigger($url)) {
            $endpoints[] = $endpoint;
          }
          $triggered[] = $url;
        }
      }

    } 

    data::write($cache, $endpoints);

  }

  public function trigger($url) {

    $response = remote::get($url);
    $html     = $response->content();

    if(preg_match_all('!\<link(.*?)\>!i', $html, $links)) {

      foreach($links[0] as $link) {

        if(!str::contains($link, 'rel="webmention"')) {
          continue;
        }
        
        if(!preg_match('!href="(.*?)"!', $link, $match)) {
          continue;
        }

        $endpoint = $match[1];
        $src      = $this->page->url();
        $target   = $url;

        // invalid endpoint
        if(!v::url($endpoint)) {
          continue;
        }

        remote::post($endpoint, array(
          'data' => array(
            'source' => $src,
            'target' => $target
          )
        ));

        return $endpoint;
                
      }

    }

  }

  public function toHtml() {

    $snippet = dirname(__DIR__) . DS . 'snippets' . DS . 'mentions.php';

    return tpl::load($snippet, array(
      'mentions'  => $this,
      'likes'     => $this->filterBy('type', 'like'),
      'replies'   => $this->filterBy('type', 'reply'),
      'mentions'  => $this->filterBy('type', 'mention'),
      'reposts'   => $this->filterBy('type', 'repost'),
      'headline'  => $this->headline
    ));

  }

  public function __toString() {
    return (string)$this->toHtml();
  }

}
# Kirby Webmentions Plugin

This Kirby plugin provides an easy way to add [webmentions](http://indiewebcamp.com/Webmention) to your site. [Download the plugin](https://github.com/bastianallgeier/kirby-webmentions/archive/master.zip) from Github and put it into /site/plugins. It will automatically be loaded by Kirby. 

To enable webmentions in a template, you can use the webmentions() helper:

```php
<?php echo webmentions() ?>
```

Additionally you have to specify your new webmention endpoint in the header of your site:

```
<link rel="webmention" href="<?php echo url('webmention') ?>">
```

Your site is now able to receive webmentions. 

## Author

Bastian Allgeier
<bastian@getkirby.com>
<figure class="webmention-author">
  <a title="<?php echo $author->name() ?>" class="webmention-author-photo" href="<?php echo $author->url() ?>">
    <?php if($author->photo()->exists()): ?>
    <img src="<?php echo $author->photo()->url() ?>" alt="<?php echo $author->name() ?>">
    <?php endif ?>
  </a>
  <figcaption class="webmention-author-meta">
    <a href="<?php echo $author->url() ?>" class="webmention-author-name"><?php echo $author->name() ?></a>
    <time class="webmention-author-date" datetime="<?php echo $mention->date('c') ?>">
      <a href="<?php echo $mention->url() ?>"><?php echo $mention->date('d M Y - h:i') ?></a>
    </time>
  </figcaption>
</figure>
<div class="webmentions">

  <?php if($reposts->count()): ?>
  <section class="webmentions-reposts">
    <h1><?php echo $reposts->count() ?> reposts</h1>
    <?php foreach($reposts as $repost): ?>
    <?php echo $repost ?>
    <?php endforeach ?>
  </section>
  <?php endif ?>

  <?php if($likes->count()): ?>
  <section class="webmentions-likes">
    <h1><?php echo $likes->count() ?> likes</h1>
    <?php foreach($likes as $like): ?>
    <?php echo $like ?>
    <?php endforeach ?>
  </section>
  <?php endif ?>

  <?php if($replies->count()): ?>
  <section class="webmentions-replies">

    <h1><?php echo $replies->count() ?> Responses</h1>
    <?php foreach($replies as $reply): ?> 
    <?php echo $reply ?>
    <?php endforeach ?>

  </section>
  <?php endif ?>

  <?php if($mentions->count()): ?>
  <section class="webmentions-mentions">

    <h1><?php echo $mentions->count() ?> Mentions</h1>
    <?php foreach($mentions as $mention): ?> 
    <?php echo $mention ?>
    <?php endforeach ?>

  </section>
  <?php endif ?>

</div>
<article class="webmention webmention-like">
  <?php echo $author ?>
</article>
<article class="webmention webmention-mention">
  <?php echo $author ?>
  <?php if(!$mention->text()->isEmpty()): ?>
  <div class="webmention-text">
    <p><?php echo $mention->text()->excerpt(200) ?></p>
    <a class="webmention-continue" href="<?php echo $mention->url() ?>">Continue reading</a>
  </div>
  <?php endif ?>
</article>
<article class="webmention webmention-reply">
  <?php echo $author ?>
  <?php if($mention->text()->isNotEmpty()): ?>
  <div class="webmention-text">
    <p><?php echo $mention->text()->excerpt(200) ?></p>
    <a class="webmention-continue" href="<?php echo $mention->url() ?>">Continue reading</a>
  </div>
  <?php endif ?>
</article>
<?php require(__DIR__ . DS . 'like.php') ?>
<?php

namespace IndieWeb\comments;

function truncateString($text, $length) {
  ob_start();
  $short = \str::short($text, $length);
  ob_end_clean();
  return $short;
}

function truncate($text, $maxTextLength, $maxLines) {
  $lines = explode("\n", $text);
  $visibleLines = array_filter($lines);
  if(count($visibleLines) > $maxLines) {
    $newContent = array();
    $visibleLinesAdded = 0;
    $i = 0;
    while($visibleLinesAdded < $maxLines && $i < count($lines)) {
      $line = $lines[$i];
      $newContent[] = $line;
      if(trim($line) != '')
        $visibleLinesAdded++;
      $i++;
    }
    $text = implode("\n", $newContent);
    // Tack on extra chars and then tell cassis to ellipsize it shorter to take advantage of proper ellipsizing logic.
    // This is for when the full text is shorter than $maxTextLength but has more lines than $maxLines
    $text .= ' ....';
    $text = truncateString($text, min($maxTextLength, strlen($text)-1));
  } else {
    $text = truncateString($text, $maxTextLength);
  }
  return $text;
}

function removeScheme(&$url) {
  if(is_array($url)) {
    foreach($url as $i=>$u) {
      removeScheme($url[$i]);
    }
  } else {
    $url = preg_replace('/^https?/', '', $url);
  }
}

function parse($mf, $refURL=false, $maxTextLength=150, $maxLines=2) {
  // When parsing a comment, the $refURL is the URL being commented on.
  // This is used to check for an explicit in-reply-to property set to this URL.

  // Remove the scheme from the refURL and treat http and https links as the same
  removeScheme($refURL);

  $type = 'mention';
  $published = false;
  $name = false;
  $text = false;
  $url = false;
  $author = array(
    'name' => false,
    'photo' => false,
    'url' => false
  );
  $rsvp = null;

  if(array_key_exists('type', $mf) && in_array('h-entry', $mf['type']) && array_key_exists('properties', $mf)) {
    $properties = $mf['properties'];

    if(array_key_exists('author', $properties)) {
      $authorProperty = $properties['author'][0];
      if(is_array($authorProperty)) {

        if(array_key_exists('name', $authorProperty['properties'])) {
          $author['name'] = $authorProperty['properties']['name'][0];
        }

        if(array_key_exists('url', $authorProperty['properties'])) {
          $author['url'] = $authorProperty['properties']['url'][0];
        }

        if(array_key_exists('photo', $authorProperty['properties'])) {
          $author['photo'] = $authorProperty['properties']['photo'][0];
        }

      } elseif(is_string($authorProperty)) {
        $author['url'] = $authorProperty;
      }
    }

    if(array_key_exists('published', $properties)) {
      $published = $properties['published'][0];
    }

    if(array_key_exists('url', $properties)) {
      $url = $properties['url'][0];
    }

    // If the post has an explicit in-reply-to property, verify it matches $refURL and set the type to "reply"
    if($refURL && array_key_exists('in-reply-to', $properties)) {
      // in-reply-to may be a string or an h-cite
      foreach($properties['in-reply-to'] as $check) {
        removeScheme($check);
        if(is_string($check) && $check == $refURL) {
          $type = 'reply';
          continue;
        } elseif(is_array($check)) {
          if(array_key_exists('type', $check) && in_array('h-cite', $check['type'])) {
            if(array_key_exists('properties', $check) && array_key_exists('url', $check['properties'])) {
              if(in_array($refURL, $check['properties']['url'])) {
                $type = 'reply';
              }
            }
          }
        }
      }
    }

    // Check if the reply is an RSVP
    if(array_key_exists('rsvp', $properties)) {
      $rsvp = $properties['rsvp'][0];
      $type = 'rsvp';
    }

    // Check if the reply is an invitation
    if(array_key_exists('invitee', $properties)) {
      $inviteeProperty = $properties['invitee'][0];
      if(is_array($inviteeProperty)) {

        if(array_key_exists('name', $inviteeProperty['properties'])) {
          $invitee['name'] = $inviteeProperty['properties']['name'][0];
        }

        if(array_key_exists('url', $inviteeProperty['properties'])) {
          $invitee['url'] = $inviteeProperty['properties']['url'][0];
        }

        if(array_key_exists('photo', $inviteeProperty['properties'])) {
          $invitee['photo'] = $inviteeProperty['properties']['photo'][0];
        }

      } elseif(is_string($inviteeProperty)) {
        $invitee['url'] = $inviteeProperty;
      }
      $type = 'invite';
    }

    // Check if this post is a "repost"
    if($refURL && array_key_exists('repost-of', $properties)) {
      removeScheme($properties['repost-of']);
      if(in_array($refURL, $properties['repost-of']))
        $type = 'repost';
    }

    // Also check for "u-repost" since some people are sending that. Probably "u-repost-of" will win out.
    if($refURL && array_key_exists('repost', $properties)) {
      removeScheme($properties['repost']);
      if(in_array($refURL, $properties['repost']))
        $type = 'repost';
    }

    if($refURL && array_key_exists('like-of', $properties)) {
      removeScheme($properties['like-of']);
      if(in_array($refURL, $properties['like-of']))
        $type = 'like';
    }

    // Check if this post is a "like"
    if($refURL && array_key_exists('like', $properties)) {
      removeScheme($properties['like']);
      if(in_array($refURL, $properties['like']))
        $type = 'like';
    }

    // From http://indiewebcamp.com/comments-presentation#How_to_display

    // If the entry has an e-content, and if the content is not too long, use that
    if(array_key_exists('content', $properties)) {
      $content = $properties['content'][0];
      if ((is_array($content) && array_key_exists('value', $content)) || is_string($content)) {
        if (is_array($content)) {
          $content = $content['value'];
        }

        $visibleLines = array_filter(explode("\n", $content));
        if(strlen($content) <= $maxTextLength && count($visibleLines) <= $maxLines) {
          $text = $content;
        }
      }
      // If the content is not a string or array with “value”, something is wrong.
    }

    // If there is no e-content, or if it is too long
    if($text == false) {
      // if the h-entry has a p-summary, and the text is not too long, use that
      if(array_key_exists('summary', $properties)) {
        $summary = $properties['summary'][0];
        if(is_array($summary) && array_key_exists('value', $summary))
          $summary = $summary['value'];

        if(strlen($summary) <= $maxTextLength) {
          $text = $summary;
        } else {
          // if the p-summary is too long, then truncate the p-summary
          $text = truncate($summary, $maxTextLength, $maxLines);
        }
      } else {
        // if no p-summary, but there is an e-content, use a truncated e-content
        if(array_key_exists('content', $properties)) {
          // $content already exists from line 127, and is guaranteed to be a string.
          $text = truncate($content, $maxTextLength, $maxLines);
        }
      }
    }

    // If there is no e-content and no p-summary
    if($text == false) {
      // If there is a p-name, and it's not too long, use that
      if(array_key_exists('name', $properties)) {
        $pname = $properties['name'][0];
        if(strlen($pname) <= $maxTextLength) {
          $text = $pname;
        } else {
          // if the p-name is too long, truncate it
          $text = truncate($pname, $maxTextLength, $maxLines);
        }
      }
    }

    // Now see if the "name" property of the h-entry is unique or part of the content
    if(array_key_exists('name', $properties)) {
      $nameSanitized = strtolower(strip_tags($properties['name'][0]));
      $nameSanitized = preg_replace('/ ?\.+$/', '', $nameSanitized); // Remove trailing ellipses
      // Using the already truncated version of the content here. But the "name" would not have been truncated so may be longer than the content.
      $contentSanitized = strtolower(strip_tags($text));
      $contentSanitized = preg_replace('/ ?\.+$/', '', $contentSanitized); // Remove trailing ellipses

      // If this is a "mention" instead of a "reply", and if there is no "content" property,
      // then we actually want to use the "name" property as the name and leave "text" blank.
      if($type == 'mention' && !array_key_exists('content', $properties)) {
        $name = $properties['name'][0];
        $text = false;
      } else {
        if($nameSanitized != $contentSanitized and $nameSanitized !== '') {
          // If the name is the beginning of the content, we don't care
          // Same if the content is the beginning of the name (like with really long notes)
          if(!(strpos($contentSanitized, $nameSanitized) === 0) && !(strpos($nameSanitized, $contentSanitized) === 0)) {
            // The name was determined to be different from the content, so return it
            $name = $properties['name'][0]; //truncate($properties['name'][0], $maxTextLength, $maxLines);
          }
        }
      }
    }

  }

  $result = array(
    'author' => $author,
    'published' => $published,
    'name' => $name,
    'text' => $text,
    'url' => $url,
    'type' => $type
  );

  if($type == 'invite') 
    $result['invitee'] = $invitee;

  if($rsvp !== null) {
    $result['rsvp'] = $rsvp;
  }

  return $result;
}

<?php

namespace Mf2;

use DOMDocument;
use DOMElement;
use DOMXPath;
use DOMNode;
use DOMNodeList;
use Exception;
use SplObjectStorage;
use stdClass;

/**
 * Parse Microformats2
 * 
 * Functional shortcut for the commonest cases of parsing microformats2 from HTML.
 * 
 * Example usage:
 * 
 *     use Mf2;
 *     $output = Mf2\parse('<span class="h-card">Barnaby Walters</span>');
 *     echo json_encode($output, JSON_PRETTY_PRINT);
 * 
 * Produces:
 * 
 *     {
 *      "items": [
 *       {
 *        "type": ["h-card"],
 *        "properties": {
 *         "name": ["Barnaby Walters"]
 *        }
 *       }
 *      ],
 *      "rels": {}
 *     }
 * 
 * @param string|DOMDocument $input The HTML string or DOMDocument object to parse
 * @param string $url The URL the input document was found at, for relative URL resolution
 * @param bool $convertClassic whether or not to convert classic microformats
 * @return array Canonical MF2 array structure
 */
function parse($input, $url = null, $convertClassic = true) {
	$parser = new Parser($input, $url);
	return $parser->parse($convertClassic);
}

/**
 * Fetch microformats2
 *
 * Given a URL, fetches it (following up to 5 redirects) and, if the content-type appears to be HTML, returns the parsed
 * microformats2 array structure.
 *
 * Not that even if the response code was a 4XX or 5XX error, if the content-type is HTML-like then it will be parsed
 * all the same, as there are legitimate cases where error pages might contain useful microformats (for example a deleted
 * h-entry resulting in a 410 Gone page with a stub h-entry explaining the reason for deletion). Look in $curlInfo['http_code']
 * for the actual value.
 *
 * @param string $url The URL to fetch
 * @param bool $convertClassic (optional, default true) whether or not to convert classic microformats
 * @param &array $curlInfo (optional) the results of curl_getinfo will be placed in this variable for debugging
 * @return array|null canonical microformats2 array structure on success, null on failure
 */
function fetch($url, $convertClassic = true, &$curlInfo=null) {
	$ch = curl_init();
	curl_setopt($ch, CURLOPT_URL, $url);
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
	curl_setopt($ch, CURLOPT_HEADER, 0);
	curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
	curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
	$html = curl_exec($ch);
	$info = $curlInfo = curl_getinfo($ch);
	curl_close($ch);

	if (strpos(strtolower($info['content_type']), 'html') === false) {
		// The content was not delivered as HTML, do not attempt to parse it.
		return null;
	}

	return parse($html, $url, $convertClassic);
}

/**
 * Unicode to HTML Entities
 * @param string $input String containing characters to convert into HTML entities
 * @return string 
 */
function unicodeToHtmlEntities($input) {
	return mb_convert_encoding($input, 'HTML-ENTITIES', mb_detect_encoding($input));
}

/**
 * Collapse Whitespace
 * 
 * Collapses any sequences of whitespace within a string into a single space
 * character.
 * 
 * @deprecated since v0.2.3
 * @param string $str
 * @return string
 */
function collapseWhitespace($str) {
	return preg_replace('/[\s|\n]+/', ' ', $str);
}

function unicodeTrim($str) {
	// this is cheating. TODO: find a better way if this causes any problems
	$str = str_replace(mb_convert_encoding('&nbsp;', 'UTF-8', 'HTML-ENTITIES'), ' ', $str);
	$str = preg_replace('/^\s+/', '', $str);
	return preg_replace('/\s+$/', '', $str);
}

/**
 * Microformat Name From Class string
 * 
 * Given the value of @class, get the relevant mf classnames (e.g. h-card, 
 * p-name).
 * 
 * @param string $class A space delimited list of classnames
 * @param string $prefix The prefix to look for
 * @return string|array The prefixed name of the first microfomats class found or false
 */
function mfNamesFromClass($class, $prefix='h-') {
	$class = str_replace(array(' ', '	', "\n"), ' ', $class);
	$classes = explode(' ', $class);
	$matches = array();

	foreach ($classes as $classname) {
		$compare_classname = ' ' . $classname;
		$compare_prefix = ' ' . $prefix;
		if (strstr($compare_classname, $compare_prefix) !== false && ($compare_classname != $compare_prefix)) {
			$matches[] = ($prefix === 'h-') ? $classname : substr($classname, strlen($prefix));
		}
	}

	return $matches;
}

/**
 * Get Nested µf Property Name From Class
 * 
 * Returns all the p-, u-, dt- or e- prefixed classnames it finds in a 
 * space-separated string.
 * 
 * @param string $class
 * @return array
 */
function nestedMfPropertyNamesFromClass($class) {
	$prefixes = array('p-', 'u-', 'dt-', 'e-');
	$propertyNames = array();

	$class = str_replace(array(' ', '	', "\n"), ' ', $class);
	foreach (explode(' ', $class) as $classname) {
		foreach ($prefixes as $prefix) {
			$compare_classname = strtolower(' ' . $classname);
			if (stristr($compare_classname, $prefix) && ($compare_classname != $prefix)) {
				$propertyNames = array_merge($propertyNames, mfNamesFromClass($classname, ltrim($prefix)));
			}
		}
	}

	return $propertyNames;
}

/**
 * Wraps mfNamesFromClass to handle an element as input (common)
 * 
 * @param DOMElement $e The element to get the classname for
 * @param string $prefix The prefix to look for
 * @return mixed See return value of mf2\Parser::mfNameFromClass()
 */
function mfNamesFromElement(\DOMElement $e, $prefix = 'h-') {
	$class = $e->getAttribute('class');
	return mfNamesFromClass($class, $prefix);
}

/**
 * Wraps nestedMfPropertyNamesFromClass to handle an element as input
 */
function nestedMfPropertyNamesFromElement(\DOMElement $e) {
	$class = $e->getAttribute('class');
	return nestedMfPropertyNamesFromClass($class);
}

/**
 * Converts various time formats to HH:MM
 * @param string $time The time to convert
 * @return string
 */
function convertTimeFormat($time) {
	$hh = $mm = $ss = '';
	preg_match('/(\d{1,2}):?(\d{2})?:?(\d{2})?(a\.?m\.?|p\.?m\.?)?/i', $time, $matches);

	// If no am/pm is specified:
	if (empty($matches[4])) {
		return $time;
	} else {
		// Otherwise, am/pm is specified.
		$meridiem = strtolower(str_replace('.', '', $matches[4]));

		// Hours.
		$hh = $matches[1];

		// Add 12 to hours if pm applies.
		if ($meridiem == 'pm' && ($hh < 12)) {
			$hh += 12;
		}

		$hh = str_pad($hh, 2, '0', STR_PAD_LEFT);

		// Minutes.
		$mm = (empty($matches[2]) ) ? '00' : $matches[2];

		// Seconds, only if supplied.
		if (!empty($matches[3])) {
			$ss = $matches[3];
		}

		if (empty($ss)) {
			return sprintf('%s:%s', $hh, $mm);
		}
		else {
			return sprintf('%s:%s:%s', $hh, $mm, $ss);
		}
	}
}

/**
 * Microformats2 Parser
 * 
 * A class which holds state for parsing microformats2 from HTML.
 * 
 * Example usage:
 * 
 *     use Mf2;
 *     $parser = new Mf2\Parser('<p class="h-card">Barnaby Walters</p>');
 *     $output = $parser->parse();
 */
class Parser {
	/** @var string The baseurl (if any) to use for this parse */
	public $baseurl;

	/** @var DOMXPath object which can be used to query over any fragment*/
	public $xpath;
	
	/** @var DOMDocument */
	public $doc;
	
	/** @var SplObjectStorage */
	protected $parsed;
	
	public $jsonMode;

	/**
	 * Constructor
	 * 
	 * @param DOMDocument|string $input The data to parse. A string of HTML or a DOMDocument
	 * @param string $url The URL of the parsed document, for relative URL resolution
	 * @param boolean $jsonMode Whether or not to use a stdClass instance for an empty `rels` dictionary. This breaks PHP looping over rels, but allows the output to be correctly serialized as JSON.
	 */
	public function __construct($input, $url = null, $jsonMode = false) {
		libxml_use_internal_errors(true);
		if (is_string($input)) {
			$doc = new DOMDocument();
			@$doc->loadHTML(unicodeToHtmlEntities($input));
		} elseif (is_a($input, 'DOMDocument')) {
			$doc = $input;
		} else {
			$doc = new DOMDocument();
			@$doc->loadHTML('');
		}
		
		$this->xpath = new DOMXPath($doc);
		
		$baseurl = $url;
		foreach ($this->xpath->query('//base[@href]') as $base) {
			$baseElementUrl = $base->getAttribute('href');
			
			if (parse_url($baseElementUrl, PHP_URL_SCHEME) === null) {
				/* The base element URL is relative to the document URL.
				 *
				 * :/
				 *
				 * Perhaps the author was high? */
				
				$baseurl = resolveUrl($url, $baseElementUrl);
			} else {
				$baseurl = $baseElementUrl;
			}
			break;
		}

		// Ignore <template> elements as per the HTML5 spec
		foreach ($this->xpath->query('//template') as $templateEl) {
			$templateEl->parentNode->removeChild($templateEl);
		}
		
		$this->baseurl = $baseurl;
		$this->doc = $doc;
		$this->parsed = new SplObjectStorage();
		$this->jsonMode = $jsonMode;
	}
	
	private function elementPrefixParsed(\DOMElement $e, $prefix) {
		if (!$this->parsed->contains($e))
			$this->parsed->attach($e, array());
		
		$prefixes = $this->parsed[$e];
		$prefixes[] = $prefix;
		$this->parsed[$e] = $prefixes;
	}
	
	private function isElementParsed(\DOMElement $e, $prefix) {
		if (!$this->parsed->contains($e))
			return false;
		
		$prefixes = $this->parsed[$e];
		
		if (!in_array($prefix, $prefixes))
			return false;
		
		return true;
	}

	private function resolveChildUrls(DOMElement $el) {
		$hyperlinkChildren = $this->xpath->query('.//*[@src or @href or @data]', $el);

		foreach ($hyperlinkChildren as $child) {
			if ($child->hasAttribute('href'))
				$child->setAttribute('href', $this->resolveUrl($child->getAttribute('href')));
			if ($child->hasAttribute('src'))
				$child->setAttribute('src', $this->resolveUrl($child->getAttribute('src')));
			if ($child->hasAttribute('data'))
				$child->setAttribute('data', $this->resolveUrl($child->getAttribute('data')));
		}
	}

	public function textContent(DOMElement $el) {
		$this->resolveChildUrls($el);

		$clonedEl = $el->cloneNode(true);

		foreach ($this->xpath->query('.//img', $clonedEl) as $imgEl) {
			$newNode = $this->doc->createTextNode($imgEl->getAttribute($imgEl->hasAttribute('alt') ? 'alt' : 'src'));
			$imgEl->parentNode->replaceChild($newNode, $imgEl);
		}

		return $clonedEl->textContent;
	}

	// TODO: figure out if this has problems with sms: and geo: URLs
	public function resolveUrl($url) {
		// If the URL is seriously malformed it’s probably beyond the scope of this 
		// parser to try to do anything with it.
		if (parse_url($url) === false)
			return $url;
		
		$scheme = parse_url($url, PHP_URL_SCHEME);
		
		if (empty($scheme) and !empty($this->baseurl)) {
			return resolveUrl($this->baseurl, $url);
		} else {
			return $url;
		}
	}
	
	// Parsing Functions
	
	/**
	 * Parse value-class/value-title on an element, joining with $separator if 
	 * there are multiple.
	 * 
	 * @param \DOMElement $e
	 * @param string $separator = '' if multiple value-title elements, join with this string
	 * @return string|null the parsed value or null if value-class or -title aren’t in use
	 */
	public function parseValueClassTitle(\DOMElement $e, $separator = '') {
		$valueClassElements = $this->xpath->query('./*[contains(concat(" ", @class, " "), " value ")]', $e);
		
		if ($valueClassElements->length !== 0) {
			// Process value-class stuff
			$val = '';
			foreach ($valueClassElements as $el) {
				$val .= $this->textContent($el);
			}
			
			return unicodeTrim($val);
		}
		
		$valueTitleElements = $this->xpath->query('./*[contains(concat(" ", @class, " "), " value-title ")]', $e);
		
		if ($valueTitleElements->length !== 0) {
			// Process value-title stuff
			$val = '';
			foreach ($valueTitleElements as $el) {
				$val .= $el->getAttribute('title');
			}
			
			return unicodeTrim($val);
		}
		
		// No value-title or -class in this element
		return null;
	}
	
	/**
	 * Given an element with class="p-*", get it’s value
	 * 
	 * @param DOMElement $p The element to parse
	 * @return string The plaintext value of $p, dependant on type
	 * @todo Make this adhere to value-class
	 */
	public function parseP(\DOMElement $p) {
		$classTitle = $this->parseValueClassTitle($p, ' ');
		
		if ($classTitle !== null)
			return $classTitle;
		
		if ($p->tagName == 'img' and $p->getAttribute('alt') !== '') {
			$pValue = $p->getAttribute('alt');
		} elseif ($p->tagName == 'area' and $p->getAttribute('alt') !== '') {
			$pValue = $p->getAttribute('alt');
		} elseif ($p->tagName == 'abbr' and $p->getAttribute('title') !== '') {
			$pValue = $p->getAttribute('title');
		} elseif (in_array($p->tagName, array('data', 'input')) and $p->getAttribute('value') !== '') {
			$pValue = $p->getAttribute('value');
		} else {
			$pValue = unicodeTrim($this->textContent($p));
		}
		
		return $pValue;
	}

	/**
	 * Given an element with class="u-*", get the value of the URL
	 * 
	 * @param DOMElement $u The element to parse
	 * @return string The plaintext value of $u, dependant on type
	 * @todo make this adhere to value-class
	 */
	public function parseU(\DOMElement $u) {
		if (($u->tagName == 'a' or $u->tagName == 'area') and $u->getAttribute('href') !== null) {
			$uValue = $u->getAttribute('href');
		} elseif ($u->tagName == 'img' and $u->getAttribute('src') !== null) {
			$uValue = $u->getAttribute('src');
		} elseif ($u->tagName == 'object' and $u->getAttribute('data') !== null) {
			$uValue = $u->getAttribute('data');
		}
		
		if (isset($uValue)) {
			return $this->resolveUrl($uValue);
		}
		
		$classTitle = $this->parseValueClassTitle($u);
		
		if ($classTitle !== null) {
			return $classTitle;
		} elseif ($u->tagName == 'abbr' and $u->getAttribute('title') !== null) {
			return $u->getAttribute('title');
		} elseif (in_array($u->tagName, array('data', 'input')) and $u->getAttribute('value') !== null) {
			return $u->getAttribute('value');
		} else {
			return unicodeTrim($this->textContent($u));
		}
	}

	/**
	 * Given an element with class="dt-*", get the value of the datetime as a php date object
	 * 
	 * @param DOMElement $dt The element to parse
	 * @param array $dates Array of dates processed so far
	 * @return string The datetime string found
	 */
	public function parseDT(\DOMElement $dt, &$dates = array()) {
		// Check for value-class pattern
		$valueClassChildren = $this->xpath->query('./*[contains(concat(" ", @class, " "), " value ") or contains(concat(" ", @class, " "), " value-title ")]', $dt);
		$dtValue = false;
		
		if ($valueClassChildren->length > 0) {
			// They’re using value-class
			$dateParts = array();
			
			foreach ($valueClassChildren as $e) {
				if (strstr(' ' . $e->getAttribute('class') . ' ', ' value-title ')) {
					$title = $e->getAttribute('title');
					if (!empty($title))
						$dateParts[] = $title;
				}
				elseif ($e->tagName == 'img' or $e->tagName == 'area') {
					// Use @alt
					$alt = $e->getAttribute('alt');
					if (!empty($alt))
						$dateParts[] = $alt;
				}
				elseif ($e->tagName == 'data') {
					// Use @value, otherwise innertext
					$value = $e->hasAttribute('value') ? $e->getAttribute('value') : unicodeTrim($e->nodeValue);
					if (!empty($value))
						$dateParts[] = $value;
				}
				elseif ($e->tagName == 'abbr') {
					// Use @title, otherwise innertext
					$title = $e->hasAttribute('title') ? $e->getAttribute('title') : unicodeTrim($e->nodeValue);
					if (!empty($title))
						$dateParts[] = $title;
				}
				elseif ($e->tagName == 'del' or $e->tagName == 'ins' or $e->tagName == 'time') {
					// Use @datetime if available, otherwise innertext
					$dtAttr = ($e->hasAttribute('datetime')) ? $e->getAttribute('datetime') : unicodeTrim($e->nodeValue);
					if (!empty($dtAttr))
						$dateParts[] = $dtAttr;
				}
				else {
					if (!empty($e->nodeValue))
						$dateParts[] = unicodeTrim($e->nodeValue);
				}
			}

			// Look through dateParts
			$datePart = '';
			$timePart = '';
			foreach ($dateParts as $part) {
				// Is this part a full ISO8601 datetime?
				if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:Z?[+|-]\d{2}:?\d{2})?$/', $part)) {
					// Break completely, we’ve got our value.
					$dtValue = $part;
					break;
				} else {
					// Is the current part a valid time(+TZ?) AND no other time representation has been found?
					if ((preg_match('/\d{1,2}:\d{1,2}(Z?[+|-]\d{2}:?\d{2})?/', $part) or preg_match('/\d{1,2}[a|p]m/', $part)) and empty($timePart)) {
						$timePart = $part;
					} elseif (preg_match('/\d{4}-\d{2}-\d{2}/', $part) and empty($datePart)) {
						// Is the current part a valid date AND no other date representation has been found?
						$datePart = $part;
					}

					if ( !empty($datePart) && !in_array($datePart, $dates) ) {
						$dates[] = $datePart;
					}

					$dtValue = '';

					if ( empty($datePart) && !empty($timePart) ) {
						$timePart = convertTimeFormat($timePart);
						$dtValue = unicodeTrim($timePart, 'T');
					}
					else if ( !empty($datePart) && empty($timePart) ) {
						$dtValue = rtrim($datePart, 'T');
					}
					else {
						$timePart = convertTimeFormat($timePart);
						$dtValue = rtrim($datePart, 'T') . 'T' . unicodeTrim($timePart, 'T');
					}
				}
			}
		} else {
			// Not using value-class (phew).
			if ($dt->tagName == 'img' or $dt->tagName == 'area') {
				// Use @alt
				// Is it an entire dt?
				$alt = $dt->getAttribute('alt');
				if (!empty($alt))
					$dtValue = $alt;
			} elseif (in_array($dt->tagName, array('data'))) {
				// Use @value, otherwise innertext
				// Is it an entire dt?
				$value = $dt->getAttribute('value');
				if (!empty($value))
					$dtValue = $value;
				else
					$dtValue = $dt->nodeValue;
			} elseif ($dt->tagName == 'abbr') {
				// Use @title, otherwise innertext
				// Is it an entire dt?
				$title = $dt->getAttribute('title');
				if (!empty($title))
					$dtValue = $title;
				else
					$dtValue = $dt->nodeValue;
			} elseif ($dt->tagName == 'del' or $dt->tagName == 'ins' or $dt->tagName == 'time') {
				// Use @datetime if available, otherwise innertext
				// Is it an entire dt?
				$dtAttr = $dt->getAttribute('datetime');
				if (!empty($dtAttr))
					$dtValue = $dtAttr;
				else
					$dtValue = $dt->nodeValue;
			} else {
				$dtValue = $dt->nodeValue;
			}

			if (preg_match('/(\d{4}-\d{2}-\d{2})/', $dtValue, $matches)) {
				$dates[] = $matches[0];
			}
		}

		/**
		 * if $dtValue is only a time and there are recently parsed dates, 
		 * form the full date-time using the most recently parsed dt- value
		 */
		if ((preg_match('/^\d{1,2}:\d{1,2}(Z?[+|-]\d{2}:?\d{2})?/', $dtValue) or preg_match('/^\d{1,2}[a|p]m/', $dtValue)) && !empty($dates)) {
			$dtValue = convertTimeFormat($dtValue);
			$dtValue = end($dates) . 'T' . unicodeTrim($dtValue, 'T');
		}

		return $dtValue;
	}

	/**
	 * 	Given the root element of some embedded markup, return a string representing that markup
	 *
	 * 	@param DOMElement $e The element to parse
	 * 	@return string $e’s innerHTML
	 * 
	 * @todo need to mark this element as e- parsed so it doesn’t get parsed as it’s parent’s e-* too
	 */
	public function parseE(\DOMElement $e) {
		$classTitle = $this->parseValueClassTitle($e);
		
		if ($classTitle !== null)
			return $classTitle;
		
		// Expand relative URLs within children of this element
		// TODO: as it is this is not relative to only children, make this .// and rerun tests
		$this->resolveChildUrls($e);

		$html = '';
		foreach ($e->childNodes as $node) {
			$html .= $node->C14N();
		}
		
		return array(
			'html' => $html,
			'value' => unicodeTrim($this->textContent($e))
		);
	}

	/**
	 * Recursively parse microformats
	 * 
	 * @param DOMElement $e The element to parse
	 * @return array A representation of the values contained within microformat $e
	 */
	public function parseH(\DOMElement $e) {
		// If it’s already been parsed (e.g. is a child mf), skip
		if ($this->parsed->contains($e))
			return null;

		// Get current µf name
		$mfTypes = mfNamesFromElement($e, 'h-');

		// Initalise var to store the representation in
		$return = array();
		$children = array();
		$dates = array();

		// Handle nested microformats (h-*)
		foreach ($this->xpath->query('.//*[contains(concat(" ", @class)," h-")]', $e) as $subMF) {
			// Parse
			$result = $this->parseH($subMF);
			
			// If result was already parsed, skip it
			if (null === $result)
				continue;
			
			$result['value'] = $this->parseP($subMF);

			// Does this µf have any property names other than h-*?
			$properties = nestedMfPropertyNamesFromElement($subMF);
			
			if (!empty($properties)) {
				// Yes! It’s a nested property µf
				foreach ($properties as $property) {
					$return[$property][] = $result;
				}
			} else {
				// No, it’s a child µf
				$children[] = $result;
			}
			
			// Make sure this sub-mf won’t get parsed as a µf or property
			// TODO: Determine if clearing this is required?
			$this->elementPrefixParsed($subMF, 'h');
			$this->elementPrefixParsed($subMF, 'p');
			$this->elementPrefixParsed($subMF, 'u');
			$this->elementPrefixParsed($subMF, 'dt');
			$this->elementPrefixParsed($subMF, 'e');
		}

		if($e->tagName == 'area') {
			$coords = $e->getAttribute('coords');
			$shape = $e->getAttribute('shape');
		}

		// Handle p-*
		foreach ($this->xpath->query('.//*[contains(concat(" ", @class) ," p-")]', $e) as $p) {
			if ($this->isElementParsed($p, 'p'))
				continue;

			$pValue = $this->parseP($p);
			
			// Add the value to the array for it’s p- properties
			foreach (mfNamesFromElement($p, 'p-') as $propName) {
				if (!empty($propName))
					$return[$propName][] = $pValue;
			}
			
			// Make sure this sub-mf won’t get parsed as a top level mf
			$this->elementPrefixParsed($p, 'p');
		}

		// Handle u-*
		foreach ($this->xpath->query('.//*[contains(concat(" ",  @class)," u-")]', $e) as $u) {
			if ($this->isElementParsed($u, 'u'))
				continue;
			
			$uValue = $this->parseU($u);
			
			// Add the value to the array for it’s property types
			foreach (mfNamesFromElement($u, 'u-') as $propName) {
				$return[$propName][] = $uValue;
			}
			
			// Make sure this sub-mf won’t get parsed as a top level mf
			$this->elementPrefixParsed($u, 'u');
		}
		
		// Handle dt-*
		foreach ($this->xpath->query('.//*[contains(concat(" ", @class), " dt-")]', $e) as $dt) {
			if ($this->isElementParsed($dt, 'dt'))
				continue;
			
			$dtValue = $this->parseDT($dt, $dates);
			
			if ($dtValue) {
				// Add the value to the array for dt- properties
				foreach (mfNamesFromElement($dt, 'dt-') as $propName) {
					$return[$propName][] = $dtValue;
				}
			}
			
			// Make sure this sub-mf won’t get parsed as a top level mf
			$this->elementPrefixParsed($dt, 'dt');
		}

		// Handle e-*
		foreach ($this->xpath->query('.//*[contains(concat(" ", @class)," e-")]', $e) as $em) {
			if ($this->isElementParsed($em, 'e'))
				continue;

			$eValue = $this->parseE($em);

			if ($eValue) {
				// Add the value to the array for e- properties
				foreach (mfNamesFromElement($em, 'e-') as $propName) {
					$return[$propName][] = $eValue;
				}
			}
			// Make sure this sub-mf won’t get parsed as a top level mf
			$this->elementPrefixParsed($em, 'e');
		}

		// Implied Properties
		// Check for p-name
		if (!array_key_exists('name', $return)) {
			try {
				// Look for img @alt
				if (($e->tagName == 'img' or $e->tagName == 'area') and $e->getAttribute('alt') != '')
					throw new Exception($e->getAttribute('alt'));
				
				if ($e->tagName == 'abbr' and $e->hasAttribute('title'))
					throw new Exception($e->getAttribute('title'));
				
				// Look for nested img @alt
				foreach ($this->xpath->query('./img[count(preceding-sibling::*)+count(following-sibling::*)=0]', $e) as $em) {
					$emNames = mfNamesFromElement($em, 'h-');
					if (empty($emNames) && $em->getAttribute('alt') != '') {
						throw new Exception($em->getAttribute('alt'));
					}
				}

				// Look for nested area @alt
				foreach ($this->xpath->query('./area[count(preceding-sibling::*)+count(following-sibling::*)=0]', $e) as $em) {
					$emNames = mfNamesFromElement($em, 'h-');
					if (empty($emNames) && $em->getAttribute('alt') != '') {
						throw new Exception($em->getAttribute('alt'));
					}
				}


				// Look for double nested img @alt
				foreach ($this->xpath->query('./*[count(preceding-sibling::*)+count(following-sibling::*)=0]/img[count(preceding-sibling::*)+count(following-sibling::*)=0]', $e) as $em) {
					$emNames = mfNamesFromElement($em, 'h-');
					if (empty($emNames) && $em->getAttribute('alt') != '') {
						throw new Exception($em->getAttribute('alt'));
					}
				}

				// Look for double nested img @alt
				foreach ($this->xpath->query('./*[count(preceding-sibling::*)+count(following-sibling::*)=0]/area[count(preceding-sibling::*)+count(following-sibling::*)=0]', $e) as $em) {
					$emNames = mfNamesFromElement($em, 'h-');
					if (empty($emNames) && $em->getAttribute('alt') != '') {
						throw new Exception($em->getAttribute('alt'));
					}
				}

				throw new Exception($e->nodeValue);
			} catch (Exception $exc) {
				$return['name'][] = unicodeTrim($exc->getMessage());
			}
		}

		// Check for u-photo
		if (!array_key_exists('photo', $return)) {
			// Look for img @src
			try {
				if ($e->tagName == 'img')
					throw new Exception($e->getAttribute('src'));

				// Look for nested img @src
				foreach ($this->xpath->query('./img[count(preceding-sibling::*)+count(following-sibling::*)=0]', $e) as $em) {
					if ($em->getAttribute('src') != '')
						throw new Exception($em->getAttribute('src'));
				}

				// Look for double nested img @src
				foreach ($this->xpath->query('./*[count(preceding-sibling::*)+count(following-sibling::*)=0]/img[count(preceding-sibling::*)+count(following-sibling::*)=0]', $e) as $em) {
					if ($em->getAttribute('src') != '')
						throw new Exception($em->getAttribute('src'));
				}
			} catch (Exception $exc) {
				$return['photo'][] = $this->resolveUrl($exc->getMessage());
			}
		}

		// Check for u-url
		if (!array_key_exists('url', $return)) {
			// Look for img @src
			if ($e->tagName == 'a' or $e->tagName == 'area')
				$url = $e->getAttribute('href');
			
			// Look for nested a @href
			foreach ($this->xpath->query('./a[count(preceding-sibling::a)+count(following-sibling::a)=0]', $e) as $em) {
				$emNames = mfNamesFromElement($em, 'h-');
				if (empty($emNames)) {
					$url = $em->getAttribute('href');
					break;
				}
			}

			// Look for nested area @src
			foreach ($this->xpath->query('./area[count(preceding-sibling::area)+count(following-sibling::area)=0]', $e) as $em) {
				$emNames = mfNamesFromElement($em, 'h-');
				if (empty($emNames)) {
					$url = $em->getAttribute('href');
					break;
				}
			}
			
			if (!empty($url))
				$return['url'][] = $this->resolveUrl($url);
		}

		// Make sure things are in alphabetical order
		sort($mfTypes);
		
		// Phew. Return the final result.
		$parsed = array(
			'type' => $mfTypes,
			'properties' => $return
		);

		if (!empty($shape)) {
			$parsed['shape'] = $shape;
		}

		if (!empty($coords)) {
			$parsed['coords'] = $coords;
		}

		if (!empty($children)) {
			$parsed['children'] = array_values(array_filter($children));
		}
		return $parsed;
	}
	
	/**
	 * Parse Rels and Alternatives
	 * 
	 * Returns [$rels, $alternatives]. If the $rels value is to be empty, i.e. there are no links on the page 
	 * with a rel value *not* containing `alternate`, then the type of $rels depends on $this->jsonMode. If set
	 * to true, it will be a stdClass instance, optimising for JSON serialisation. Otherwise (the default case),
	 * it will be an empty array.
	 */
	public function parseRelsAndAlternates() {
		$rels = array();
		$alternates = array();
		
		// Iterate through all a, area and link elements with rel attributes
		foreach ($this->xpath->query('//*[@rel and @href]') as $hyperlink) {
			if ($hyperlink->getAttribute('rel') == '')
				continue;
			
			// Resolve the href
			$href = $this->resolveUrl($hyperlink->getAttribute('href'));
			
			// Split up the rel into space-separated values
			$linkRels = array_filter(explode(' ', $hyperlink->getAttribute('rel')));
			
			// If alternate in rels, create alternate structure, append
			if (in_array('alternate', $linkRels)) {
				$alt = array(
					'url' => $href,
					'rel' => implode(' ', array_diff($linkRels, array('alternate')))
				);
				if ($hyperlink->hasAttribute('media'))
					$alt['media'] = $hyperlink->getAttribute('media');
				
				if ($hyperlink->hasAttribute('hreflang'))
					$alt['hreflang'] = $hyperlink->getAttribute('hreflang');
				
				$alternates[] = $alt;
			} else {
				foreach ($linkRels as $rel) {
					$rels[$rel][] = $href;
				}
			}
		}
		
		if (empty($rels) and $this->jsonMode) {
			$rels = new stdClass();
		}
		
		return array($rels, $alternates);
	}
	
	/**
	 * Kicks off the parsing routine
	 * 
	 * If `$htmlSafe` is set, any angle brackets in the results from non e-* properties
	 * will be HTML-encoded, bringing all output to the same level of encoding.
	 * 
	 * If a DOMElement is set as the $context, only descendants of that element will
	 * be parsed for microformats.
	 * 
	 * @param bool $htmlSafe whether or not to html-encode non e-* properties. Defaults to false
	 * @param DOMElement $context optionally an element from which to parse microformats
	 * @return array An array containing all the µfs found in the current document
	 */
	public function parse($convertClassic = true, DOMElement $context = null) {
		$mfs = array();
		
		if ($convertClassic) {
			$this->convertLegacy();
		}
		
		$mfElements = null === $context
			? $this->xpath->query('//*[contains(concat(" ",	@class), " h-")]')
			: $this->xpath->query('.//*[contains(concat(" ",	@class), " h-")]', $context);
		
		// Parser microformats
		foreach ($mfElements as $node) {
			// For each microformat
			$result = $this->parseH($node);

			// Add the value to the array for this property type
			$mfs[] = $result;
		}
		
		// Parse rels
		list($rels, $alternates) = $this->parseRelsAndAlternates();
		
		$top = array(
			'items' => array_values(array_filter($mfs)),
			'rels' => $rels
		);
		
		if (count($alternates))
			$top['alternates'] = $alternates;
		
		return $top;
	}
	
	/**
	 * Parse From ID
	 * 
	 * Given an ID, parse all microformats which are children of the element with
	 * that ID.
	 * 
	 * Note that rel values are still document-wide.
	 * 
	 * If an element with the ID is not found, an empty skeleton mf2 array structure 
	 * will be returned.
	 * 
	 * @param string $id
	 * @param bool $htmlSafe = false whether or not to HTML-encode angle brackets in non e-* properties
	 * @return array
	 */
	public function parseFromId($id, $convertClassic=true) {
		$matches = $this->xpath->query("//*[@id='{$id}']");
		
		if (empty($matches))
			return array('items' => array(), 'rels' => array(), 'alternates' => array());
		
		return $this->parse($convertClassic, $matches->item(0));
	}

	/**
	 * Convert Legacy Classnames
	 * 
	 * Adds microformats2 classnames into a document containing only legacy
	 * semantic classnames.
	 * 
	 * @return Parser $this
	 */
	public function convertLegacy() {
		$doc = $this->doc;
		$xp = new DOMXPath($doc);
		
		// replace all roots
		foreach ($this->classicRootMap as $old => $new) {
			foreach ($xp->query('//*[contains(concat(" ", @class, " "), " ' . $old . ' ") and not(contains(concat(" ", @class, " "), " ' . $new . ' "))]') as $el) {
				$el->setAttribute('class', $el->getAttribute('class') . ' ' . $new);
			}
		}
		
		foreach ($this->classicPropertyMap as $oldRoot => $properties) {
			$newRoot = $this->classicRootMap[$oldRoot];
			foreach ($properties as $old => $new) {
				foreach ($xp->query('//*[contains(concat(" ", @class, " "), " ' . $oldRoot . ' ")]//*[contains(concat(" ", @class, " "), " ' . $old . ' ") and not(contains(concat(" ", @class, " "), " ' . $new . ' "))]') as $el) {
					$el->setAttribute('class', $el->getAttribute('class') . ' ' . $new);
				}
			}
		}
		
		return $this;
	}
	
	/**
	 * XPath Query
	 * 
	 * Runs an XPath query over the current document. Works in exactly the same
	 * way as DOMXPath::query.
	 * 
	 * @param string $expression
	 * @param DOMNode $context
	 * @return DOMNodeList
	 */
	public function query($expression, $context = null) {
		return $this->xpath->query($expression, $context);
	}
	
	/**
	 * Classic Root Classname map
	 */
	public $classicRootMap = array(
		'vcard' => 'h-card',
		'hfeed' => 'h-feed',
		'hentry' => 'h-entry',
		'hrecipe' => 'h-recipe',
		'hresume' => 'h-resume',
		'hevent' => 'h-event',
		'hreview' => 'h-review',
		'hproduct' => 'h-product'
	);
	
	public $classicPropertyMap = array(
		'vcard' => array(
			'fn' => 'p-name',
			'url' => 'u-url',
			'honorific-prefix' => 'p-honorific-prefix',
			'given-name' => 'p-given-name',
			'additional-name' => 'p-additional-name',
			'family-name' => 'p-family-name',
			'honorific-suffix' => 'p-honorific-suffix',
			'nickname' => 'p-nickname',
			'email' => 'u-email',
			'logo' => 'u-logo',
			'photo' => 'u-photo',
			'url' => 'u-url',
			'uid' => 'u-uid',
			'category' => 'p-category',
			'adr' => 'p-adr h-adr',
			'extended-address' => 'p-extended-address',
			'street-address' => 'p-street-address',
			'locality' => 'p-locality',
			'region' => 'p-region',
			'postal-code' => 'p-postal-code',
			'country-name' => 'p-country-name',
			'label' => 'p-label',
			'geo' => 'p-geo h-geo',
			'latitude' => 'p-latitude',
			'longitude' => 'p-longitude',
			'tel' => 'p-tel',
			'note' => 'p-note',
			'bday' => 'dt-bday',
			'key' => 'u-key',
			'org' => 'p-org',
			'organization-name' => 'p-organization-name',
			'organization-unit' => 'p-organization-unit',
		),
		'hentry' => array(
			'entry-title' => 'p-name',
			'entry-summary' => 'p-summary',
			'entry-content' => 'e-content',
			'published' => 'dt-published',
			'updated' => 'dt-updated',
			'author' => 'p-author h-card',
			'category' => 'p-category',
			'geo' => 'p-geo h-geo',
			'latitude' => 'p-latitude',
			'longitude' => 'p-longitude',
		),
		'hrecipe' => array(
			'fn' => 'p-name',
			'ingredient' => 'p-ingredient',
			'yield' => 'p-yield',
			'instructions' => 'e-instructions',
			'duration' => 'dt-duration',
			'nutrition' => 'p-nutrition',
			'photo' => 'u-photo',
			'summary' => 'p-summary',
			'author' => 'p-author h-card'
		),
		'hresume' => array(
			'summary' => 'p-summary',
			'contact' => 'h-card p-contact',
			'education' => 'h-event p-education',
			'experience' => 'h-event p-experience',
			'skill' => 'p-skill',
			'affiliation' => 'p-affiliation h-card',
		),
		'hevent' => array(
			'dtstart' => 'dt-start',
			'dtend' => 'dt-end',
			'duration' => 'dt-duration',
			'description' => 'p-description',
			'summary' => 'p-summary',
			'description' => 'p-description',
			'url' => 'u-url',
			'category' => 'p-category',
			'location' => 'h-card',
			'geo' => 'p-location h-geo'
		),
		'hreview' => array(
			'summary' => 'p-name',
			'fn' => 'p-item h-item p-name', // doesn’t work properly, see spec
			'photo' => 'u-photo', // of the item being reviewed (p-item h-item u-photo)
			'url' => 'u-url', // of the item being reviewed (p-item h-item u-url)
			'reviewer' => 'p-reviewer p-author h-card',
			'dtreviewed' => 'dt-reviewed',
			'rating' => 'p-rating',
			'best' => 'p-best',
			'worst' => 'p-worst',
			'description' => 'p-description'
		),
		'hproduct' => array(
			'fn' => 'p-name',
			'photo' => 'u-photo',
			'brand' => 'p-brand',
			'category' => 'p-category',
			'description' => 'p-description',
			'identifier' => 'u-identifier',
			'url' => 'u-url',
			'review' => 'p-review h-review',
			'price' => 'p-price'
		)
	);
}

function parseUriToComponents($uri) {
	$result = array(
		'scheme' => null,
		'authority' => null,
		'path' => null,
		'query' => null,
		'fragment' => null
	);

	$u = @parse_url($uri);

	if(array_key_exists('scheme', $u))
		$result['scheme'] = $u['scheme'];

	if(array_key_exists('host', $u)) {
		if(array_key_exists('user', $u))
			$result['authority'] = $u['user'];
		if(array_key_exists('pass', $u))
			$result['authority'] .= ':' . $u['pass'];
		if(array_key_exists('user', $u) || array_key_exists('pass', $u))
			$result['authority'] .= '@';
		$result['authority'] .= $u['host'];
		if(array_key_exists('port', $u))
			$result['authority'] .= ':' . $u['port'];
	}

	if(array_key_exists('path', $u))
		$result['path'] = $u['path'];

	if(array_key_exists('query', $u))
		$result['query'] = $u['query'];

	if(array_key_exists('fragment', $u))
		$result['fragment'] = $u['fragment'];

	return $result;
}

function resolveUrl($baseURI, $referenceURI) {
	$target = array(
		'scheme' => null,
		'authority' => null,
		'path' => null,
		'query' => null,
		'fragment' => null
	);

	# 5.2.1 Pre-parse the Base URI
	# The base URI (Base) is established according to the procedure of
  # Section 5.1 and parsed into the five main components described in
  # Section 3
	$base = parseUriToComponents($baseURI);

	# If base path is blank (http://example.com) then set it to /
	# (I can't tell if this is actually in the RFC or not, but seems like it makes sense)
	if($base['path'] == null)
		$base['path'] = '/';

	# 5.2.2. Transform References

	# The URI reference is parsed into the five URI components
	# (R.scheme, R.authority, R.path, R.query, R.fragment) = parse(R);
	$reference = parseUriToComponents($referenceURI);

	# A non-strict parser may ignore a scheme in the reference
	# if it is identical to the base URI's scheme.
	# TODO

	if($reference['scheme']) {
		$target['scheme'] = $reference['scheme'];
		$target['authority'] = $reference['authority'];
		$target['path'] = removeDotSegments($reference['path']);
		$target['query'] = $reference['query'];
	} else {
		if($reference['authority']) {
			$target['authority'] = $reference['authority'];
			$target['path'] = removeDotSegments($reference['path']);
			$target['query'] = $reference['query'];
		} else {
			if($reference['path'] == '') {
				$target['path'] = $base['path'];
				if($reference['query']) {
					$target['query'] = $reference['query'];
				} else {
					$target['query'] = $base['query'];
				}
			} else {
				if(substr($reference['path'], 0, 1) == '/') {
					$target['path'] = removeDotSegments($reference['path']);
				} else {
					$target['path'] = mergePaths($base, $reference);
					$target['path'] = removeDotSegments($target['path']);
				}
				$target['query'] = $reference['query'];
			}
			$target['authority'] = $base['authority'];
		}
		$target['scheme'] = $base['scheme'];
	}
	$target['fragment'] = $reference['fragment'];

	# 5.3 Component Recomposition
	$result = '';
	if($target['scheme']) {
		$result .= $target['scheme'] . ':';
	}
	if($target['authority']) {
		$result .= '//' . $target['authority'];
	}
	$result .= $target['path'];
	if($target['query']) {
		$result .= '?' . $target['query'];
	}
	if($target['fragment']) {
		$result .= '#' . $target['fragment'];
	} elseif($referenceURI == '#') {
		$result .= '#';
	}
	return $result;
}

# 5.2.3 Merge Paths
function mergePaths($base, $reference) {
	# If the base URI has a defined authority component and an empty
	#    path, 
	if($base['authority'] && $base['path'] == null) {
		# then return a string consisting of "/" concatenated with the
		# reference's path; otherwise,
		$merged = '/' . $reference['path'];
	} else {
		if(($pos=strrpos($base['path'], '/')) !== false) {
			# return a string consisting of the reference's path component
			#    appended to all but the last segment of the base URI's path (i.e.,
			#    excluding any characters after the right-most "/" in the base URI
			#    path,
			$merged = substr($base['path'], 0, $pos + 1) . $reference['path'];
		} else {
			#    or excluding the entire base URI path if it does not contain
			#    any "/" characters).
			$merged = $base['path'];
		}
	}
	return $merged;
}

# 5.2.4.A Remove leading ../ or ./
function removeLeadingDotSlash(&$input) {
	if(substr($input, 0, 3) == '../') {
		$input = substr($input, 3);
	} elseif(substr($input, 0, 2) == './') {
		$input = substr($input, 2);
	}
}

# 5.2.4.B Replace leading /. with /
function removeLeadingSlashDot(&$input) {
	if(substr($input, 0, 3) == '/./') {
		$input = '/' . substr($input, 3);
	} else {
		$input = '/' . substr($input, 2);
	}
}

# 5.2.4.C Given leading /../ remove component from output buffer
function removeOneDirLevel(&$input, &$output) {
	if(substr($input, 0, 4) == '/../') {
		$input = '/' . substr($input, 4);
	} else {
		$input = '/' . substr($input, 3);
	}
	$output = substr($output, 0, strrpos($output, '/'));
}

# 5.2.4.D Remove . and .. if it's the only thing in the input
function removeLoneDotDot(&$input) {
	if($input == '.') {
		$input = substr($input, 1);
	} else {
		$input = substr($input, 2);
	}
}

# 5.2.4.E Move one segment from input to output
function moveOneSegmentFromInput(&$input, &$output) {
	if(substr($input, 0, 1) != '/') {
		$pos = strpos($input, '/');
	} else {
		$pos = strpos($input, '/', 1);
	}

	if($pos === false) {
		$output .= $input;
		$input = '';
	} else {
		$output .= substr($input, 0, $pos);
		$input = substr($input, $pos);
	}
}

# 5.2.4 Remove Dot Segments
function removeDotSegments($path) {
	# 1.  The input buffer is initialized with the now-appended path
	#     components and the output buffer is initialized to the empty
	#     string.
	$input = $path;
	$output = '';

	$step = 0;

	# 2.  While the input buffer is not empty, loop as follows:
	while($input) {
		$step++;

		if(substr($input, 0, 3) == '../' || substr($input, 0, 2) == './') {
			#     A.  If the input buffer begins with a prefix of "../" or "./",
			#         then remove that prefix from the input buffer; otherwise,
			removeLeadingDotSlash($input);
		} elseif(substr($input, 0, 3) == '/./' || $input == '/.') {
			#     B.  if the input buffer begins with a prefix of "/./" or "/.",
			#         where "." is a complete path segment, then replace that
			#         prefix with "/" in the input buffer; otherwise,
			removeLeadingSlashDot($input);
		} elseif(substr($input, 0, 4) == '/../' || $input == '/..') {
			#     C.  if the input buffer begins with a prefix of "/../" or "/..",
			#          where ".." is a complete path segment, then replace that
			#          prefix with "/" in the input buffer and remove the last
			#          segment and its preceding "/" (if any) from the output
			#          buffer; otherwise,
			removeOneDirLevel($input, $output);
		} elseif($input == '.' || $input == '..') {
			#     D.  if the input buffer consists only of "." or "..", then remove
			#         that from the input buffer; otherwise,
			removeLoneDotDot($input);
		} else {
			#     E.  move the first path segment in the input buffer to the end of
			#         the output buffer and any subsequent characters up to, but not including,
			#         the next "/" character or the end of the input buffer
			moveOneSegmentFromInput($input, $output);
		}
	}

	return $output;
}
<?php 

load(array(
  'kirby\\webmentions\\endpoint' => __DIR__ . DS . 'lib' . DS . 'endpoint.php',
  'kirby\\webmentions\\mentions' => __DIR__ . DS . 'lib' . DS . 'mentions.php',
  'kirby\\webmentions\\mention'  => __DIR__ . DS . 'lib' . DS . 'mention.php',
  'kirby\\webmentions\\author'   => __DIR__ . DS . 'lib' . DS . 'author.php',
));

require(__DIR__ . DS . 'helpers.php');

new Kirby\Webmentions\Endpoint;