From 5b6e344ee9d3c30b873de7b00bef600dd2c17d4e Mon Sep 17 00:00:00 2001 From: Yutaka Kurosaki <> Date: Sat, 9 May 2026 10:47:46 +0900 Subject: [PATCH] Detect YouTube URLs and emit privacy-enhanced iframe Recognizes youtu.be, watch?v=, shorts, embed, and mobile variants. Emits an iframe pointing to youtube-nocookie.com with lazy loading, strict-origin referrer policy, and allowfullscreen. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/Markdown/MediaUrlResolver.php | 37 +++++++++++++++++- .../Unit/Markdown/MediaUrlResolverTest.php | 39 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/app/Markdown/MediaUrlResolver.php b/src/app/Markdown/MediaUrlResolver.php index a4dfe5d..a650ee3 100644 --- a/src/app/Markdown/MediaUrlResolver.php +++ b/src/app/Markdown/MediaUrlResolver.php @@ -14,7 +14,8 @@ public function resolve(string $url): ?string return null; } return $this->detectVideo($url) - ?? $this->detectAudio($url); + ?? $this->detectAudio($url) + ?? $this->detectYouTube($url); } private function detectVideo(string $url): ?string @@ -35,6 +36,40 @@ private function detectAudio(string $url): ?string return ""; } + private function detectYouTube(string $url): ?string + { + $patterns = [ + '~^https?://youtu\.be/([A-Za-z0-9_-]{11})(?:[/?#]|$)~', + '~^https?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#]*&)?v=([A-Za-z0-9_-]{11})(?:[&#]|$)~', + '~^https?://(?:www\.|m\.)?youtube\.com/shorts/([A-Za-z0-9_-]{11})(?:[/?#]|$)~', + '~^https?://(?:www\.|m\.)?youtube\.com/embed/([A-Za-z0-9_-]{11})(?:[/?#]|$)~', + ]; + $videoId = null; + foreach ($patterns as $p) { + if (preg_match($p, $url, $m)) { + $videoId = $m[1]; + break; + } + } + if ($videoId === null) { + return null; + } + $src = "https://www.youtube-nocookie.com/embed/{$videoId}"; + return $this->iframeHtml($src, 'youtube'); + } + + private function iframeHtml(string $src, string $provider): string + { + $safe = htmlspecialchars($src, ENT_QUOTES, 'UTF-8'); + return ''; + } + private function getPathExtension(string $url): string { $path = parse_url($url, PHP_URL_PATH); diff --git a/src/tests/Unit/Markdown/MediaUrlResolverTest.php b/src/tests/Unit/Markdown/MediaUrlResolverTest.php index 2a64cad..4498802 100644 --- a/src/tests/Unit/Markdown/MediaUrlResolverTest.php +++ b/src/tests/Unit/Markdown/MediaUrlResolverTest.php @@ -87,4 +87,43 @@ public static function audioUrls(): array 'm4a' => ['/clip.m4a'], ]; } + + #[DataProvider('youtubeUrls')] + public function test_youtube_urls_produce_iframe(string $url): void + { + $html = $this->resolver->resolve($url); + $this->assertNotNull($html); + $this->assertStringStartsWith('assertStringContainsString('youtube-nocookie.com/embed/dQw4w9WgXcQ', $html); + $this->assertStringContainsString('class="kb-embed kb-embed-youtube"', $html); + $this->assertStringContainsString('loading="lazy"', $html); + $this->assertStringContainsString('allowfullscreen', $html); + } + + public static function youtubeUrls(): array + { + return [ + 'short youtu.be' => ['https://youtu.be/dQw4w9WgXcQ'], + 'watch v=' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ'], + 'shorts' => ['https://www.youtube.com/shorts/dQw4w9WgXcQ'], + 'embed' => ['https://www.youtube.com/embed/dQw4w9WgXcQ'], + 'mobile' => ['https://m.youtube.com/watch?v=dQw4w9WgXcQ'], + 'no www watch' => ['https://youtube.com/watch?v=dQw4w9WgXcQ'], + ]; + } + + #[DataProvider('invalidYoutubeUrls')] + public function test_invalid_youtube_urls_return_null(string $url): void + { + $this->assertNull($this->resolver->resolve($url)); + } + + public static function invalidYoutubeUrls(): array + { + return [ + 'too short id' => ['https://youtu.be/short'], + 'host mismatch' => ['https://example.com/watch?v=dQw4w9WgXcQ'], + 'XSS attempt in id' => ['https://youtu.be/abc">