*/ // must be run within Dokuwiki if(!defined('DOKU_INC')) { die(); } /** * Exception Class * * Class InvalidYouTubeEmbed */ class InvalidEmbed extends Exception { public function errorMessage(): string { return $this->getMessage(); } } class syntax_plugin_externalembed extends DokuWiki_Syntax_Plugin { /** * @return string Syntax mode type */ public function getType(): string { return 'substition'; } /** * @return string Paragraph type */ public function getPType(): string { return 'block'; } /** * @return int Sort order - Low numbers go before high numbers */ public function getSort(): int { return 2; } /** * Connect lookup pattern to lexer. * * @param string $mode Parser mode */ public function connectTo($mode) { $this->Lexer->addEntryPattern('{{external_embed>', $mode, 'plugin_externalembed'); } public function postConnect() { $this->Lexer->addExitPattern('}}', 'plugin_externalembed'); } /** * Handle matches of the externalembed syntax * * @param string $match The match of the syntax * @param int $state The state of the handler * @param int $pos The position in the document * @param Doku_Handler $handler The handler * * @return array Data for the renderer * @noinspection PhpMissingParamTypeInspection */ function handle($match, $state, $pos, $handler): array { switch($state) { case DOKU_LEXER_EXIT: case DOKU_LEXER_ENTER : /** @var array $data */ return array(); case DOKU_LEXER_SPECIAL: case DOKU_LEXER_MATCHED : break; case DOKU_LEXER_UNMATCHED : if(!empty($match)) { try { //get and define config variables define('YT_API_KEY', $this->getConf('YT_API_KEY')); define('THUMBNAIL_CACHE_TIME', $this->getConf('THUMBNAIL_CACHE_TIME') * 60 * 60); define('PLAYLIST_CACHE_TIME', $this->getConf('PLAYLIST_CACHE_TIME')); define('DEFAULT_PRIVACY_DISCLAIMER', $this->getConf('DEFAULT_PRIVACY_DISCLAIMER')); // cam be empty $disclaimers = array(); define('DOMAIN_WHITELIST', $this->getDomains($this->getConf('DOMAIN_WHITELIST'), $disclaimers)); define('DISCLAIMERS', $disclaimers); //can be empty define('MINIMUM_EMBED_WIDTH', $this->getConf('MINIMUM_EMBED_WIDTH')); define('MINIMUM_EMBED_HEIGHT', $this->getConf('MINIMUM_EMBED_WIDTH')); if(!($cacheHelper = $this->loadHelper('externalembed_cacheInterface'))) { throw new InvalidEmbed('Could not load cache interface helper'); } //validate config variables if(empty(YT_API_KEY)) { throw new InvalidEmbed('Empty API Key, set this in the configuration manager in the admin panel'); } if(empty(THUMBNAIL_CACHE_TIME)) { throw new InvalidEmbed('Empty cache time for thumbnails, set this in the configuration manager in the admin panel'); } if(empty(PLAYLIST_CACHE_TIME)) { throw new InvalidEmbed('Empty cache time for playlists, set this in the configuration manager in the admin panel'); } if(empty(DOMAIN_WHITELIST)) { throw new InvalidEmbed('Empty domain whitelist, set this in the configuration manager in the admin panel'); } $parameters = $this->getParameters($match); $embed_type = $this->getEmbedType($parameters); $parameters['type'] = $embed_type; //gets the embed type and checks if the domain is in the whitelist //MAIN PROGRAM: switch(true) { case ($embed_type === "youtube_video"): $validated_parameters = $this->parseYouTubeVideoString($parameters); $yt_request = $this->getVideoRequest($validated_parameters); $validated_parameters['thumbnail'] = $this->cacheYouTubeThumbnail($cacheHelper, $validated_parameters['video_id']); $html = $this->renderJSON($yt_request, $validated_parameters); return array('embed_html' => $html, 'video_ID' => $validated_parameters['video_id']); //return html and metadata case ($embed_type === "youtube_playlist"): $validated_parameters = $this->parseYouTubePlaylistString($parameters); $playlist_cache = $this->cachePlaylist($cacheHelper, $validated_parameters); $cached_video_id = $this->getLatestVideo($playlist_cache); $validated_parameters['video_id'] = $cached_video_id; //adds the video ID to the metadata later $validated_parameters['thumbnail'] = $this->cacheYouTubeThumbnail($cacheHelper, $validated_parameters['video_id']); $yt_request = $this->getVideoRequest($validated_parameters); $html = $this->renderJSON($yt_request, $validated_parameters); return array('embed_html' => $html, 'video_ID' => $validated_parameters['video_id'], 'playlist_ID' => $validated_parameters['playlist_id']); case ($embed_type === 'fusion'): $validated_parameters = $this->parseFusionString($parameters); $fusion_request = $this->getFusionRequest($validated_parameters); $html = $this->renderJSON($fusion_request, $validated_parameters); return array('embed_html' => $html); case ($embed_type === 'other'): $validated_parameters = $this->parseOtherEmbedString($parameters); $html = $this->renderJSON($validated_parameters['url'], $validated_parameters); return array('embed_html' => $html); default: throw new InvalidEmbed("Unknown Embed Type"); //todo: allow fusion embed links } } catch(InvalidEmbed $e) { $html = "

