doc/dev/plugins/external_links/classes/ExternalLinks.php

506 lines
19 KiB
PHP
Raw Normal View History

<?php
/**
* External Links
*
* This file is part of Grav External Links plugin.
*
* Dual licensed under the MIT or GPL Version 3 licenses, see LICENSE.
* http://benjamin-regler.de/license/
*/
namespace Grav\Plugin;
use Grav\Common\Utils;
use Grav\Common\Grav;
/**
* External Links
*
* Helper class to add small icons to external and mailto links, informing
* users the link will take them to a new site or open their email client.
*/
class ExternalLinks
{
/**
* @var ExternalLinks
*/
/** -------------
* Public methods
* --------------
*/
/**
* Process contents i.e. apply filer to the content.
*
* @param string $content The content to render.
* @param array $options Options to be passed to the renderer.
* @param null|Page $page Null or an instance of \Grav\Common\Page.
*
* @return string The rendered contents.
*/
public function render($content, $options = [], $page = null)
{
// Get all <a> tags and process them
$content = preg_replace_callback('~<a(?:\s[^>]*)?>.*?</a>~i',
function($match) use ($options, $page) {
// Load PHP built-in DOMDocument class
if (($dom = $this->loadDOMDocument($match[0])) === null) {
return $match[0];
}
// Check that there is really a link tag
$a = $dom->getElementsByTagName('a');
if ($a->length == 0) {
return $match[0];
}
$a = $a->item(0);
// Process links with non-empty href attribute
$href = $a->getAttribute('href');
if (strlen($href) == 0) {
return $match[0];
}
// Get the class of the <a> element
$class = $a->hasAttribute('class') ? $a->getAttribute('class') : '';
$classes = array_filter(explode(' ', $class));
// Exclude links with specific class from processing
$exclude = $options->get('exclude.classes', null);
if ($exclude && !!array_intersect($exclude, $classes)) {
return $match[0];
}
// Get domains to be seen as internal
$domains = $options->get('exclude.domains', []);
// This is a mailto link.
if (strpos($href, 'mailto:') === 0) {
$classes[] = 'mailto';
}
// The link is external
elseif ($url = $this->isExternalUrl($href, $domains, $page)) {
// Add external class
$classes[] = 'external-link';
$a->setAttribute('href', $url);
// Add target="_blank"
$target = $options->get('target');
if ($target) {
$a->setAttribute('target', $target);
}
// Add no-follow.
$nofollow = $options->get('no_follow');
if ($nofollow) {
$rel = array_filter(explode(' ', $a->getAttribute('rel')));
if (!in_array('nofollow', $rel)) {
$rel[] = 'nofollow';
$a->setAttribute('rel', implode(' ', $rel));
}
}
// Set rel="noopener noreferrer"
$rel = $a->hasAttribute('rel') ? $a->getAttribute('rel') : '';
$rel = array_filter(explode(' ', $rel));
$rel[] = 'noopener';
$rel[] = 'noreferrer';
$a->setAttribute('rel', implode(' ', array_unique($rel)));
// Add image class to <a> if it has at least one <img> child element
$imgs = $a->getElementsByTagName('img');
if ($imgs->length > 1) {
// Add "images" class to <a> element, if it has multiple child images
$classes[] = 'images';
} elseif ($imgs->length == 1) {
$imgNode = $imgs->item(0);
// Get image size
list($width, $height) = $this->getImageSize($imgNode);
// Determine maximum dimension of image size
$size = max($width, $height);
// Depending on size determine image type
$classes[] = ((0 < $size) && ($size <= 32)) ? 'icon' : 'image';
} else {
// Add "no-image" class to <a> element, if it has no child images
$classes[] = 'no-image';
}
// Add title (aka alert text)
if ($options->get('title')) {
$language = Grav::instance()['language'];
$message = $language->translate(['PLUGINS.EXTERNAL_LINKS.TITLE_MESSAGE']);
// Set default title to link else, set title as data attribute
$key = $a->hasAttribute('title') ? 'data-title' : 'title';
$a->setAttribute($key, $message);
}
}
// Set class attribute
if (count($classes) && ($options->get('mode') === 'active')) {
$a->setAttribute('class', implode(' ', $classes));
}
// Save Dom document back to HTML representation
$html = $this->saveDOMDocument($dom);
return $html;
}, $content);
// Write content back to page
return $content;
}
/** -------------------------------
* Private/protected helper methods
* --------------------------------
*/
/**
* Test if a URL is external
*
* @param string $url The URL to test.
* @param array $domains An array of domains to be seen as internal.
* @param null|Page $page Null or an instance of \Grav\Common\Page.
*
* @return mixed Returns the URL as a string, if it is external,
* false otherwise.
*/
protected function isExternalUrl($url, $domains = [], $page = null)
{
static $allowed_protocols;
static $pattern;
/** @var Config $config */
$config = Grav::instance()['config'];
/** @var Page $page */
$page = $page ?: Grav::instance()['page'];
// Statically store allowed protocols
if (!isset($allowed_protocols)) {
$allowed_protocols = array_flip(
$config->get('plugins.external_links.links.schemes', ['http', 'https'])
);
}
// Statically store internal domains as a PCRE pattern.
if (!isset($pattern) || (count($domains) > 0)) {
$domains = array_merge($domains,
array(Grav::instance()['base_url_absolute']));
foreach ($domains as $domain) {
$domains[] = preg_quote($domain, '#');
}
$pattern = '#(' . str_replace(array('\*', '/*'), '.*?',
implode('|', $domains)) . ')#i';
}
$external = false;
// Check for URLs that don't match any excluded domain
if (!preg_match($pattern, $url)) {
// Check if URL is external by extracting colon position
$colonpos = strpos($url, ':');
if ($colonpos > 0) {
// We found a colon, possibly a protocol. Verify.
$protocol = strtolower(substr($url, 0, $colonpos));
if (isset($allowed_protocols[$protocol])) {
// The protocol turns out be an allowed protocol
$external = $url;
}
} else {
if ($config->get('plugins.external_links.links.www')) {
// Remove possible path duplicate
$route = Grav::instance()['base_url'] . $page->route();
$href = Utils::startsWith($url, $route)
? ltrim(mb_substr($url, mb_strlen($route)), '/')
: $url;
// We found an url without protocol, but with starting 'www' (sub-)domain
if (Utils::startsWith($url, 'www.')) {
$external = 'http://' . $url;
} elseif (Utils::startsWith($href, 'www.')) {
$external = 'http://' . $href;
}
}
if ($config->get('plugins.external_links.links.redirects')) {
$targetPage = Grav::instance()['pages']->find($url);
if ($targetPage && $targetPage->redirect()) {
$external = $this->isExternalUrl($targetPage->redirect(), $domains, $page);
}
}
}
}
// Only if a valid protocol or an URL starting with 'www.' was found return true
return $external;
}
/**
* Determine the size of an image
*
* @param DOMNode $imgNode The image already parsed as a DOMNode
* @param integer $limit Load first $limit KB of remote image
*
* @return array Return the dimension of the image of the
* format array(width, height)
*/
protected function getImageSize($imgNode, $limit = 32)
{
// Hold units (assume standard font with 16px base pixel size)
// Calculations based on pixels
$units = array(
'px' => 1, /* base unit: pixel */
'pt' => 16 / 12, /* 12 point = 16 pixel = 1/72 inch */
'pc' => 16, /* 1 pica = 16 pixel = 12 points */
'in' => 96, /* 1 inch = 96 pixel = 2.54 centimeters */
'mm' => 96 / 25.4, /* 1 millimeter = 96 pixel / 1 inch [mm] */
'cm' => 96 / 2.54, /* 1 centimeter = 96 pixel / 1 inch [cm] */
'm' => 96 / 0.0254, /* 1 centimeter = 96 pixel / 1 inch [m] */
'ex' => 7, /* 1 ex = 7 pixel */
'em' => 16, /* 1 em = 16 pixel */
'rem' => 16, /* 1 rem = 16 pixel */
'%' => 16 / 100, /* 100 percent = 16 pixel */
);
// Initialize dimensions
$width = 0;
$height = 0;
// Determine image dimensions based on "src" atrribute
if ($imgNode->hasAttribute('src')) {
$src = $imgNode->getAttribute('src');
// Simple check if the URL is internal i.e. check if path exists
$path = $_SERVER['DOCUMENT_ROOT'] . $src;
if (realpath($path) && is_file($path)) {
$size = @getimagesize($path);
} else {
// The URL is external; try to load it (default: 32 KB)
$size = $this->getRemoteImageSize($src, $limit * 1024);
}
}
// Read out width and height from <img> attributes
$width = $imgNode->hasAttribute('width') ?
$imgNode->getAttribute('width') : $size[0];
$height = $imgNode->hasAttribute('height') ?
$imgNode->getAttribute('height') : $size[1];
// Get width and height from style attribute
if ( $imgNode->hasAttribute('style') ) {
$style = $imgNode->getAttribute('style');
// Width
if (preg_match('~width:\s*(\d+)([a-z]+)~i', $style, $matches)) {
$width = $matches[1];
// Convert unit to pixel
if ( isset($units[$matches[2]]) ) {
$width *= $units[$matches[2]];
}
}
// Height
if (preg_match('~height:\s*(\d+)([a-z]+)~i', $style, $matches)) {
$height = $matches[1];
// Convert unit to pixel
if (isset($units[$matches[2]])) {
$height *= $units[$matches[2]];
}
}
}
// Update width and height
$size[0] = $width;
$size[1] = $height;
// Return image dimensions
return $size;
}
/**
* Get the size of a remote image
*
* @param string $uri The URI of the remote image
* @param integer $limit Load first $limit bytes of remote image
*
* @return mixed Returns an array with up to 7 elements
*/
protected function getRemoteImageSize($uri, $limit = -1)
{
// Create temporary file to store data from $uri
$tmp_name = tempnam(sys_get_temp_dir(), uniqid('ris'));
if ($tmp_name === false) {
return false;
}
// Open temporary file
$tmp = fopen($tmp_name, 'wb');
// Check which method we should use to get remote image sizes
$allow_url_fopen = ini_get('allow_url_fopen') ? true : false;
$use_curl = function_exists('curl_version');
// Use stream copy
if ($allow_url_fopen) {
$options = [];
if ( $limit > 0 ) {
// Loading number of $limit bytes
$options['http']['header'] = array('Range: bytes=0-' . $limit);
}
// Create stream context
$context = stream_context_create($options);
@copy($uri, $tmp_name, $context);
// Use Curl
} elseif ($use_curl) {
// Initialize Curl
$options = array(
CURLOPT_HEADER => false, // Don't return headers
CURLOPT_FOLLOWLOCATION => true, // Follow redirects
CURLOPT_AUTOREFERER => true, // Set referrer on redirect
CURLOPT_CONNECTTIMEOUT => 120, // Timeout on connect
CURLOPT_TIMEOUT => 120, // Timeout on response
CURLOPT_MAXREDIRS => 10, // Stop after 10 redirects
CURLOPT_ENCODING => '', // Handle all encodings
CURLOPT_BINARYTRANSFER => true, // Transfer as binary file
CURLOPT_FILE => $tmp, // Curl file
CURLOPT_URL => $uri, // URI
);
$curl = curl_init();
curl_setopt_array($curl, $options);
if ($limit > 0) {
// Loading number of $limit
$headers = array('Range: bytes=0-' . $limit);
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
curl_setopt($curl, CURLOPT_RANGE, '0-' . $limit);
// Abort request when more data is received
curl_setopt($curl, CURLOPT_BUFFERSIZE, 512); // More progress info
curl_setopt($curl, CURLOPT_NOPROGRESS, false); // Monitor progress
curl_setopt($curl, CURLOPT_PROGRESSFUNCTION,
function($download_size, $downloaded, $upload_size, $uploaded) use ($limit) {
// If $downloaded exceeds $limit, returning non-zero breaks
// the connection!
return ( $downloaded > $limit ) ? 1 : 0;
});
}
// Execute Curl
curl_exec($curl);
curl_close($curl);
}
// Close temporary file
fclose($tmp);
// Retrieve image information
$info = array(0, 0, 'width="0" height="0"');
if (filesize($tmp_name) > 0) {
$info = @getimagesize($tmp_name);
}
// Delete temporary file
unlink($tmp_name);
return $info;
}
/**
* Load contents into PHP built-in DOMDocument object
*
* Two Really good resources to handle DOMDocument with HTML(5)
* correctly.
*
* @see http://stackoverflow.com/questions/3577641/how-do-you-parse-and-process-html-xml-in-php
* @see http://stackoverflow.com/questions/7997936/how-do-you-format-dom-structures-in-php
*
* @param string $content The content to be loaded into the
* DOMDocument object
*
* @return DOMDocument DOMDocument object of content
*/
protected function loadDOMDocument($content)
{
// Clear previous errors
if (libxml_use_internal_errors(true) === true) {
libxml_clear_errors();
}
// Parse content using PHP built-in DOMDocument class
$document = new \DOMDocument('1.0', 'UTF-8');
// Encode contents as UTF-8, strip whitespaces & normalize newlines
$content = mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8');
// $whitespaces = array(
// '~\R~u' => "\n", // Normalize new line
// '~\>[^\S ]+~s' => '>', // Strip whitespaces after tags, except space
// '~[^\S ]+\<~s' => '<', // Strip whitespaces before tags, except space
// '~(\s)+~s' => '\\1' // Shorten multiple whitespace sequences
// );
// $content = preg_replace(array_keys($whitespaces), $whitespaces, $content);
// Parse the HTML using UTF-8
// The @ before the method call suppresses any warnings that
// loadHTML might throw because of invalid HTML in the page.
@$document->loadHTML($content);
// Do nothing, if DOM is empty
if (is_null($document->documentElement)) {
return null;
}
return $document;
}
/**
* Save contents of PHP built-in DOMDocument object as HTML5
*
* @param DOMDocument $document DOMDocument object with nodes
*
* @return string The outputted DOM document as HTML(5)
* compliant string
*/
protected function saveDOMDocument($document)
{
// Pretty print output
$document->preserveWhiteSpace = false;
$document->formatOutput = true;
// Transform DOM document to valid HTML(5)
$content = '';
$body = $document->getElementsByTagName('body')->item(0);
foreach ($body->childNodes as $node) {
// Expand empty tags (e.g. <br/> to <br></br>)
if (($html = $document->saveXML($node, LIBXML_NOEMPTYTAG)) !== false) {
$content .= $html;
}
}
// Fix formatting for self-closing tags in HTML5 and removing
// encapsulated (uncommented) CDATA blocks in <script> and
// <style> tags
$regex = array(
'~' . preg_quote('<![CDATA[', '~') . '~' => '',
'~' . preg_quote(']]>', '~') . '~' => '',
'~></(?:area|base(?:font)?|br|col|command|embed|frame|hr|img|input|keygen|link|meta|param|source|track|wbr)>~' => ' />',
);
// Make XML HTML5 compliant
$content = preg_replace(array_keys($regex), $regex, $content);
return $content;
}
}