relative_link_filter.rb 5.2 KB
Newer Older
1 2
# frozen_string_literal: true

3 4
require 'uri'

5 6
module Banzai
  module Filter
7
    # HTML filter that "fixes" relative links to uploads or files in a repository.
8 9 10
    #
    # Context options:
    #   :commit
11
    #   :group
12
    #   :current_user
13 14 15
    #   :project
    #   :project_wiki
    #   :ref
16
    #   :requested_path
17
    class RelativeLinkFilter < HTML::Pipeline::Filter
18
      include Gitlab::Utils::StrongMemoize
19

20
      def call
21 22
        return doc if context[:system_note]

23
        @uri_types = {}
24
        clear_memoization(:linkable_files)
25

26
        doc.search('a:not(.gfm)').each do |el|
27 28 29
          process_link_attr el.attribute('href')
        end

30
        doc.css('img, video').each do |el|
31
          process_link_attr el.attribute('src')
32
          process_link_attr el.attribute('data-src')
33 34 35 36 37 38 39
        end

        doc
      end

      protected

40
      def linkable_files?
41 42 43
        strong_memoize(:linkable_files) do
          context[:project_wiki].nil? && repository.try(:exists?) && !repository.empty?
        end
44 45
      end

46 47
      def process_link_attr(html_attr)
        return if html_attr.blank?
48
        return if html_attr.value.start_with?('//')
49

50 51
        if html_attr.value.start_with?('/uploads/')
          process_link_to_upload_attr(html_attr)
52
        elsif linkable_files? && repo_visible_to_user?
53 54 55 56 57
          process_link_to_repository_attr(html_attr)
        end
      end

      def process_link_to_upload_attr(html_attr)
58
        path_parts = [Addressable::URI.unescape(html_attr.value)]
59

60
        if project
61
          path_parts.unshift(relative_url_root, project.full_path)
62 63
        elsif group
          path_parts.unshift(relative_url_root, 'groups', group.full_path, '-')
64 65
        else
          path_parts.unshift(relative_url_root)
66 67
        end

68 69 70 71 72
        begin
          path = Addressable::URI.escape(File.join(*path_parts))
        rescue Addressable::URI::InvalidURIError
          return
        end
73 74 75 76 77

        html_attr.value =
          if context[:only_path]
            path
          else
78
            Addressable::URI.join(Gitlab.config.gitlab.base_url, path).to_s
79
          end
80 81 82
      end

      def process_link_to_repository_attr(html_attr)
83 84 85 86
        uri = URI(html_attr.value)
        if uri.relative? && uri.path.present?
          html_attr.value = rebuild_relative_uri(uri).to_s
        end
87
      rescue URI::Error, Addressable::URI::InvalidURIError
88
        # noop
89 90 91
      end

      def rebuild_relative_uri(uri)
92
        file_path = relative_file_path(uri)
93 94 95

        uri.path = [
          relative_url_root,
96
          project.full_path,
97
          uri_type(file_path),
98
          Addressable::URI.escape(ref).gsub('#', '%23'),
99
          Addressable::URI.escape(file_path)
100 101 102 103 104
        ].compact.join('/').squeeze('/').chomp('/')

        uri
      end

105
      def relative_file_path(uri)
106
        path = Addressable::URI.unescape(uri.path).delete("\0")
107 108
        request_path = Addressable::URI.unescape(context[:requested_path])
        nested_path = build_relative_path(path, request_path)
109 110 111
        file_exists?(nested_path) ? nested_path : path
      end

112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133
      # Convert a relative path into its correct location based on the currently
      # requested path
      #
      # path         - Relative path String
      # request_path - Currently-requested path String
      #
      # Examples:
      #
      #   # File in the same directory as the current path
      #   build_relative_path("users.md", "doc/api/README.md")
      #   # => "doc/api/users.md"
      #
      #   # File in the same directory, which is also the current path
      #   build_relative_path("users.md", "doc/api")
      #   # => "doc/api/users.md"
      #
      #   # Going up one level to a different directory
      #   build_relative_path("../update/7.14-to-8.0.md", "doc/api/README.md")
      #   # => "doc/update/7.14-to-8.0.md"
      #
      # Returns a String
      def build_relative_path(path, request_path)
134 135
        return request_path if path.empty?
        return path unless request_path
136
        return path[1..-1] if path.start_with?('/')
137 138

        parts = request_path.split('/')
139
        parts.pop if uri_type(request_path) != :tree
140

141
        path.sub!(%r{\A\./}, '')
142

143
        while path.start_with?('../')
144 145 146 147
          parts.pop
          path.sub!('../', '')
        end

148 149 150 151
        parts.push(path).join('/')
      end

      def file_exists?(path)
152
        path.present? && !!uri_type(path)
153 154
      end

155
      def uri_type(path)
156
        # https://gitlab.com/gitlab-org/gitlab-ce/issues/58657
157 158 159
        Gitlab::GitalyClient.allow_n_plus_1_calls do
          @uri_types[path] ||= current_commit.uri_type(path)
        end
160 161
      end

162
      def current_commit
163
        @current_commit ||= context[:commit] || repository.commit(ref)
164 165 166 167 168 169
      end

      def relative_url_root
        Gitlab.config.gitlab.relative_url_root.presence || '/'
      end

170 171
      def repo_visible_to_user?
        project && Ability.allowed?(current_user, :download_code, project)
172 173
      end

174
      def ref
175 176 177 178 179 180 181 182 183
        context[:ref] || project.default_branch
      end

      def group
        context[:group]
      end

      def project
        context[:project]
184 185
      end

186 187 188 189
      def current_user
        context[:current_user]
      end

190
      def repository
191
        @repository ||= project&.repository
192 193 194 195
      end
    end
  end
end