jsonld_helper.rb 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. # frozen_string_literal: true
  2. module JsonLdHelper
  3. include ContextHelper
  4. def equals_or_includes?(haystack, needle)
  5. haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
  6. end
  7. def equals_or_includes_any?(haystack, needles)
  8. needles.any? { |needle| equals_or_includes?(haystack, needle) }
  9. end
  10. def first_of_value(value)
  11. value.is_a?(Array) ? value.first : value
  12. end
  13. def uri_from_bearcap(str)
  14. if str&.start_with?('bear:')
  15. Addressable::URI.parse(str).query_values['u']
  16. else
  17. str
  18. end
  19. end
  20. # The url attribute can be a string, an array of strings, or an array of objects.
  21. # The objects could include a mimeType. Not-included mimeType means it's text/html.
  22. def url_to_href(value, preferred_type = nil)
  23. single_value = if value.is_a?(Array) && !value.first.is_a?(String)
  24. value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) }
  25. elsif value.is_a?(Array)
  26. value.first
  27. else
  28. value
  29. end
  30. if single_value.nil? || single_value.is_a?(String)
  31. single_value
  32. else
  33. single_value['href']
  34. end
  35. end
  36. def as_array(value)
  37. if value.nil?
  38. []
  39. elsif value.is_a?(Array)
  40. value
  41. else
  42. [value]
  43. end
  44. end
  45. def value_or_id(value)
  46. value.is_a?(String) || value.nil? ? value : value['id']
  47. end
  48. def supported_context?(json)
  49. !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
  50. end
  51. def unsupported_uri_scheme?(uri)
  52. uri.nil? || !uri.start_with?('http://', 'https://')
  53. end
  54. def non_matching_uri_hosts?(base_url, comparison_url)
  55. return true if unsupported_uri_scheme?(comparison_url)
  56. needle = Addressable::URI.parse(comparison_url).host
  57. haystack = Addressable::URI.parse(base_url).host
  58. !haystack.casecmp(needle).zero?
  59. end
  60. def canonicalize(json)
  61. graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context))
  62. graph.dump(:normalize)
  63. end
  64. def compact(json)
  65. compacted = JSON::LD::API.compact(json.without('signature'), full_context, documentLoader: method(:load_jsonld_context))
  66. compacted['signature'] = json['signature']
  67. compacted
  68. end
  69. # Patches a JSON-LD document to avoid compatibility issues on redistribution
  70. #
  71. # Since compacting a JSON-LD document against Mastodon's built-in vocabulary
  72. # means other extension namespaces will be expanded, malformed JSON-LD
  73. # attributes lost, and some values “unexpectedly” compacted this method
  74. # patches the following likely sources of incompatibility:
  75. # - 'https://www.w3.org/ns/activitystreams#Public' being compacted to
  76. # 'as:Public' (for instance, pre-3.4.0 Mastodon does not understand
  77. # 'as:Public')
  78. # - single-item arrays being compacted to the item itself (`[foo]` being
  79. # compacted to `foo`)
  80. #
  81. # It is not always possible for `patch_for_forwarding!` to produce a document
  82. # deemed safe for forwarding. Use `safe_for_forwarding?` to check the status
  83. # of the output document.
  84. #
  85. # @param original [Hash] The original JSON-LD document used as reference
  86. # @param compacted [Hash] The compacted JSON-LD document to be patched
  87. # @return [void]
  88. def patch_for_forwarding!(original, compacted)
  89. original.without('@context', 'signature').each do |key, value|
  90. next if value.nil? || !compacted.key?(key)
  91. compacted_value = compacted[key]
  92. if value.is_a?(Hash) && compacted_value.is_a?(Hash)
  93. patch_for_forwarding!(value, compacted_value)
  94. elsif value.is_a?(Array)
  95. compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
  96. return if value.size != compacted_value.size
  97. compacted[key] = value.zip(compacted_value).map do |v, vc|
  98. if v.is_a?(Hash) && vc.is_a?(Hash)
  99. patch_for_forwarding!(v, vc)
  100. vc
  101. elsif v == 'https://www.w3.org/ns/activitystreams#Public' && vc == 'as:Public'
  102. v
  103. else
  104. vc
  105. end
  106. end
  107. elsif value == 'https://www.w3.org/ns/activitystreams#Public' && compacted_value == 'as:Public'
  108. compacted[key] = value
  109. end
  110. end
  111. end
  112. # Tests whether a JSON-LD compaction is deemed safe for redistribution,
  113. # that is, if it doesn't change its meaning to consumers that do not actually
  114. # handle JSON-LD, but rely on values being serialized in a certain way.
  115. #
  116. # See `patch_for_forwarding!` for details.
  117. #
  118. # @param original [Hash] The original JSON-LD document used as reference
  119. # @param compacted [Hash] The compacted JSON-LD document to be patched
  120. # @return [Boolean] Whether the patched document is deemed safe
  121. def safe_for_forwarding?(original, compacted)
  122. original.without('@context', 'signature').all? do |key, value|
  123. compacted_value = compacted[key]
  124. return false unless value.instance_of?(compacted_value.class)
  125. if value.is_a?(Hash)
  126. safe_for_forwarding?(value, compacted_value)
  127. elsif value.is_a?(Array)
  128. value.zip(compacted_value).all? do |v, vc|
  129. v.is_a?(Hash) ? (vc.is_a?(Hash) && safe_for_forwarding?(v, vc)) : v == vc
  130. end
  131. else
  132. value == compacted_value
  133. end
  134. end
  135. end
  136. def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {})
  137. unless id_is_known
  138. json = fetch_resource_without_id_validation(uri, on_behalf_of)
  139. return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])
  140. uri = json['id']
  141. end
  142. json = fetch_resource_without_id_validation(uri, on_behalf_of, request_options: request_options)
  143. json.present? && json['id'] == uri ? json : nil
  144. end
  145. def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false, request_options: {})
  146. on_behalf_of ||= Account.representative
  147. build_request(uri, on_behalf_of, options: request_options).perform do |response|
  148. raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error
  149. body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response)
  150. end
  151. end
  152. def valid_activitypub_content_type?(response)
  153. return true if response.mime_type == 'application/activity+json'
  154. # When the mime type is `application/ld+json`, we need to check the profile,
  155. # but `http.rb` does not parse it for us.
  156. return false unless response.mime_type == 'application/ld+json'
  157. response.headers[HTTP::Headers::CONTENT_TYPE]&.split(';')&.map(&:strip)&.any? do |str|
  158. str.start_with?('profile="') && str[9...-1].split.include?('https://www.w3.org/ns/activitystreams')
  159. end
  160. end
  161. def body_to_json(body, compare_id: nil)
  162. json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body
  163. return if compare_id.present? && json['id'] != compare_id
  164. json
  165. rescue Oj::ParseError
  166. nil
  167. end
  168. def response_successful?(response)
  169. (200...300).cover?(response.code)
  170. end
  171. def response_error_unsalvageable?(response)
  172. response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
  173. end
  174. def build_request(uri, on_behalf_of = nil, options: {})
  175. Request.new(:get, uri, **options).tap do |request|
  176. request.on_behalf_of(on_behalf_of) if on_behalf_of
  177. request.add_headers('Accept' => 'application/activity+json, application/ld+json')
  178. end
  179. end
  180. def load_jsonld_context(url, _options = {}, &block)
  181. json = Rails.cache.fetch("jsonld:context:#{url}", expires_in: 30.days, raw: true) do
  182. request = Request.new(:get, url)
  183. request.add_headers('Accept' => 'application/ld+json')
  184. request.perform do |res|
  185. raise JSON::LD::JsonLdError::LoadingDocumentFailed unless res.code == 200 && res.mime_type == 'application/ld+json'
  186. res.body_with_limit
  187. end
  188. end
  189. doc = JSON::LD::API::RemoteDocument.new(json, documentUrl: url)
  190. block ? yield(doc) : doc
  191. end
  192. end