| 1 | /* |
|---|
| 2 | * Copyright (C) 2014 Apple Inc. All rights reserved. |
|---|
| 3 | * |
|---|
| 4 | * Redistribution and use in source and binary forms, with or without |
|---|
| 5 | * modification, are permitted provided that the following conditions |
|---|
| 6 | * are met: |
|---|
| 7 | * 1. Redistributions of source code must retain the above copyright |
|---|
| 8 | * notice, this list of conditions and the following disclaimer. |
|---|
| 9 | * 2. Redistributions in binary form must reproduce the above copyright |
|---|
| 10 | * notice, this list of conditions and the following disclaimer in the |
|---|
| 11 | * documentation and/or other materials provided with the distribution. |
|---|
| 12 | * |
|---|
| 13 | * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY |
|---|
| 14 | * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
|---|
| 15 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
|---|
| 16 | * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR |
|---|
| 17 | * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
|---|
| 18 | * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
|---|
| 19 | * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
|---|
| 20 | * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY |
|---|
| 21 | * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
|---|
| 22 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
|---|
| 23 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|---|
| 24 | */ |
|---|
| 25 | |
|---|
| 26 | #include "config.h" |
|---|
| 27 | #include "YouTubePluginReplacement.h" |
|---|
| 28 | |
|---|
| 29 | #include "HTMLIFrameElement.h" |
|---|
| 30 | #include "HTMLNames.h" |
|---|
| 31 | #include "HTMLParserIdioms.h" |
|---|
| 32 | #include "HTMLPlugInElement.h" |
|---|
| 33 | #include "RenderElement.h" |
|---|
| 34 | #include "Settings.h" |
|---|
| 35 | #include "ShadowRoot.h" |
|---|
| 36 | #include "YouTubeEmbedShadowElement.h" |
|---|
| 37 | #include <wtf/text/StringBuilder.h> |
|---|
| 38 | |
|---|
| 39 | namespace WebCore { |
|---|
| 40 | |
|---|
| 41 | void YouTubePluginReplacement::registerPluginReplacement(PluginReplacementRegistrar registrar) |
|---|
| 42 | { |
|---|
| 43 | registrar(ReplacementPlugin(create, supportsMimeType, supportsFileExtension, supportsURL, isEnabledBySettings)); |
|---|
| 44 | } |
|---|
| 45 | |
|---|
| 46 | Ref<PluginReplacement> YouTubePluginReplacement::create(HTMLPlugInElement& plugin, const Vector<String>& paramNames, const Vector<String>& paramValues) |
|---|
| 47 | { |
|---|
| 48 | return adoptRef(*new YouTubePluginReplacement(plugin, paramNames, paramValues)); |
|---|
| 49 | } |
|---|
| 50 | |
|---|
| 51 | bool YouTubePluginReplacement::supportsMimeType(const String& mimeType) |
|---|
| 52 | { |
|---|
| 53 | return equalLettersIgnoringASCIICase(mimeType, "application/x-shockwave-flash") |
|---|
| 54 | || equalLettersIgnoringASCIICase(mimeType, "application/futuresplash"); |
|---|
| 55 | } |
|---|
| 56 | |
|---|
| 57 | bool YouTubePluginReplacement::supportsFileExtension(const String& extension) |
|---|
| 58 | { |
|---|
| 59 | return equalLettersIgnoringASCIICase(extension, "spl") || equalLettersIgnoringASCIICase(extension, "swf"); |
|---|
| 60 | } |
|---|
| 61 | |
|---|
| 62 | YouTubePluginReplacement::YouTubePluginReplacement(HTMLPlugInElement& plugin, const Vector<String>& paramNames, const Vector<String>& paramValues) |
|---|
| 63 | : m_parentElement(&plugin) |
|---|
| 64 | { |
|---|
| 65 | ASSERT(paramNames.size() == paramValues.size()); |
|---|
| 66 | for (size_t i = 0; i < paramNames.size(); ++i) |
|---|
| 67 | m_attributes.add(paramNames[i], paramValues[i]); |
|---|
| 68 | } |
|---|
| 69 | |
|---|
| 70 | RenderPtr<RenderElement> YouTubePluginReplacement::createElementRenderer(HTMLPlugInElement& plugin, RenderStyle&& style, const RenderTreePosition& insertionPosition) |
|---|
| 71 | { |
|---|
| 72 | ASSERT_UNUSED(plugin, m_parentElement == &plugin); |
|---|
| 73 | |
|---|
| 74 | if (!m_embedShadowElement) |
|---|
| 75 | return nullptr; |
|---|
| 76 | |
|---|
| 77 | return m_embedShadowElement->createElementRenderer(WTFMove(style), insertionPosition); |
|---|
| 78 | } |
|---|
| 79 | |
|---|
| 80 | bool YouTubePluginReplacement::installReplacement(ShadowRoot& root) |
|---|
| 81 | { |
|---|
| 82 | m_embedShadowElement = YouTubeEmbedShadowElement::create(m_parentElement->document()); |
|---|
| 83 | |
|---|
| 84 | root.appendChild(*m_embedShadowElement); |
|---|
| 85 | |
|---|
| 86 | auto iframeElement = HTMLIFrameElement::create(HTMLNames::iframeTag, m_parentElement->document()); |
|---|
| 87 | if (m_attributes.contains("width")) |
|---|
| 88 | iframeElement->setAttributeWithoutSynchronization(HTMLNames::widthAttr, AtomicString("100%", AtomicString::ConstructFromLiteral)); |
|---|
| 89 | |
|---|
| 90 | const auto& heightValue = m_attributes.find("height"); |
|---|
| 91 | if (heightValue != m_attributes.end()) { |
|---|
| 92 | iframeElement->setAttribute(HTMLNames::styleAttr, AtomicString("max-height: 100%", AtomicString::ConstructFromLiteral)); |
|---|
| 93 | iframeElement->setAttributeWithoutSynchronization(HTMLNames::heightAttr, heightValue->value); |
|---|
| 94 | } |
|---|
| 95 | |
|---|
| 96 | iframeElement->setAttributeWithoutSynchronization(HTMLNames::srcAttr, youTubeURL(m_attributes.get("src"))); |
|---|
| 97 | iframeElement->setAttributeWithoutSynchronization(HTMLNames::frameborderAttr, AtomicString("0", AtomicString::ConstructFromLiteral)); |
|---|
| 98 | |
|---|
| 99 | // Disable frame flattening for this iframe. |
|---|
| 100 | iframeElement->setAttributeWithoutSynchronization(HTMLNames::scrollingAttr, AtomicString("no", AtomicString::ConstructFromLiteral)); |
|---|
| 101 | m_embedShadowElement->appendChild(iframeElement); |
|---|
| 102 | |
|---|
| 103 | return true; |
|---|
| 104 | } |
|---|
| 105 | |
|---|
| 106 | static inline URL createYouTubeURL(const String& videoID, const String& timeID) |
|---|
| 107 | { |
|---|
| 108 | ASSERT(!videoID.isEmpty()); |
|---|
| 109 | ASSERT(videoID != "/"); |
|---|
| 110 | |
|---|
| 111 | URL result(URL(), "youtube:" + videoID); |
|---|
| 112 | if (!timeID.isEmpty()) |
|---|
| 113 | result.setQuery("t=" + timeID); |
|---|
| 114 | |
|---|
| 115 | return result; |
|---|
| 116 | } |
|---|
| 117 | |
|---|
| 118 | static YouTubePluginReplacement::KeyValueMap queryKeysAndValues(const String& queryString) |
|---|
| 119 | { |
|---|
| 120 | YouTubePluginReplacement::KeyValueMap queryDictionary; |
|---|
| 121 | |
|---|
| 122 | size_t queryLength = queryString.length(); |
|---|
| 123 | if (!queryLength) |
|---|
| 124 | return queryDictionary; |
|---|
| 125 | |
|---|
| 126 | size_t equalSearchLocation = 0; |
|---|
| 127 | size_t equalSearchLength = queryLength; |
|---|
| 128 | |
|---|
| 129 | while (equalSearchLocation < queryLength - 1 && equalSearchLength) { |
|---|
| 130 | |
|---|
| 131 | // Search for "=". |
|---|
| 132 | size_t equalLocation = queryString.find('=', equalSearchLocation); |
|---|
| 133 | if (equalLocation == notFound) |
|---|
| 134 | break; |
|---|
| 135 | |
|---|
| 136 | size_t indexAfterEqual = equalLocation + 1; |
|---|
| 137 | if (indexAfterEqual > queryLength - 1) |
|---|
| 138 | break; |
|---|
| 139 | |
|---|
| 140 | // Get the key before the "=". |
|---|
| 141 | size_t keyLocation = equalSearchLocation; |
|---|
| 142 | size_t keyLength = equalLocation - equalSearchLocation; |
|---|
| 143 | |
|---|
| 144 | // Seach for the ampersand. |
|---|
| 145 | size_t ampersandLocation = queryString.find('&', indexAfterEqual); |
|---|
| 146 | |
|---|
| 147 | // Get the value after the "=", before the ampersand. |
|---|
| 148 | size_t valueLocation = indexAfterEqual; |
|---|
| 149 | size_t valueLength; |
|---|
| 150 | if (ampersandLocation != notFound) |
|---|
| 151 | valueLength = ampersandLocation - indexAfterEqual; |
|---|
| 152 | else |
|---|
| 153 | valueLength = queryLength - indexAfterEqual; |
|---|
| 154 | |
|---|
| 155 | // Save the key and the value. |
|---|
| 156 | if (keyLength && valueLength) { |
|---|
| 157 | String key = queryString.substring(keyLocation, keyLength).convertToASCIILowercase(); |
|---|
| 158 | String value = queryString.substring(valueLocation, valueLength); |
|---|
| 159 | value.replace('+', ' '); |
|---|
| 160 | |
|---|
| 161 | if (!key.isEmpty() && !value.isEmpty()) |
|---|
| 162 | queryDictionary.add(key, value); |
|---|
| 163 | } |
|---|
| 164 | |
|---|
| 165 | if (ampersandLocation == notFound) |
|---|
| 166 | break; |
|---|
| 167 | |
|---|
| 168 | // Continue searching after the ampersand. |
|---|
| 169 | size_t indexAfterAmpersand = ampersandLocation + 1; |
|---|
| 170 | equalSearchLocation = indexAfterAmpersand; |
|---|
| 171 | equalSearchLength = queryLength - indexAfterAmpersand; |
|---|
| 172 | } |
|---|
| 173 | |
|---|
| 174 | return queryDictionary; |
|---|
| 175 | } |
|---|
| 176 | |
|---|
| 177 | static bool hasCaseInsensitivePrefix(const String& input, const String& prefix) |
|---|
| 178 | { |
|---|
| 179 | return input.startsWith(prefix, false); |
|---|
| 180 | } |
|---|
| 181 | |
|---|
| 182 | static bool isYouTubeURL(const URL& url) |
|---|
| 183 | { |
|---|
| 184 | String hostName = url.host(); |
|---|
| 185 | return equalLettersIgnoringASCIICase(hostName, "m.youtube.com") |
|---|
| 186 | || equalLettersIgnoringASCIICase(hostName, "youtu.be") |
|---|
| 187 | || equalLettersIgnoringASCIICase(hostName, "www.youtube.com") |
|---|
| 188 | || equalLettersIgnoringASCIICase(hostName, "youtube.com") |
|---|
| 189 | || equalLettersIgnoringASCIICase(hostName, "www.youtube-nocookie.com") |
|---|
| 190 | || equalLettersIgnoringASCIICase(hostName, "youtube-nocookie.com"); |
|---|
| 191 | } |
|---|
| 192 | |
|---|
| 193 | static const String& valueForKey(const YouTubePluginReplacement::KeyValueMap& dictionary, const String& key) |
|---|
| 194 | { |
|---|
| 195 | const auto& value = dictionary.find(key); |
|---|
| 196 | if (value == dictionary.end()) |
|---|
| 197 | return emptyString(); |
|---|
| 198 | |
|---|
| 199 | return value->value; |
|---|
| 200 | } |
|---|
| 201 | |
|---|
| 202 | static URL processAndCreateYouTubeURL(const URL& url, bool& isYouTubeShortenedURL, String& outPathAfterFirstAmpersand) |
|---|
| 203 | { |
|---|
| 204 | if (!url.protocolIsInHTTPFamily()) |
|---|
| 205 | return URL(); |
|---|
| 206 | |
|---|
| 207 | // Bail out early if we aren't even on www.youtube.com or youtube.com. |
|---|
| 208 | if (!isYouTubeURL(url)) |
|---|
| 209 | return URL(); |
|---|
| 210 | |
|---|
| 211 | String hostName = url.host(); |
|---|
| 212 | bool isYouTubeMobileWebAppURL = equalLettersIgnoringASCIICase(hostName, "m.youtube.com"); |
|---|
| 213 | isYouTubeShortenedURL = equalLettersIgnoringASCIICase(hostName, "youtu.be"); |
|---|
| 214 | |
|---|
| 215 | // Short URL of the form: http://youtu.be/v1d301D |
|---|
| 216 | if (isYouTubeShortenedURL) { |
|---|
| 217 | String videoID = url.lastPathComponent(); |
|---|
| 218 | if (videoID.isEmpty() || videoID == "/") |
|---|
| 219 | return URL(); |
|---|
| 220 | return createYouTubeURL(videoID, emptyString()); |
|---|
| 221 | } |
|---|
| 222 | |
|---|
| 223 | String path = url.path(); |
|---|
| 224 | String query = url.query(); |
|---|
| 225 | String fragment = url.fragmentIdentifier(); |
|---|
| 226 | |
|---|
| 227 | // On the YouTube mobile web app, the path and query string are put into the |
|---|
| 228 | // fragment so that one web page is only ever loaded (see <rdar://problem/9550639>). |
|---|
| 229 | if (isYouTubeMobileWebAppURL) { |
|---|
| 230 | size_t location = fragment.find('?'); |
|---|
| 231 | if (location == notFound) { |
|---|
| 232 | path = fragment; |
|---|
| 233 | query = emptyString(); |
|---|
| 234 | } else { |
|---|
| 235 | path = fragment.substring(0, location); |
|---|
| 236 | query = fragment.substring(location + 1); |
|---|
| 237 | } |
|---|
| 238 | fragment = emptyString(); |
|---|
| 239 | } |
|---|
| 240 | |
|---|
| 241 | if (equalLettersIgnoringASCIICase(path, "/watch")) { |
|---|
| 242 | if (!query.isEmpty()) { |
|---|
| 243 | const auto& queryDictionary = queryKeysAndValues(query); |
|---|
| 244 | String videoID = valueForKey(queryDictionary, "v"); |
|---|
| 245 | |
|---|
| 246 | if (!videoID.isEmpty()) { |
|---|
| 247 | const auto& fragmentDictionary = queryKeysAndValues(url.fragmentIdentifier()); |
|---|
| 248 | String timeID = valueForKey(fragmentDictionary, "t"); |
|---|
| 249 | return createYouTubeURL(videoID, timeID); |
|---|
| 250 | } |
|---|
| 251 | } |
|---|
| 252 | |
|---|
| 253 | // May be a new-style link (see <rdar://problem/7733692>). |
|---|
| 254 | if (fragment.startsWith('!')) { |
|---|
| 255 | query = fragment.substring(1); |
|---|
| 256 | |
|---|
| 257 | if (!query.isEmpty()) { |
|---|
| 258 | const auto& queryDictionary = queryKeysAndValues(query); |
|---|
| 259 | String videoID = valueForKey(queryDictionary, "v"); |
|---|
| 260 | |
|---|
| 261 | if (!videoID.isEmpty()) { |
|---|
| 262 | String timeID = valueForKey(queryDictionary, "t"); |
|---|
| 263 | return createYouTubeURL(videoID, timeID); |
|---|
| 264 | } |
|---|
| 265 | } |
|---|
| 266 | } |
|---|
| 267 | } else if (hasCaseInsensitivePrefix(path, "/v/") || hasCaseInsensitivePrefix(path, "/e/")) { |
|---|
| 268 | String lastPathComponent = url.lastPathComponent(); |
|---|
| 269 | String videoID; |
|---|
| 270 | String pathAfterFirstAmpersand; |
|---|
| 271 | |
|---|
| 272 | size_t ampersandLocation = lastPathComponent.find('&'); |
|---|
| 273 | if (ampersandLocation != notFound) { |
|---|
| 274 | // Some URLs we care about use & in place of ? for the first query parameter. |
|---|
| 275 | videoID = lastPathComponent.substring(0, ampersandLocation); |
|---|
| 276 | pathAfterFirstAmpersand = lastPathComponent.substring(ampersandLocation + 1, lastPathComponent.length() - ampersandLocation); |
|---|
| 277 | } else |
|---|
| 278 | videoID = lastPathComponent; |
|---|
| 279 | |
|---|
| 280 | if (!videoID.isEmpty()) { |
|---|
| 281 | outPathAfterFirstAmpersand = pathAfterFirstAmpersand; |
|---|
| 282 | return createYouTubeURL(videoID, emptyString()); |
|---|
| 283 | } |
|---|
| 284 | } |
|---|
| 285 | |
|---|
| 286 | return URL(); |
|---|
| 287 | } |
|---|
| 288 | |
|---|
| 289 | String YouTubePluginReplacement::youTubeURL(const String& srcString) |
|---|
| 290 | { |
|---|
| 291 | URL srcURL = m_parentElement->document().completeURL(stripLeadingAndTrailingHTMLSpaces(srcString)); |
|---|
| 292 | return youTubeURLFromAbsoluteURL(srcURL, srcString); |
|---|
| 293 | } |
|---|
| 294 | |
|---|
| 295 | String YouTubePluginReplacement::youTubeURLFromAbsoluteURL(const URL& srcURL, const String& srcString) |
|---|
| 296 | { |
|---|
| 297 | bool isYouTubeShortenedURL = false; |
|---|
| 298 | String possibleMalformedQuery; |
|---|
| 299 | URL youTubeURL = processAndCreateYouTubeURL(srcURL, isYouTubeShortenedURL, possibleMalformedQuery); |
|---|
| 300 | if (srcURL.isEmpty() || youTubeURL.isEmpty()) |
|---|
| 301 | return srcString; |
|---|
| 302 | |
|---|
| 303 | // Transform the youtubeURL (youtube:VideoID) to iframe embed url which has the format: http://www.youtube.com/embed/VideoID |
|---|
| 304 | const String& srcPath = srcURL.path(); |
|---|
| 305 | const String& videoID = youTubeURL.string().substring(youTubeURL.protocol().length() + 1); |
|---|
| 306 | size_t locationOfVideoIDInPath = srcPath.find(videoID); |
|---|
| 307 | |
|---|
| 308 | size_t locationOfPathBeforeVideoID = notFound; |
|---|
| 309 | if (locationOfVideoIDInPath != notFound) { |
|---|
| 310 | ASSERT(locationOfVideoIDInPath); |
|---|
| 311 | |
|---|
| 312 | // From the original URL, we need to get the part before /path/VideoId. |
|---|
| 313 | locationOfPathBeforeVideoID = srcString.find(srcPath.substring(0, locationOfVideoIDInPath)); |
|---|
| 314 | } else if (equalLettersIgnoringASCIICase(srcPath, "/watch")) { |
|---|
| 315 | // From the original URL, we need to get the part before /watch/#!v=VideoID |
|---|
| 316 | // FIXME: Shouldn't this be ASCII case-insensitive? |
|---|
| 317 | locationOfPathBeforeVideoID = srcString.find("/watch"); |
|---|
| 318 | } else |
|---|
| 319 | return srcString; |
|---|
| 320 | |
|---|
| 321 | ASSERT(locationOfPathBeforeVideoID != notFound); |
|---|
| 322 | |
|---|
| 323 | const String& srcURLPrefix = srcString.substring(0, locationOfPathBeforeVideoID); |
|---|
| 324 | String query = srcURL.query(); |
|---|
| 325 | // If the URL has no query, use the possibly malformed query we found. |
|---|
| 326 | if (query.isEmpty()) |
|---|
| 327 | query = possibleMalformedQuery; |
|---|
| 328 | |
|---|
| 329 | // Append the query string if it is valid. |
|---|
| 330 | StringBuilder finalURL; |
|---|
| 331 | if (isYouTubeShortenedURL) |
|---|
| 332 | finalURL.appendLiteral("http://www.youtube.com"); |
|---|
| 333 | else |
|---|
| 334 | finalURL.append(srcURLPrefix); |
|---|
| 335 | finalURL.appendLiteral("/embed/"); |
|---|
| 336 | finalURL.append(videoID); |
|---|
| 337 | if (!query.isEmpty()) { |
|---|
| 338 | finalURL.append('?'); |
|---|
| 339 | finalURL.append(query); |
|---|
| 340 | } |
|---|
| 341 | return finalURL.toString(); |
|---|
| 342 | } |
|---|
| 343 | |
|---|
| 344 | bool YouTubePluginReplacement::supportsURL(const URL& url) |
|---|
| 345 | { |
|---|
| 346 | return isYouTubeURL(url); |
|---|
| 347 | } |
|---|
| 348 | |
|---|
| 349 | bool YouTubePluginReplacement::isEnabledBySettings(const Settings& settings) |
|---|
| 350 | { |
|---|
| 351 | return settings.youTubeFlashPluginReplacementEnabled(); |
|---|
| 352 | } |
|---|
| 353 | |
|---|
| 354 | } |
|---|