External Embed Error: " . $e->getMessage() . "

"; return array('embed_html' => $html); } } } return array(); } /** * Render xhtml output or metadata * * @param string $mode Renderer mode (supported modes: xhtml) * @param Doku_Renderer $renderer The renderer * @param array $data The data from the handler() function * * @return bool If rendering was successful. * @noinspection PhpParameterNameChangedDuringInheritanceInspection */ public function render($mode, Doku_Renderer $renderer, $data): bool { if($data === false) return false; if($mode == 'xhtml') { if(!empty($data['embed_html'])) { $renderer->doc .= $data['embed_html']; return true; } else { return false; } } elseif($mode == 'metadata') { if(!empty($data['video_ID'])) { /** @var Doku_Renderer_metadata $renderer */ // erase tags on persistent metadata no more used if(isset($renderer->persistent['plugin']['externalembed']['video_ids'])) { //unset($renderer->meta['plugin']['externalembed']['video_ids']); unset($renderer->persistent['plugin']['externalembed']['video_ids']); $renderer->meta['plugin']['externalembed']['video_ids'] = array(); //$renderer->persistent['plugin']['externalembed']['video_ids'] = array(); } // merge with previous tags and make the values unique if(!isset($renderer->meta['plugin']['externalembed']['video_ids'])) { $renderer->meta['plugin']['externalembed']['video_ids'] = array(); } //$renderer->persistent['plugin']['externalembed']['video_ids'] = array(); $renderer->meta['plugin']['externalembed']['video_ids'] = array_unique(array_merge($renderer->meta['plugin']['externalembed']['video_ids'], array($data['video_ID']))); //$renderer->persistent['plugin']['externalembed']['video_ids'] = array_unique(array_merge($renderer->persistent['plugin']['externalembed']['video_ids'], array($data['video_ID']))); if(!empty($data['playlist_ID'])) { if(isset($renderer->persistent['plugin']['externalembed']['playlist_ids'])) { unset($renderer->persistent['plugin']['externalembed']['playlist_ids']); $renderer->meta['plugin']['externalembed']['playlist_ids'] = array(); $renderer->persistent['plugin']['externalembed']['playlist_ids'] = array(); } if(!isset($renderer->meta['plugin']['externalembed']['playlist_ids'])) { $renderer->meta['plugin']['externalembed']['playlist_ids'] = array(); $renderer->persistent['plugin']['externalembed']['playlist_ids'] = array(); } $renderer->meta['plugin']['externalembed']['playlist_ids'] = array_unique(array_merge($renderer->meta['plugin']['externalembed']['playlist_ids'], array($data['playlist_ID']))); $renderer->persistent['plugin']['externalembed']['playlist_ids'] = array_unique(array_merge($renderer->persistent['plugin']['externalembed']['playlist_ids'], array($data['playlist_ID']))); } return true; } return false; } return false; } /** * Method that generates an HTML iframe for embedded content * Substitutes default privacy disclaimer if none is found the disclaimers array * * @param $request string the source url * @param $parameters array iframe attributes and url data * @return string the html to embed * @throws InvalidEmbed */ private function renderJSON(string $request, array $parameters): string { $parameters['disclaimer'] = DEFAULT_PRIVACY_DISCLAIMER; $parameters['request'] = $request; $type = $parameters['type']; if($type !== 'other') { $parameters['size'] = $this->getEmbedSize($parameters); } else { $parameters['size'] = ''; } if($parameters['embed-position'] == "centre") { $position = "mediacenter"; } else if($parameters['embed-position'] == 'right') { $position = "mediaright"; } else if($parameters['embed-position'] == "left") { $position = "medialeft"; } else { $position = ''; } //remove unnecessary parameters that don't need to be sent unset( $parameters['url'], $parameters['autoplay'], $parameters['loop'], $parameters['mute'], $parameters['controls'], $parameters['embed-position'] ); if(key_exists($parameters['domain'], DISCLAIMERS)) { //if there is a unique disclaimer for the domain, replace the default value with custom value if(!empty(DISCLAIMERS[$parameters['domain']])) { $parameters['disclaimer'] = DISCLAIMERS[$parameters['domain']]; } } $dataJSON = json_encode(array_map("utf8_encode", $parameters)); return '
'; } /** * Selects the class to add to the embed so that its size is correct * @param $parameters * @return string * @throws InvalidEmbed */ private function getEmbedSize(&$parameters): string { switch($parameters['height']) { case '360': $parameters['width'] = '640'; return 'externalembed_height_360'; case '480': $parameters['width'] = '854'; return 'externalembed_height_480'; case '720': $parameters['width'] = '1280'; return 'externalembed_height_720'; case '1080': $parameters['width'] = '1920'; return 'externalembed_height_1080'; default: throw new InvalidEmbed('Unknown width value for size class'); } } /** * Check to see if domain in the url is in the domain whitelist. * * Check url to determine the type of embed * If the url is a YouTube playlist, the embed will show the latest video in the playlist * If the url is a YouTube video, the embed will only show the video * Else the type is 'other' as long as the domain is on the whitelist * * @param $parameters * @return string either: 'playlist' 'YT_video' or 'other' * @throws InvalidEmbed */ private function getEmbedType(&$parameters): string { if(key_exists('url', $parameters) === false) { throw new InvalidEmbed('Missing url parameter'); } $parameters['domain'] = $this->validateDomain($parameters['url']); //validate and return the domain of the url $embed_type = 'other'; if($parameters['domain'] === 'youtube.com' || $parameters['domain'] === 'youtu.be') { //determine if the url is a video or a playlist https://youtu.be/clD_8BItvh4 if(strpos($parameters['url'], 'playlist?list=') !== false) { return 'youtube_playlist'; } else if((strpos($parameters['url'], '/watch') || strpos($parameters['url'], 'youtu.be/')) !== false) { return 'youtube_video'; } else { throw new InvalidEmbed("Unknown youtube url"); } } if($parameters['domain'] === 'inventopia.autodesk360.com') { return 'fusion'; } return $embed_type; } /** * Method that checks the domain entered by the user against the accepted whitelist of domains sets in the configuration manager * * @param $url * @return string The valid domain * @throws InvalidEmbed If the domain is not in the whitelist */ private function validateDomain($url): string { $domain = ltrim(parse_url('http://' . str_replace(array('https://', 'http://'), '', $url), PHP_URL_HOST), 'www.'); if(array_search($domain, DOMAIN_WHITELIST) === false) { throw new InvalidEmbed( "Could not embed content from domain: " . htmlspecialchars($domain) . "
Contact your administrator to add it to their whitelist.
Accepted Domains: " . implode(" | ", DOMAIN_WHITELIST) ); } return $domain; } /** * Method for extracting the accepted domains from the config string * Split each data entry by line * Then split each line by commas to extract the disclaimer for each accepted domain. * If there is no disclaimer for a domain, store this as an empty string "" in the disclaimers array * * @param $whitelist_string string string entered from config file * @param $disclaimers array array that stores the disclaimers for each accepted domain * @return array */ private function getDomains(string $whitelist_string, array &$disclaimers): array { $domains = array(); $items = explode("\n", $whitelist_string); foreach($items as $domain_disclaimer) { $data = explode(',', $domain_disclaimer); array_push($domains, trim($data[0])); $disclaimers[trim($data[0])] = trim($data[1]); } return $domains; } /** * Method that parses the users string query for a video from the wiki editor * * @param $parameters * @return array //an array of parameter: value, associations * @throws InvalidEmbed */ private function parseYouTubeVideoString($parameters): array { $video_parameter_types = array("type" => true, 'url' => true, 'domain' => true, 'video_id' => true, 'height' => '720', 'autoplay' => 'false', 'mute' => 'false', 'loop' => 'false', 'controls' => 'true', "embed-position" => "block"); $video_parameter_values = array('autoplay' => ['', 'true', 'false'], 'mute' => ['', 'true', 'false'], 'loop' => ['', 'true', 'false'], 'controls' => ['', 'true', 'false'], 'height' => ['360', '480', '720', '1080'], "embed-position" => ['', 'left', 'centre', 'right', 'block']); $regex = '/^((?:https?:)?\/\/)?((?:www|m)\.)?(youtube\.com|youtu.be)(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/'; if(preg_match($regex, $parameters['url'], $match)) { $parameters['video_id'] = $match[5]; } else { throw new InvalidEmbed('Invalid YouTube URL'); } return $this->checkParameters($parameters, $video_parameter_types, $video_parameter_values); } /** * Method that parses the users string query for a playlist from the wiki editor * * @param $parameters * @return array //an array of parameter: value, associations * @throws InvalidEmbed */ private function parseYouTubePlaylistString($parameters): array { $playlist_parameter_types = array("type" => true, 'url' => true, 'domain' => true, 'playlist_id' => true, 'height' => '720', 'autoplay' => 'false', 'mute' => 'false', 'loop' => 'false', 'controls' => 'true', "embed-position" => "block"); $playlist_parameter_values = array('autoplay' => ['', 'true', 'false'], 'mute' => ['', 'true', 'false'], 'loop' => ['', 'true', 'false'], 'controls' => ['', 'true', 'false'], 'height' => ['360', '480', '720', '1080'], "embed-position" => ['', 'left', 'centre', 'right', 'block']); $regex = '/^.*(youtu.be\/|list=)([^#&?]*).*/'; if(preg_match($regex, $parameters['url'], $matches)) { $parameters['playlist_id'] = $matches[2]; //set the playlist id } return $this->checkParameters($parameters, $playlist_parameter_types, $playlist_parameter_values); } /** * Method that parses the users string query for a fusion embed * * @param $parameters * @return array an array of validated parameters * @throws InvalidEmbed */ private function parseFusionString($parameters): array { $fusion_parameter_types = array('type' => true, 'url' => true, 'domain' => true, 'width' => '1280', 'height' => '720', 'allowFullScreen' => 'true', "embed-position" => "block"); $fusion_parameter_values = array('allowFullScreen' => ['true', 'false'], "embed-position" => ['', 'left', 'centre', 'right', 'block']); return $this->checkParameters($parameters, $fusion_parameter_types, $fusion_parameter_values); } /** * Method that parses the users string query for an embed type classed as "other" * * @param $parameters * @return array an array of validated parameters * @throws InvalidEmbed */ private function parseOtherEmbedString($parameters): array { $other_parameter_types = array("type" => true, 'url' => true, 'domain' => true, 'width' => '1280', 'height' => '720', "embed-position" => "block"); $other_parameter_values = array("embed-position" => ['', 'left', 'centre', 'right', 'block']); return $this->checkParameters($parameters, $other_parameter_types, $other_parameter_values); } /** * Splits the query string into an associative array of Type => Value pairs * * @param $user_string string The user's embed query * @return array */ private function getParameters(string $user_string): array { $query = array(); $string_array = explode(' | ', $user_string); foreach($string_array as $item) { $parameter = explode(": ", $item); //creates key value pairs for parameters e.g. [type] = "image" $query[strtolower($parameter[0])] = str_replace('"', '', $parameter[1]); //removes quotes } if(array_key_exists("fields", $query)) { // separate field names into an array if it exists $fields = array_map("trim", explode(",", $query['fields'])); $query['fields'] = $fields; } return $query; } /** * Checks query parameters to make sure: * Required parameters are present * Missing parameters are substituted with default params * Parameter values match expected values * * @param $query_array array * @param $required_parameters array * @param $parameter_values array * @return array // query array with added default parameters * @throws InvalidEmbed */ private function checkParameters(array &$query_array, array $required_parameters, array $parameter_values): array { foreach($required_parameters as $key => $value) { if(!array_key_exists($key, $query_array)) { // if parameter is missing: if($value === true) { // check if parameter is required throw new InvalidEmbed("Missing Parameter: " . $key); } $query_array[$key] = $value; // substitute default } if(($query_array[$key] == null || $query_array[$key] === "") && $value === true) { //if parameter is required but value is not present throw new InvalidEmbed("Missing Parameter Value for: '" . $key . "'."); } if(array_key_exists($key, $parameter_values)) { //check accepted parameter_values array if(!in_array($query_array[$key], $parameter_values[$key])) { //if parameter value is not accepted: $message = "Invalid Parameter Value: '" . htmlspecialchars($query_array[$key]) . "' for Key: '" . $key . "'.
Possible values: " . implode(" | ", $parameter_values[$key]); if(in_array("", $parameter_values[$key])) { $message .= " or ''"; } throw new InvalidEmbed($message); } } } //if(intval($query_array['width']) < MINIMUM_EMBED_WIDTH) $query_array['width'] = MINIMUM_EMBED_WIDTH; if(intval($query_array['height']) < MINIMUM_EMBED_WIDTH) $query_array['height'] = MINIMUM_EMBED_HEIGHT; foreach($query_array as $key => $value) { if(!array_key_exists($key, $required_parameters)) { throw new InvalidEmbed("Invalid parameter: " . htmlspecialchars($key) . '. For url: ' . htmlspecialchars($query_array['url'])); } } return $query_array; } /** * Method that generates the src attribute for the iframe element * * @param $parameters * @return string */ private function getVideoRequest($parameters): string { if($parameters['autoplay'] === 'true') { $autoplay = '1'; } else { $autoplay = '0'; } if($parameters['mute'] === 'true') { $mute = '1'; } else { $mute = '0'; } if($parameters['loop'] === 'true') { $loop = '1'; } else { $loop = '0'; } if($parameters['controls'] === 'true') { $controls = '1'; } else { $controls = '0'; } return 'https://www.youtube.com/embed/' . $parameters['video_id'] . '?' . 'autoplay=' . $autoplay . '&mute=' . $mute . '&loop=' . $loop . '&controls=' . $controls; } /** * Method that turns a normal fusion url into an embed url * Also sets the required iframe parameters for enabling fullscreen * @param $parameters * @return string */ private function getFusionRequest(&$parameters): string { if($parameters['allowFullScreen'] === 'true') { $parameters['allowfullscreen'] = 'true'; $parameters['webkitallowfullscreen'] = 'true'; $parameters['mozallowfullscreen'] = 'true'; } unset($parameters['allowFullScreen']); return $parameters['url'] . '?mode=embed'; } /** * @param $video_cache * @return string //returns the last video in the cache (the latest one) */ private function getLatestVideo($video_cache): string { $cache_data = json_decode($video_cache->retrieveCache()); return end($cache_data); } /** * Method for getting YouTube thumbnail and storing it as a base64 string in a cache file * * @param $cache_helper object The ExternalEmbedInterface * @param $video_id string the YouTube video ID * @return string YouTube Thumbnail as a base 64 string */ private function cacheYouTubeThumbnail(object $cache_helper, string $video_id): string { $thumbnail = $cache_helper->getYouTubeThumbnail($video_id); $cache_helper->cacheYouTubeThumbnail($video_id, $thumbnail); //use the helper interface to create the cache return $thumbnail['thumbnail']; //return the thumbnail encoded data } /** * Generates a cache json file using the playlist ID. * The cache file stores all the video ids from the playlist * * @param $cacheHelper * @param $parameters * @return mixed */ private function cachePlaylist($cacheHelper, $parameters) { $playlist_data = $cacheHelper->getPlaylist($parameters['playlist_id']); return $cacheHelper->cachePlaylist($parameters['playlist_id'], $playlist_data); } }