From ffd28232610b87b3c738357655b95feedb3479d0 Mon Sep 17 00:00:00 2001
From: Mike Greiling <mike@pixelcog.com>
Date: Mon, 21 Nov 2016 12:18:49 -0600
Subject: [PATCH 1/8] remove duplicate functionality (bad merge conflict
 resolution?)

---
 app/assets/javascripts/application.js         | 31 ++++---------------
 .../javascripts/lib/utils/common_utils.js     | 16 ++++++++--
 2 files changed, 19 insertions(+), 28 deletions(-)

diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 76f3c6506ed..ec6d7cad3ab 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -57,32 +57,13 @@
 
 (function () {
   document.addEventListener('page:fetch', gl.utils.cleanupBeforeFetch);
-  window.addEventListener('hashchange', gl.utils.shiftWindow);
-
-  // automatically adjust scroll position for hash urls taking the height of the navbar into account
-  // https://github.com/twitter/bootstrap/issues/1768
-  window.adjustScroll = function() {
-    var navbar = document.querySelector('.navbar-gitlab');
-    var subnav = document.querySelector('.layout-nav');
-    var fixedTabs = document.querySelector('.js-tabs-affix');
-
-    adjustment = 0;
-    if (navbar) adjustment -= navbar.offsetHeight;
-    if (subnav) adjustment -= subnav.offsetHeight;
-    if (fixedTabs) adjustment -= fixedTabs.offsetHeight;
-
-    return scrollBy(0, adjustment);
-  };
-
-  window.addEventListener("hashchange", adjustScroll);
-
-  window.onload = function () {
-    // Scroll the window to avoid the topnav bar
-    // https://github.com/twitter/bootstrap/issues/1768
-    if (location.hash) {
-      return setTimeout(adjustScroll, 100);
+  window.addEventListener('hashchange', gl.utils.handleLocationHash);
+  window.addEventListener('load', function onLoad() {
+    window.removeEventListener('load', onLoad, false);
+    if (window.location.hash) {
+      setTimeout(gl.utils.handleLocationHash, 100);
     }
-  };
+  }, false);
 
   $(function () {
     var $body = $('body');
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index d83c41fae9d..b6b3bd18877 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -94,10 +94,20 @@
       return $(document).off('scroll');
     };
 
-    w.gl.utils.shiftWindow = function() {
-      return w.scrollBy(0, -100);
-    };
+    // automatically adjust scroll position for hash urls taking the height of the navbar into account
+    // https://github.com/twitter/bootstrap/issues/1768
+    w.gl.utils.handleLocationHash = function() {
+      var navbar = document.querySelector('.navbar-gitlab');
+      var subnav = document.querySelector('.layout-nav');
+      var fixedTabs = document.querySelector('.js-tabs-affix');
+
+      var adjustment = 0;
+      if (navbar) adjustment -= navbar.offsetHeight;
+      if (subnav) adjustment -= subnav.offsetHeight;
+      if (fixedTabs) adjustment -= fixedTabs.offsetHeight;
 
+      window.scrollBy(0, adjustment);
+    };
 
     gl.utils.updateTooltipTitle = function($tooltipEl, newTitle) {
       return $tooltipEl.tooltip('destroy').attr('title', newTitle).tooltip('fixTitle');
-- 
GitLab


From ff2026f40ec0bc162dc4281b067ed4716b2ad248 Mon Sep 17 00:00:00 2001
From: Mike Greiling <mike@pixelcog.com>
Date: Mon, 21 Nov 2016 12:02:19 -0600
Subject: [PATCH 2/8] add transparent namespace to all user-generated anchors
 in GitLab flavored markdown

---
 .../javascripts/lib/utils/common_utils.js     | 21 ++++++++++++++++---
 lib/banzai/filter/table_of_contents_filter.rb |  8 ++++---
 2 files changed, 23 insertions(+), 6 deletions(-)

diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index b6b3bd18877..09b0a5eb9a5 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-unused-expressions, no-param-reassign, no-else-return, quotes, object-shorthand, comma-dangle, camelcase, one-var, vars-on-top, one-var-declaration-per-line, no-return-assign, consistent-return, padded-blocks, max-len */
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-unused-expressions, no-param-reassign, no-else-return, quotes, object-shorthand, comma-dangle, camelcase, one-var, vars-on-top, one-var-declaration-per-line, no-return-assign, consistent-return, padded-blocks, max-len, prefer-template */
 (function() {
   (function(w) {
     var base;
@@ -97,6 +97,9 @@
     // automatically adjust scroll position for hash urls taking the height of the navbar into account
     // https://github.com/twitter/bootstrap/issues/1768
     w.gl.utils.handleLocationHash = function() {
+      var hash = w.gl.utils.getLocationHash();
+      if (!hash) return;
+
       var navbar = document.querySelector('.navbar-gitlab');
       var subnav = document.querySelector('.layout-nav');
       var fixedTabs = document.querySelector('.js-tabs-affix');
@@ -104,9 +107,21 @@
       var adjustment = 0;
       if (navbar) adjustment -= navbar.offsetHeight;
       if (subnav) adjustment -= subnav.offsetHeight;
-      if (fixedTabs) adjustment -= fixedTabs.offsetHeight;
 
-      window.scrollBy(0, adjustment);
+      // scroll to user-generated markdown anchor if we cannot find a match
+      if (document.getElementById(hash) === null) {
+        var target = document.getElementById('user-content_' + hash);
+        if (target && target.scrollIntoView) {
+          target.scrollIntoView(true);
+          window.scrollBy(0, adjustment);
+        }
+      } else {
+        // only adjust for fixedTabs when not targeting user-generated content
+        if (fixedTabs) {
+          adjustment -= fixedTabs.offsetHeight;
+        }
+        window.scrollBy(0, adjustment);
+      }
     };
 
     gl.utils.updateTooltipTitle = function($tooltipEl, newTitle) {
diff --git a/lib/banzai/filter/table_of_contents_filter.rb b/lib/banzai/filter/table_of_contents_filter.rb
index a4eda6fdf76..80669953723 100644
--- a/lib/banzai/filter/table_of_contents_filter.rb
+++ b/lib/banzai/filter/table_of_contents_filter.rb
@@ -35,9 +35,11 @@ module Banzai
           headers[id] += 1
 
           if header_content = node.children.first
+            # namespace detection will be automatically handled via javascript (see issue #22781)
+            namespace = "user-content_"
             href = "#{id}#{uniq}"
             push_toc(href, text)
-            header_content.add_previous_sibling(anchor_tag(href))
+            header_content.add_previous_sibling(anchor_tag("#{namespace}#{href}", href))
           end
         end
 
@@ -48,8 +50,8 @@ module Banzai
 
       private
 
-      def anchor_tag(href)
-        %Q{<a id="#{href}" class="anchor" href="##{href}" aria-hidden="true"></a>}
+      def anchor_tag(id, href)
+        %Q{<a id="#{id}" class="anchor" href="##{href}" aria-hidden="true"></a>}
       end
 
       def push_toc(href, text)
-- 
GitLab


From 85422ea412ce1c3ea75562dfb008b6cdfaec8858 Mon Sep 17 00:00:00 2001
From: Mike Greiling <mike@pixelcog.com>
Date: Mon, 21 Nov 2016 13:27:11 -0600
Subject: [PATCH 3/8] add CHANGELOG entry for !7631

---
 changelogs/unreleased/22781-user-generated-permalinks.yml | 4 ++++
 1 file changed, 4 insertions(+)
 create mode 100644 changelogs/unreleased/22781-user-generated-permalinks.yml

diff --git a/changelogs/unreleased/22781-user-generated-permalinks.yml b/changelogs/unreleased/22781-user-generated-permalinks.yml
new file mode 100644
index 00000000000..e46739e48e3
--- /dev/null
+++ b/changelogs/unreleased/22781-user-generated-permalinks.yml
@@ -0,0 +1,4 @@
+---
+title: Prevent DOM ID collisions resulting from user-generated content anchors
+merge_request: 7631
+author:
-- 
GitLab


From 94ae12f6979db948687464e39f76a39fa5f43bae Mon Sep 17 00:00:00 2001
From: Mike Greiling <mike@pixelcog.com>
Date: Mon, 21 Nov 2016 13:41:15 -0600
Subject: [PATCH 4/8] remove id collision caveat from documentation

---
 doc/development/gotchas.md | 48 --------------------------------------
 1 file changed, 48 deletions(-)

diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md
index 7bfc9cb361f..0f78e8238af 100644
--- a/doc/development/gotchas.md
+++ b/doc/development/gotchas.md
@@ -141,51 +141,3 @@ in an initializer._
 ### Further reading
 
 - Stack Overflow: [Why you should not write inline JavaScript](http://programmers.stackexchange.com/questions/86589/why-should-i-avoid-inline-scripting)
-
-## ID-based CSS selectors need to be a bit more specific
-
-Normally, because HTML `id` attributes need to be unique to the page, it's
-perfectly fine to write some JavaScript like the following:
-
-```javascript
-$('#js-my-selector').hide();
-```
-
-However, there's a feature of GitLab's Markdown processing that [automatically
-adds anchors to header elements][ToC Processing], with the `id` attribute being
-automatically generated based on the content of the header.
-
-Unfortunately, this feature makes it possible for user-generated content to
-create a header element with the same `id` attribute we're using in our
-selector, potentially breaking the JavaScript behavior. A user could break the
-above example with the following Markdown:
-
-```markdown
-## JS My Selector
-```
-
-Which gets converted to the following HTML:
-
-```html
-<h2>
-  <a id="js-my-selector" class="anchor" href="#js-my-selector" aria-hidden="true"></a>
-  JS My Selector
-</h2>
-```
-
-[ToC Processing]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-4-stable/lib/banzai/filter/table_of_contents_filter.rb#L31-37
-
-### Solution
-
-The current recommended fix for this is to make our selectors slightly more
-specific:
-
-```javascript
-$('div#js-my-selector').hide();
-```
-
-### Further reading
-
-- Issue: [Merge request ToC anchor conflicts with tabs](https://gitlab.com/gitlab-org/gitlab-ce/issues/3908)
-- Merge Request: [Make tab target selectors less naive](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2023)
-- Merge Request: [Make cross-project reference's clipboard target less naive](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2024)
-- 
GitLab


From 567b8a99fe0e454a6d2c0b79cde86ee2dea806f3 Mon Sep 17 00:00:00 2001
From: Mike Greiling <mike@pixelcog.com>
Date: Mon, 21 Nov 2016 13:48:29 -0600
Subject: [PATCH 5/8] update gitlab flavored markdown tests to reflect
 namespaced ids

---
 features/steps/shared/markdown.rb             |  2 +-
 .../filter/table_of_contents_filter_spec.rb   | 21 ++++++++++++-------
 spec/support/matchers/markdown_matchers.rb    |  6 +++---
 3 files changed, 17 insertions(+), 12 deletions(-)

diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb
index 56b36f7c46c..d355913a7ce 100644
--- a/features/steps/shared/markdown.rb
+++ b/features/steps/shared/markdown.rb
@@ -2,7 +2,7 @@ module SharedMarkdown
   include Spinach::DSL
 
   def header_should_have_correct_id_and_link(level, text, id, parent = ".wiki")
-    node = find("#{parent} h#{level} a##{id}")
+    node = find("#{parent} h#{level} a#user-content_#{id}")
     expect(node[:href]).to eq "##{id}"
 
     # Work around a weird Capybara behavior where calling `parent` on a node
diff --git a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
index 356dd01a03a..e551a7d0ca5 100644
--- a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
+++ b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
@@ -22,7 +22,7 @@ describe Banzai::Filter::TableOfContentsFilter, lib: true do
       html = header(i, "Header #{i}")
       doc = filter(html)
 
-      expect(doc.css("h#{i} a").first.attr('id')).to eq "header-#{i}"
+      expect(doc.css("h#{i} a").first.attr('id')).to eq "user-content_header-#{i}"
     end
   end
 
@@ -32,7 +32,12 @@ describe Banzai::Filter::TableOfContentsFilter, lib: true do
       expect(doc.css('h1 a').first.attr('class')).to eq 'anchor'
     end
 
-    it 'links to the id' do
+    it 'has a namespaced id' do
+      doc = filter(header(1, 'Header'))
+      expect(doc.css('h1 a').first.attr('id')).to eq 'user-content_header'
+    end
+
+    it 'links to the non-namespaced id' do
       doc = filter(header(1, 'Header'))
       expect(doc.css('h1 a').first.attr('href')).to eq '#header'
     end
@@ -40,29 +45,29 @@ describe Banzai::Filter::TableOfContentsFilter, lib: true do
     describe 'generated IDs' do
       it 'translates spaces to dashes' do
         doc = filter(header(1, 'This header has spaces in it'))
-        expect(doc.css('h1 a').first.attr('id')).to eq 'this-header-has-spaces-in-it'
+        expect(doc.css('h1 a').first.attr('href')).to eq '#this-header-has-spaces-in-it'
       end
 
       it 'squeezes multiple spaces and dashes' do
         doc = filter(header(1, 'This---header     is poorly-formatted'))
-        expect(doc.css('h1 a').first.attr('id')).to eq 'this-header-is-poorly-formatted'
+        expect(doc.css('h1 a').first.attr('href')).to eq '#this-header-is-poorly-formatted'
       end
 
       it 'removes punctuation' do
         doc = filter(header(1, "This, header! is, filled. with @ punctuation?"))
-        expect(doc.css('h1 a').first.attr('id')).to eq 'this-header-is-filled-with-punctuation'
+        expect(doc.css('h1 a').first.attr('href')).to eq '#this-header-is-filled-with-punctuation'
       end
 
       it 'appends a unique number to duplicates' do
         doc = filter(header(1, 'One') + header(2, 'One'))
 
-        expect(doc.css('h1 a').first.attr('id')).to eq 'one'
-        expect(doc.css('h2 a').first.attr('id')).to eq 'one-1'
+        expect(doc.css('h1 a').first.attr('href')).to eq '#one'
+        expect(doc.css('h2 a').first.attr('href')).to eq '#one-1'
       end
 
       it 'supports Unicode' do
         doc = filter(header(1, '한글'))
-        expect(doc.css('h1 a').first.attr('id')).to eq '한글'
+        expect(doc.css('h1 a').first.attr('id')).to eq 'user-content_한글'
         expect(doc.css('h1 a').first.attr('href')).to eq '#한글'
       end
     end
diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb
index 8c98b1f988c..a44bd2601c1 100644
--- a/spec/support/matchers/markdown_matchers.rb
+++ b/spec/support/matchers/markdown_matchers.rb
@@ -38,9 +38,9 @@ module MarkdownMatchers
     set_default_markdown_messages
 
     match do |actual|
-      expect(actual).to have_selector('h1 a#gitlab-markdown')
-      expect(actual).to have_selector('h2 a#markdown')
-      expect(actual).to have_selector('h3 a#autolinkfilter')
+      expect(actual).to have_selector('h1 a#user-content_gitlab-markdown')
+      expect(actual).to have_selector('h2 a#user-content_markdown')
+      expect(actual).to have_selector('h3 a#user-content_autolinkfilter')
     end
   end
 
-- 
GitLab


From 118ab549147684d86af39fd9229789e6b15d09a7 Mon Sep 17 00:00:00 2001
From: Mike Greiling <mike@pixelcog.com>
Date: Tue, 22 Nov 2016 16:11:12 -0600
Subject: [PATCH 6/8] prevent anchor tag outline on :focus or :target
 pseudo-class

---
 app/assets/stylesheets/framework/typography.scss | 1 +
 1 file changed, 1 insertion(+)

diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 070e42d63d2..aa604b1cd19 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -182,6 +182,7 @@
       left: -16px;
       position: absolute;
       text-decoration: none;
+      outline: none;
 
       &::after {
         content: image-url('icon_anchor.svg');
-- 
GitLab


From 19f174bc68e0dc17e0298d8903bc855868334776 Mon Sep 17 00:00:00 2001
From: Mike Greiling <mike@pixelcog.com>
Date: Tue, 22 Nov 2016 17:23:34 -0600
Subject: [PATCH 7/8] remove setTimeout wrapper for location hash correction

---
 app/assets/javascripts/application.js | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index ec6d7cad3ab..cfab4721f4b 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -60,9 +60,7 @@
   window.addEventListener('hashchange', gl.utils.handleLocationHash);
   window.addEventListener('load', function onLoad() {
     window.removeEventListener('load', onLoad, false);
-    if (window.location.hash) {
-      setTimeout(gl.utils.handleLocationHash, 100);
-    }
+    gl.utils.handleLocationHash();
   }, false);
 
   $(function () {
-- 
GitLab


From 131a04d7962b01daa58b8e5efe3f1359a3e73fe1 Mon Sep 17 00:00:00 2001
From: Mike Greiling <mike@pixelcog.com>
Date: Tue, 29 Nov 2016 13:07:38 -0600
Subject: [PATCH 8/8] remove underscore from user-content id namespace

---
 app/assets/javascripts/lib/utils/common_utils.js        | 2 +-
 features/steps/shared/markdown.rb                       | 2 +-
 lib/banzai/filter/table_of_contents_filter.rb           | 2 +-
 spec/lib/banzai/filter/table_of_contents_filter_spec.rb | 6 +++---
 spec/support/matchers/markdown_matchers.rb              | 6 +++---
 5 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 09b0a5eb9a5..c5846068b07 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -110,7 +110,7 @@
 
       // scroll to user-generated markdown anchor if we cannot find a match
       if (document.getElementById(hash) === null) {
-        var target = document.getElementById('user-content_' + hash);
+        var target = document.getElementById('user-content-' + hash);
         if (target && target.scrollIntoView) {
           target.scrollIntoView(true);
           window.scrollBy(0, adjustment);
diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb
index d355913a7ce..a036d9b884f 100644
--- a/features/steps/shared/markdown.rb
+++ b/features/steps/shared/markdown.rb
@@ -2,7 +2,7 @@ module SharedMarkdown
   include Spinach::DSL
 
   def header_should_have_correct_id_and_link(level, text, id, parent = ".wiki")
-    node = find("#{parent} h#{level} a#user-content_#{id}")
+    node = find("#{parent} h#{level} a#user-content-#{id}")
     expect(node[:href]).to eq "##{id}"
 
     # Work around a weird Capybara behavior where calling `parent` on a node
diff --git a/lib/banzai/filter/table_of_contents_filter.rb b/lib/banzai/filter/table_of_contents_filter.rb
index 80669953723..8e7084f2543 100644
--- a/lib/banzai/filter/table_of_contents_filter.rb
+++ b/lib/banzai/filter/table_of_contents_filter.rb
@@ -36,7 +36,7 @@ module Banzai
 
           if header_content = node.children.first
             # namespace detection will be automatically handled via javascript (see issue #22781)
-            namespace = "user-content_"
+            namespace = "user-content-"
             href = "#{id}#{uniq}"
             push_toc(href, text)
             header_content.add_previous_sibling(anchor_tag("#{namespace}#{href}", href))
diff --git a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
index e551a7d0ca5..70b31f3a880 100644
--- a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
+++ b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
@@ -22,7 +22,7 @@ describe Banzai::Filter::TableOfContentsFilter, lib: true do
       html = header(i, "Header #{i}")
       doc = filter(html)
 
-      expect(doc.css("h#{i} a").first.attr('id')).to eq "user-content_header-#{i}"
+      expect(doc.css("h#{i} a").first.attr('id')).to eq "user-content-header-#{i}"
     end
   end
 
@@ -34,7 +34,7 @@ describe Banzai::Filter::TableOfContentsFilter, lib: true do
 
     it 'has a namespaced id' do
       doc = filter(header(1, 'Header'))
-      expect(doc.css('h1 a').first.attr('id')).to eq 'user-content_header'
+      expect(doc.css('h1 a').first.attr('id')).to eq 'user-content-header'
     end
 
     it 'links to the non-namespaced id' do
@@ -67,7 +67,7 @@ describe Banzai::Filter::TableOfContentsFilter, lib: true do
 
       it 'supports Unicode' do
         doc = filter(header(1, '한글'))
-        expect(doc.css('h1 a').first.attr('id')).to eq 'user-content_한글'
+        expect(doc.css('h1 a').first.attr('id')).to eq 'user-content-한글'
         expect(doc.css('h1 a').first.attr('href')).to eq '#한글'
       end
     end
diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb
index a44bd2601c1..97b8b342eb2 100644
--- a/spec/support/matchers/markdown_matchers.rb
+++ b/spec/support/matchers/markdown_matchers.rb
@@ -38,9 +38,9 @@ module MarkdownMatchers
     set_default_markdown_messages
 
     match do |actual|
-      expect(actual).to have_selector('h1 a#user-content_gitlab-markdown')
-      expect(actual).to have_selector('h2 a#user-content_markdown')
-      expect(actual).to have_selector('h3 a#user-content_autolinkfilter')
+      expect(actual).to have_selector('h1 a#user-content-gitlab-markdown')
+      expect(actual).to have_selector('h2 a#user-content-markdown')
+      expect(actual).to have_selector('h3 a#user-content-autolinkfilter')
     end
   end
 
-- 
GitLab