FragmentDirective.cpp (19225B)
1 /* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ 2 /* vim:set ts=2 sw=2 sts=2 et cindent: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this 5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 #include "FragmentDirective.h" 8 9 #include <cstdint> 10 11 #include "BasePrincipal.h" 12 #include "Document.h" 13 #include "RangeBoundary.h" 14 #include "TextDirectiveCreator.h" 15 #include "TextDirectiveFinder.h" 16 #include "TextDirectiveUtil.h" 17 #include "mozilla/Assertions.h" 18 #include "mozilla/CycleCollectedUniquePtr.h" 19 #include "mozilla/PresShell.h" 20 #include "mozilla/ResultVariant.h" 21 #include "mozilla/dom/BrowsingContext.h" 22 #include "mozilla/dom/BrowsingContextGroup.h" 23 #include "mozilla/dom/FragmentDirectiveBinding.h" 24 #include "mozilla/dom/FragmentOrElement.h" 25 #include "mozilla/dom/Promise.h" 26 #include "mozilla/dom/Selection.h" 27 #include "mozilla/glean/DomMetrics.h" 28 #include "nsContentUtils.h" 29 #include "nsDocShell.h" 30 #include "nsICSSDeclaration.h" 31 #include "nsIFrame.h" 32 #include "nsINode.h" 33 #include "nsIURIMutator.h" 34 #include "nsRange.h" 35 #include "nsString.h" 36 37 namespace mozilla::dom { 38 39 NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(FragmentDirective, mDocument, mFinder) 40 41 NS_IMPL_CYCLE_COLLECTING_ADDREF(FragmentDirective) 42 NS_IMPL_CYCLE_COLLECTING_RELEASE(FragmentDirective) 43 NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(FragmentDirective) 44 NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY 45 NS_INTERFACE_MAP_ENTRY(nsISupports) 46 NS_INTERFACE_MAP_END 47 48 FragmentDirective::FragmentDirective(Document* aDocument) 49 : mDocument(aDocument) {} 50 51 FragmentDirective::~FragmentDirective() = default; 52 53 JSObject* FragmentDirective::WrapObject(JSContext* aCx, 54 JS::Handle<JSObject*> aGivenProto) { 55 return FragmentDirective_Binding::Wrap(aCx, this, aGivenProto); 56 } 57 58 void FragmentDirective::SetTextDirectives( 59 nsTArray<TextDirective>&& aTextDirectives) { 60 MOZ_ASSERT(mDocument); 61 if (!aTextDirectives.IsEmpty()) { 62 mFinder.reset( 63 new TextDirectiveFinder(mDocument, std::move(aTextDirectives))); 64 } else { 65 mFinder = nullptr; 66 } 67 } 68 69 void FragmentDirective::ClearUninvokedDirectives() { mFinder = nullptr; } 70 bool FragmentDirective::HasUninvokedDirectives() const { return !!mFinder; }; 71 72 bool FragmentDirective::ParseAndRemoveFragmentDirectiveFromFragmentString( 73 nsCString& aFragment, nsTArray<TextDirective>* aTextDirectives, 74 nsIURI* aURI) { 75 auto uri = TextDirectiveUtil::ShouldLog() && aURI ? aURI->GetSpecOrDefault() 76 : nsCString(); 77 if (aFragment.IsEmpty()) { 78 TEXT_FRAGMENT_LOG("URL '{}' has no fragment.", uri); 79 return false; 80 } 81 TEXT_FRAGMENT_LOG( 82 "Trying to extract a fragment directive from fragment '{}' of URL '{}'.", 83 aFragment, uri); 84 ParsedFragmentDirectiveResult fragmentDirective; 85 const bool hasRemovedFragmentDirective = 86 StaticPrefs::dom_text_fragments_enabled() && 87 parse_fragment_directive(&aFragment, &fragmentDirective); 88 if (hasRemovedFragmentDirective) { 89 TEXT_FRAGMENT_LOG( 90 "Found a fragment directive '{}', which was removed from the fragment. " 91 "New fragment is '{}'.", 92 fragmentDirective.fragment_directive, 93 fragmentDirective.hash_without_fragment_directive); 94 if (TextDirectiveUtil::ShouldLog()) { 95 if (fragmentDirective.text_directives.IsEmpty()) { 96 TEXT_FRAGMENT_LOG( 97 "Found no valid text directives in fragment directive '{}'.", 98 fragmentDirective.fragment_directive); 99 } else { 100 TEXT_FRAGMENT_LOG( 101 "Found {} valid text directives in fragment directive '{}':", 102 fragmentDirective.text_directives.Length(), 103 fragmentDirective.fragment_directive); 104 for (size_t index = 0; 105 index < fragmentDirective.text_directives.Length(); ++index) { 106 const auto& textDirective = fragmentDirective.text_directives[index]; 107 TEXT_FRAGMENT_LOG(" [{}]: {}", index, ToString(textDirective)); 108 } 109 } 110 } 111 aFragment = fragmentDirective.hash_without_fragment_directive; 112 if (aTextDirectives) { 113 aTextDirectives->SwapElements(fragmentDirective.text_directives); 114 } 115 } else { 116 TEXT_FRAGMENT_LOG( 117 "Fragment '{}' of URL '{}' did not contain a fragment directive.", 118 aFragment, uri); 119 } 120 return hasRemovedFragmentDirective; 121 } 122 123 void FragmentDirective::ParseAndRemoveFragmentDirectiveFromFragment( 124 nsCOMPtr<nsIURI>& aURI, nsTArray<TextDirective>* aTextDirectives) { 125 if (!aURI || !StaticPrefs::dom_text_fragments_enabled()) { 126 return; 127 } 128 bool hasRef = false; 129 aURI->GetHasRef(&hasRef); 130 131 nsAutoCString hash; 132 aURI->GetRef(hash); 133 if (!hasRef || hash.IsEmpty()) { 134 TEXT_FRAGMENT_LOG("URL '{}' has no fragment. Exiting.", 135 aURI->GetSpecOrDefault()); 136 } 137 138 const bool hasRemovedFragmentDirective = 139 ParseAndRemoveFragmentDirectiveFromFragmentString(hash, aTextDirectives, 140 aURI); 141 if (!hasRemovedFragmentDirective) { 142 return; 143 } 144 (void)NS_MutateURI(aURI).SetRef(hash).Finalize(aURI); 145 TEXT_FRAGMENT_LOG("Updated hash of the URL. New URL: {}", 146 aURI->GetSpecOrDefault()); 147 } 148 149 nsTArray<RefPtr<nsRange>> FragmentDirective::FindTextFragmentsInDocument() { 150 MOZ_ASSERT(mDocument); 151 if (!mFinder) { 152 auto uri = TextDirectiveUtil::ShouldLog() && mDocument->GetDocumentURI() 153 ? mDocument->GetDocumentURI()->GetSpecOrDefault() 154 : nsCString(); 155 TEXT_FRAGMENT_LOG("No uninvoked text directives in document '{}'. Exiting.", 156 uri); 157 return {}; 158 } 159 auto textDirectives = mFinder->FindTextDirectivesInDocument(); 160 if (!mFinder->HasUninvokedDirectives()) { 161 mFinder = nullptr; 162 } 163 return textDirectives; 164 } 165 166 /* static */ nsresult FragmentDirective::GetSpecIgnoringFragmentDirective( 167 nsCOMPtr<nsIURI>& aURI, nsACString& aSpecIgnoringFragmentDirective) { 168 bool hasRef = false; 169 if (aURI->GetHasRef(&hasRef); !hasRef) { 170 return aURI->GetSpec(aSpecIgnoringFragmentDirective); 171 } 172 173 nsAutoCString ref; 174 nsresult rv = aURI->GetRef(ref); 175 if (NS_FAILED(rv)) { 176 return rv; 177 } 178 179 rv = aURI->GetSpecIgnoringRef(aSpecIgnoringFragmentDirective); 180 if (NS_FAILED(rv)) { 181 return rv; 182 } 183 184 ParseAndRemoveFragmentDirectiveFromFragmentString(ref); 185 186 if (!ref.IsEmpty()) { 187 aSpecIgnoringFragmentDirective.Append('#'); 188 aSpecIgnoringFragmentDirective.Append(ref); 189 } 190 191 return NS_OK; 192 } 193 194 bool FragmentDirective::IsTextDirectiveAllowedToBeScrolledTo() { 195 // This method follows 196 // https://wicg.github.io/scroll-to-text-fragment/#check-if-a-text-directive-can-be-scrolled 197 // However, there are some spec issues 198 // (https://github.com/WICG/scroll-to-text-fragment/issues/240). 199 // The web-platform tests currently seem more up-to-date. Therefore, 200 // this method is adapted slightly to make sure all tests pass. 201 // Comments are added to explain changes. 202 203 MOZ_ASSERT(mDocument); 204 auto uri = TextDirectiveUtil::ShouldLog() && mDocument->GetDocumentURI() 205 ? mDocument->GetDocumentURI()->GetSpecOrDefault() 206 : nsCString(); 207 TEXT_FRAGMENT_LOG( 208 "Trying to find out if the load of URL '{}' is allowed to scroll to the " 209 "text fragment", 210 uri); 211 // It seems the spec does not cover same-document navigation in particular, 212 // or Gecko needs to deal with this in a different way due to the 213 // implementation not following the spec step-by-step. 214 // Therefore, the following algorithm needs some adaptions to deal with 215 // same-document navigations correctly. 216 217 nsCOMPtr<nsILoadInfo> loadInfo = 218 mDocument->GetChannel() ? mDocument->GetChannel()->LoadInfo() : nullptr; 219 const bool isSameDocumentNavigation = 220 loadInfo && loadInfo->GetIsSameDocumentNavigation(); 221 222 TEXT_FRAGMENT_LOG("Current load is{} a same-document navigation.", 223 isSameDocumentNavigation ? "" : " not"); 224 225 // 1. If document's pending text directives field is null or empty, return 226 // false. 227 // --- 228 // we don't store the *pending* text directives in this class, only the 229 // *uninvoked* text directives (uninvoked = `TextDirective`, pending = 230 // `nsRange`). 231 // Uninvoked text directives are typically already processed into pending text 232 // directives when this code is called. Pending text directives are handled by 233 // the caller when this code runs; therefore, the caller should decide if this 234 // method should be called or not. 235 236 // 2. Let is user involved be true if: document's text directive user 237 // activation is true, or user involvement is one of "activation" or "browser 238 // UI"; false otherwise. 239 // 3. Set document's text directive user activation to false. 240 const bool textDirectiveUserActivation = 241 mDocument->ConsumeTextDirectiveUserActivation(); 242 TEXT_FRAGMENT_LOG( 243 "Consumed Document's TextDirectiveUserActivation flag (value={})", 244 textDirectiveUserActivation ? "true" : "false"); 245 246 // 4. If document's content type is not a text directive allowing MIME type, 247 // return false. 248 const bool isAllowedMIMEType = [doc = this->mDocument, func = __FUNCTION__] { 249 nsAutoString contentType; 250 doc->GetContentType(contentType); 251 TEXT_FRAGMENT_LOG_FN("Got document MIME type: {}", func, 252 NS_ConvertUTF16toUTF8(contentType)); 253 return contentType == u"text/html" || contentType == u"text/plain"; 254 }(); 255 256 if (!isAllowedMIMEType) { 257 TEXT_FRAGMENT_LOG("Invalid document MIME type. Scrolling not allowed."); 258 return false; 259 } 260 261 // 5. If user involvement is "browser UI", return true. 262 // 263 // If a navigation originates from browser UI, it's always ok to allow it 264 // since it'll be user triggered and the page/script isn't providing the text 265 // snippet. 266 // 267 // Note: The intent in this item is to distinguish cases where the app/page is 268 // able to control the URL from those that are fully under the user's 269 // control. In the former we want to prevent scrolling of the text fragment 270 // unless the destination is loaded in a separate browsing context group (so 271 // that the source cannot both control the text snippet and observe 272 // side-effects in the navigation). There are some cases where "browser UI" 273 // may be a grey area in this regard. E.g. an "open in new window" context 274 // menu item when right clicking on a link. 275 // 276 // See sec-fetch-site [0] for a related discussion on how this applies. 277 // [0] https://w3c.github.io/webappsec-fetch-metadata/#directly-user-initiated 278 // --- 279 // Gecko does not implement user involvement as defined in the spec. 280 // However, if the triggering principal is the system principal, the load 281 // has been triggered from browser chrome. This should be good enough for now. 282 auto* triggeringPrincipal = 283 loadInfo ? loadInfo->TriggeringPrincipal() : nullptr; 284 const bool isTriggeredFromBrowserUI = 285 triggeringPrincipal && triggeringPrincipal->IsSystemPrincipal(); 286 287 if (isTriggeredFromBrowserUI) { 288 TEXT_FRAGMENT_LOG( 289 "The load is triggered from browser UI. Scrolling allowed."); 290 return true; 291 } 292 TEXT_FRAGMENT_LOG("The load is not triggered from browser UI."); 293 // 6. If is user involved is false, return false. 294 // --- 295 // same-document navigation is not mentioned in the spec. However, we run this 296 // code also in same-document navigation cases. 297 // Same-document navigation is allowed even without any user interaction. 298 if (!textDirectiveUserActivation && !isSameDocumentNavigation) { 299 TEXT_FRAGMENT_LOG( 300 "User involvement is false and not same-document navigation. Scrolling " 301 "not allowed."); 302 return false; 303 } 304 // 7. If document's node navigable has a parent, return false. 305 // --- 306 // this is extended to ignore this rule if this is a same-document navigation 307 // in an iframe, which is allowed when the document's origin matches the 308 // initiator's origin (which is checked in step 8). 309 nsDocShell* docShell = nsDocShell::Cast(mDocument->GetDocShell()); 310 if (!isSameDocumentNavigation && 311 (!docShell || !docShell->GetIsTopLevelContentDocShell())) { 312 TEXT_FRAGMENT_LOG( 313 "Document's node navigable has a parent and this is not a " 314 "same-document navigation. Scrolling not allowed."); 315 return false; 316 } 317 // 8. If initiator origin is non-null and document's origin is same origin 318 // with initiator origin, return true. 319 const bool isSameOrigin = [doc = this->mDocument, triggeringPrincipal] { 320 auto* docPrincipal = doc->GetPrincipal(); 321 return triggeringPrincipal && docPrincipal && 322 docPrincipal->Equals(triggeringPrincipal); 323 }(); 324 325 if (isSameOrigin) { 326 TEXT_FRAGMENT_LOG("Same origin. Scrolling allowed."); 327 return true; 328 } 329 TEXT_FRAGMENT_LOG("Not same origin."); 330 331 // 9. If document's browsing context's group's browsing context set has length 332 // 1, return true. 333 // 334 // i.e. Only allow navigation from a cross-origin element/script if the 335 // document is loaded in a noopener context. That is, a new top level browsing 336 // context group to which the navigator does not have script access and which 337 // can be placed into a separate process. 338 if (BrowsingContextGroup* group = 339 mDocument->GetBrowsingContext() 340 ? mDocument->GetBrowsingContext()->Group() 341 : nullptr) { 342 const bool isNoOpenerContext = group->Toplevels().Length() == 1; 343 if (!isNoOpenerContext) { 344 TEXT_FRAGMENT_LOG( 345 "Cross-origin + noopener=false. Scrolling not allowed."); 346 } 347 return isNoOpenerContext; 348 } 349 350 // 10.Otherwise, return false. 351 TEXT_FRAGMENT_LOG("Scrolling not allowed."); 352 return false; 353 } 354 355 void FragmentDirective::HighlightTextDirectives( 356 const nsTArray<RefPtr<nsRange>>& aTextDirectiveRanges) { 357 MOZ_ASSERT(mDocument); 358 if (!StaticPrefs::dom_text_fragments_enabled()) { 359 return; 360 } 361 auto uri = TextDirectiveUtil::ShouldLog() && mDocument->GetDocumentURI() 362 ? mDocument->GetDocumentURI()->GetSpecOrDefault() 363 : nsCString(); 364 if (aTextDirectiveRanges.IsEmpty()) { 365 TEXT_FRAGMENT_LOG( 366 "No text directive ranges to highlight for document '{}'. Exiting.", 367 uri); 368 return; 369 } 370 371 TEXT_FRAGMENT_LOG( 372 "Highlighting text directives for document '{}' ({} ranges).", uri, 373 aTextDirectiveRanges.Length()); 374 375 const RefPtr<Selection> targetTextSelection = 376 [doc = this->mDocument]() -> Selection* { 377 if (auto* presShell = doc->GetPresShell()) { 378 return presShell->GetCurrentSelection(SelectionType::eTargetText); 379 } 380 return nullptr; 381 }(); 382 if (!targetTextSelection) { 383 return; 384 } 385 for (const RefPtr<nsRange>& range : aTextDirectiveRanges) { 386 // Script won't be able to manipulate `aTextDirectiveRanges`, 387 // therefore we can mark `range` as known live. 388 targetTextSelection->AddRangeAndSelectFramesAndNotifyListeners( 389 MOZ_KnownLive(*range), IgnoreErrors()); 390 } 391 } 392 393 void FragmentDirective::GetTextDirectiveRanges( 394 nsTArray<RefPtr<nsRange>>& aRanges) const { 395 if (!StaticPrefs::dom_text_fragments_enabled()) { 396 return; 397 } 398 auto* presShell = mDocument ? mDocument->GetPresShell() : nullptr; 399 if (!presShell) { 400 return; 401 } 402 RefPtr<Selection> targetTextSelection = 403 presShell->GetCurrentSelection(SelectionType::eTargetText); 404 if (!targetTextSelection) { 405 return; 406 } 407 408 aRanges.Clear(); 409 for (uint32_t rangeIndex = 0; rangeIndex < targetTextSelection->RangeCount(); 410 ++rangeIndex) { 411 nsRange* range = targetTextSelection->GetRangeAt(rangeIndex); 412 MOZ_ASSERT(range); 413 aRanges.AppendElement(range); 414 } 415 } 416 void FragmentDirective::RemoveAllTextDirectives(ErrorResult& aRv) { 417 if (!StaticPrefs::dom_text_fragments_enabled()) { 418 return; 419 } 420 auto* presShell = mDocument ? mDocument->GetPresShell() : nullptr; 421 if (!presShell) { 422 return; 423 } 424 RefPtr<Selection> targetTextSelection = 425 presShell->GetCurrentSelection(SelectionType::eTargetText); 426 if (!targetTextSelection) { 427 return; 428 } 429 targetTextSelection->RemoveAllRanges(aRv); 430 } 431 432 already_AddRefed<Promise> FragmentDirective::CreateTextDirectiveForRanges( 433 const Sequence<OwningNonNull<nsRange>>& aRanges) { 434 RefPtr<Promise> resultPromise = 435 Promise::Create(mDocument->GetOwnerGlobal(), IgnoreErrors()); 436 if (!resultPromise) { 437 return nullptr; 438 } 439 if (!StaticPrefs::dom_text_fragments_enabled()) { 440 TEXT_FRAGMENT_LOG("Creating text fragments is disabled."); 441 resultPromise->MaybeResolve(JS::NullHandleValue); 442 return resultPromise.forget(); 443 } 444 if (aRanges.IsEmpty()) { 445 TEXT_FRAGMENT_LOG("No ranges. Nothing to do here..."); 446 resultPromise->MaybeResolve(JS::NullHandleValue); 447 return resultPromise.forget(); 448 } 449 TEXT_FRAGMENT_LOG("Creating text directive for {} ranges.", aRanges.Length()); 450 451 nsTArray<nsCString> textDirectives; 452 textDirectives.SetCapacity(aRanges.Length()); 453 454 const TimeStamp start = TimeStamp::Now(); 455 RefPtr<TimeoutWatchdog> watchdog = new TimeoutWatchdog(); 456 uint32_t rangeIndex = 0; 457 for (const auto& range : aRanges) { 458 ++rangeIndex; 459 460 if (range->Collapsed()) { 461 TEXT_FRAGMENT_LOG("Skipping collapsed range {}.", rangeIndex); 462 continue; 463 } 464 Result<nsCString, ErrorResult> maybeTextDirective = 465 TextDirectiveCreator::CreateTextDirectiveFromRange(mDocument, range, 466 watchdog); 467 if (MOZ_UNLIKELY(maybeTextDirective.isErr())) { 468 TEXT_FRAGMENT_LOG("Failed to create text directive for range {}.", 469 rangeIndex); 470 resultPromise->MaybeReject(maybeTextDirective.unwrapErr()); 471 return resultPromise.forget(); 472 } 473 nsCString textDirective = maybeTextDirective.unwrap(); 474 if (textDirective.IsEmpty() || textDirective.IsVoid()) { 475 TEXT_FRAGMENT_LOG("Skipping empty text directive for range {}.", 476 rangeIndex); 477 continue; 478 } 479 textDirectives.AppendElement(std::move(textDirective)); 480 TEXT_FRAGMENT_LOG("Created text directive for range {}: {}", rangeIndex, 481 textDirectives.LastElement()); 482 } 483 484 if (watchdog->IsDone()) { 485 TEXT_FRAGMENT_LOG("Hitting timeout while creating text directives."); 486 resultPromise->MaybeResolve(JS::NullHandleValue); 487 } else if (textDirectives.IsEmpty()) { 488 TEXT_FRAGMENT_LOG("No text directives created."); 489 mDocument->SetUseCounter(eUseCounter_custom_TextDirectiveNotCreated); 490 resultPromise->MaybeResolve(JS::NullHandleValue); 491 } else { 492 TEXT_FRAGMENT_LOG("Created {} text directives in total.", 493 textDirectives.Length()); 494 nsAutoCString textDirectivesString; 495 StringJoinAppend(textDirectivesString, "&"_ns, textDirectives); 496 TEXT_FRAGMENT_LOG("Created text directive string: '{}'.", 497 textDirectivesString); 498 resultPromise->MaybeResolve(textDirectivesString); 499 } 500 501 glean::dom_textfragment::create_directive.AccumulateRawDuration( 502 TimeStamp::Now() - start); 503 504 return resultPromise.forget(); 505 } 506 507 } // namespace mozilla::dom