jsonld_helper.rb 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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, on_behalf_of = nil)
  139. unless id
  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
  152. end
  153. end
  154. def body_to_json(body, compare_id: nil)
  155. json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body
  156. return if compare_id.present? && json['id'] != compare_id
  157. json
  158. rescue Oj::ParseError
  159. nil
  160. end
  161. def merge_context(context, new_context)
  162. if context.is_a?(Array)
  163. context << new_context
  164. else
  165. [context, new_context]
  166. end
  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)
  175. Request.new(:get, uri).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