J’ai été confronté à un problème que je n’avais pas envisagé en voulant lister en iframe un certain nombre de vidéos Youtube, Vimeo et Dailymotion (toutes disponibles en lecture) sur une page web.
Ces vidéos sont gérées avec leur API JavaScript respectives pour interagir entre elles (autrement-dit stopper une video en lecture si une autre démarre), car quoi de plus embêtant que d’avoir deux videos lues en même temps, super cacophonie !
Mon objectif initial était de gérer au mieux l’expérience utilisateur avec un loader pour chacune des videos en attendant que le chargement sur la page soit terminé.
Note : la notion de chargement consiste ici à s’assurer que la vidéo est un contenu disponible (sans erreur 404 par exemple).
Cela semblait assez nécessaire étant donné que plusieurs vidéos d’origine différente pouvaient être chargées au même moment.
Au passage, je comprends mieux pourquoi Youtube ou d’autres services affichent une vidéo à la fois, car cela est plus judicieux et pragmatique !
Seulement voilà, je n’avais pas accès au contenu des iframes du fait de la fameuse restriction de sécurité dans un contexte de « Cross Origin Resource Sharing » (contenu provenant d’un domaine différent).
je n’avais donc pas la possibilité d’accéder à un contenu externe (vidéo) en iframe et surtout tester son chargement directement en JavaScript !
D’autant plus que la gestion d’évènement en JavaScript sur une iframe est plutôt limitée …
Après un certain nombre de recherche et en me documentant sur de possibles solutions, j’ai opté pour une approche similaire à ce qui est présenté ici en vidéo :
Cross domain proxy et requête AJAX : https://www.youtube.com/watch?v=o8puzjzpjqo
L’idée pour moi est d’effectuer une requête AJAX en passant en paramètre l’URL de la vidéo afin de vérifier en PHP que la vidéo est une ressource exploitable.
Le simple retour attendu sera une chaîne JSON contenant l’équivalent d’un booléan 0 (« chargement » impossible) et 1 (« chargement » exploitable côté client en JavaScript) pour basiquement faire apparaître soit un message d’erreur, soit faire disparaître le loader et rendre accessible l’iframe. Cela ressemble plus à une astuce mais permet de s’assurer que la vidéo va être disponible pour la lecture par l’utilisateur.
Seules les portions de code principales sont présentées ici, je pense qu’elles suffisent à voir globalement le principe.
Tous les commentaires sont en anglais car c’est une habitude personnelle !
Le code PHP provient de développements présents dans un projet Symfony 4, le principe requête – réponse reste cependant classique.
– Voici la partie requête AJAX réalisée côté client :
// Ajax request ES6 function return new Promise((resolve, reject) => { let xhr = new XMLHttpRequest(); xhr.open(obj.method || "GET", obj.url, obj.async || false ); if (obj.overrideMimeType) { xhr.overrideMimeType(obj.overrideMimeType); } if (obj.responseType) { xhr.responseType = obj.responseType; } if (obj.withCredentials) { xhr.withCredentials = obj.withCredentials; } if (obj.headers) { Object.keys(obj.headers).forEach(key => { xhr.setRequestHeader(key, obj.headers[key]); }); } // Custom functions for loader if (obj.onProgressFunction) { // Custom loader xhr.onprogress = () => { obj.onProgressFunction(xhr); }; } if (obj.onLoadStartFunction) { xhr.onloadstart = () => { obj.onLoadStartFunction(xhr); }; } if (obj.onLoadEndFunction) { xhr.onloadend = () => { obj.onLoadEndFunction(xhr); }; } xhr.onerror = () => reject(xhr); xhr.onload = xhr.onreadystatechange = () => { if (xhr.readyState === XMLHttpRequest.DONE ) { if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.response); } else { reject(xhr); } } }; // Send request xhr.send(obj.body !== undefined ? obj.body : null); }); };
import request from './all/ajax-request'; export default function() { // Other scripts before here ... // Check video loading with C.O.R.S function checkLoadingCORSRequest(method, url, resolvedCallback, errorCallback, args, timeOut) { // XMLHttpRequest object const obj = { method: method, url: url.replace(/(\?.+)/gi, ''), async: true, withCredentials: false, responseType: 'json' }; // Use promise with callbacks request(obj).then((response) => { // no need to parse with JSON.parse(response).status: response is already an object if (response.status === 1) { resolvedCallback.apply(null, args); // Dispatch checked video success event to manage asynchronous execution and enable API let customEvent = new Event('checkedVideoSuccess'); args[0].dispatchEvent(customEvent); } else { errorCallback.apply(null, args); } // Cancel timeOut clearTimeout(timeOut); }).catch(() => { errorCallback.apply(null, args); // Cancel timeOut clearTimeout(timeOut); }); } // Manage iframe after loading success: media argument is an iframe element function afterMediaLoaded(media) { // Make loader disappear and render iframe correctly for instance // ... } // Manage loading failure: media argument is an iframe element function whenMediaError(media) { // Display an error message instead of iframe element for instance // ... } // Convert NodeLists to arrays (example with multiple Youtube iframes in slider) let singleSliderElement = document.getElementById('...'); let youtubeIframes = Array.from(singleSliderElement.querySelectorAll('...')), let ytTimeOut = []; for (let i = 0; i < youtubeIframes.length; i ++) { // Call iframe loading rendering behavior with Youtube example in loop: ytTimeOut[i] = setTimeout(() => { // Dynamic URL to call action PHP class script (can obviously be static) let proxyURL = singleSliderElement.getAttribute('data-video-proxy') + youtubeIframes[i].getAttribute('src'); checkLoadingCORSRequest('GET', proxyURL, afterMediaLoaded, whenMediaError, [youtubeIframes[i]], ytTimeOut[i]); }, 10); } // Other scripts after here ... }
– L’action (controller) appelée par la requête AJAX qui envoie la réponse JSON grâce à un responder (pattern ADR), une fois la vidéo contrôlée par un service :
<?php declare(strict_types = 1); namespace App\Action; use App\Responder\Json\JsonResponder; use App\Service\Medias\VideoURLProxyChecker; use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; /** * Class AjaxVideoURLCheckAction. * * Verify if video URL can be loaded using ajax. */ class AjaxVideoURLCheckAction { use LoggerAwareTrait; /** * @var VideoURLProxyChecker */ private $videoChecker; /** * AjaxTrickListAction constructor. * * @param VideoURLProxyChecker $videoChecker * * @param LoggerInterface $logger * * @return void */ public function __construct(VideoURLProxyChecker $videoChecker, LoggerInterface $logger) { $this->videoChecker = $videoChecker; $this->setLogger($logger); } /** * Check if single trick video URL can be loaded from ajax request. * * @Route("/{_locale}/load-video/url/{url<(.+)>?}", name="load_video_url_check") * * @param JsonResponder $responder * @param Request $request * * @return Response * * @see https://symfony.com/doc/current/routing/slash_in_parameter.html */ public function __invoke(JsonResponder $responder, Request $request): JsonResponse { // Check video URL value $url = $this->videoChecker->filterURLAttribute($request); if (\is_null($url)) { $this->logger->error( "[trace app videos] AjaxVideoURLCheckAction/__invoke => " . "Technical error due to video url set to null: check loading process for both client and server side!" ); } // Check if URL is formatted as expected (validation) and accessible $data = $this->videoChecker->verify($url); return $responder($data); } }
<?php declare(strict_types = 1); namespace App\Responder\Json; use Symfony\Component\HttpFoundation\JsonResponse; /** * Class JsonResponder. * * Manage a simple JSON response with status from ajax request. */ final class JsonResponder { /** * Invokable Responder with Magic method. * * @param array $data * * @return JsonResponse */ public function __invoke(array $data): JsonResponse { // Encode data with JSON string with serializer return new JsonResponse($data); } }
– Le service, classe PHP simple qui effectue les contrôles sur la ressource vidéo :
<?php declare(strict_types = 1); namespace App\Service\Medias; use Symfony\Component\HttpFoundation\Request; /* * Class VideoURLProxyChecker. * * Check if a video URL can be correctly loaded. * . */ class VideoURLProxyChecker { // CAUTION: these iframe URL patterns should certainly be improved and are very important for a quite "secure" use! // Even more, they can evolve, so it is preferable to use providers APIs! const ALLOWED_URL_PATTERNS = [ '/^https?:\/\/www\.youtube\.com\/embed\/[a-zA-Z0-9_-]+$/', // [\w-]+ '/^https?:\/\/player\.vimeo\.com\/video\/[0-9]+$/', '/^https?:\/\/www\.dailymotion\.com\/embed\/video\/[a-zA-Z0-9]+$/' ]; /** * Filter provided URL. * * @param Request $request * @param bool $isDecoded * * @return null|string */ public function filterURLAttribute(Request $request, bool $isDecoded = false): ?string { // Get URL to check $url = null; if (!\is_null($request->attributes->get('url'))) { $url = $request->attributes->get('url'); } return $isDecoded ? $url : urldecode($url); } /** * Check if URL format is allowed. * * @param string|null $url * * @return bool */ public function isAllowed(?string $url): bool { if (\is_null($url)) { return false; } $patterns = self::ALLOWED_URL_PATTERNS; // Use of "array_filter" would be more appropriate here! for ($i = 0; $i < count($patterns); $i ++) { if (preg_match( $patterns[$i], urldecode($url))) { return true; } } return false; } /** * Request URL to check if a content can be loaded. Choice is made to use cURL here. * * CAUTION: do not use this method alone because of potential "SSRF" attacks! At least use isAllowed() before... * @link https://www.vaadata.com/blog/understanding-web-vulnerability-server-side-request-forgery-1/ * * @param string|null $url * * @return bool */ public function isContent(?string $url): bool { if (\is_null($url)) { return false; } // Youtube particular case to check availability correctly // otherwise HTTP code is always 200! if (preg_match( '/youtube/', $url)) { $url = $this->prepareAccessToYoutubeVideoContent($url); } // Use cURL $handle = curl_init(urldecode($url)); curl_setopt($handle, CURLOPT_RETURNTRANSFER, 1); // Avoid content loading by getting the headers only curl_setopt($handle, CURLOPT_NOBODY, 1); // Request with cURL curl_exec($handle); $httpCode = curl_getinfo($handle, CURLINFO_HTTP_CODE); // Check resource availability with HTTP code 200 $isContentFound = 200 === $httpCode ? true : false; curl_close($handle); return $isContentFound; } /** * Check particular youtube video availability. * * @link Particular case for youtube video: * https://stackoverflow.com/questions/29166402/verify-if-video-exist-with-youtube-api-v3 * * @param $url * * @return string the correct url to use to check availability */ private function prepareAccessToYoutubeVideoContent($url): string { // Extract video id and use correct URL preg_match( '/embed\/(.+)/', urldecode($url), $matches); $videoID = $matches[1]; $url ='https://www.youtube.com/oembed?url=http://www.youtube.com/watch?v=' . $videoID; return $url; } /** * Return a status code to be converted later in JSON string. * * Value 1 means URL can be loaded and value 0 means error context must be used! * * @param string|null $url * * @return array * * @see https://symfony.com/doc/current/controller.html#returning-json-response */ public function verify(?string $url): array { // Prepare array to be converted in JSON string with Symfony JsonResponse object (no need to use "json_encode" here) if (\is_null($url)) { return ['status' => 0]; } $url = urldecode($url); return $this->isAllowed($url) && $this->isContent($url) ? ['status' => 1] : ['status' => 0]; } }
Des erreurs (de copié-collé) peuvent s’être glissées dans le code bien que celui-ci soit issu d’un projet opérationnel.
Je vous invite à me les signaler en me contactant si c’est le cas.
Je suis également preneur si vous avez des conseils pour améliorer certains scripts, ou si vous estimez que certains points sont inexactes.
L’idée de départ, bien que perfectible, reste simple et intéressante pour vérifier l’accès à des ressources externes.
– Promesse et XMLHttpRequest : la fonction JavaScript « request(object) » est en grande partie inspirée de ce script : http://ccoenraets.github.io/es6-tutorial-data/promisify
J’apprécie cette manière de faire notamment pour la gestion d’évènements vraiment souple.
il n’y a pas que « axios » et « fetch api » dans la vie !
– Voici un lien vers Symfony 4 qui est utilisé en partie dans ce post.