jsonld_helper.rb 7.9 KB

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