webmention 5.0.0 → 6.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Webmention
2
4
  module Parsers
3
- class HtmlParser < BaseParser
5
+ # @api private
6
+ class HtmlParser < Parser
4
7
  @mime_types = ['text/html']
5
8
 
6
- Parsers.register(self)
9
+ Client.register_parser(self)
7
10
 
8
11
  HTML_ATTRIBUTES_MAP = {
9
12
  'cite' => %w[blockquote del ins q],
@@ -14,47 +17,58 @@ module Webmention
14
17
  'srcset' => %w[img source]
15
18
  }.freeze
16
19
 
17
- CSS_SELECTORS_ARRAY = HTML_ATTRIBUTES_MAP.flat_map { |attribute, names| names.map { |name| "#{name}[#{attribute}]" } }.freeze
20
+ CSS_SELECTORS_ARRAY = HTML_ATTRIBUTES_MAP.flat_map do |attribute, names|
21
+ names.map { |name| "#{name}[#{attribute}]" }
22
+ end.freeze
23
+
24
+ ROOT_NODE_SELECTORS_ARRAY = ['.h-entry .e-content', '.h-entry', 'body'].freeze
25
+
26
+ private_constant :HTML_ATTRIBUTES_MAP
27
+ private_constant :CSS_SELECTORS_ARRAY
28
+ private_constant :ROOT_NODE_SELECTORS_ARRAY
18
29
 
19
- # Parse an HTML string for URLs
20
- #
21
- # @return [Array<String>] Unique external URLs whose scheme matches http/https
30
+ # @return [Array<String>] An array of absolute URLs.
22
31
  def results
23
- @results ||= resolved_urls.uniq.select { |url| url.match?(%r{https?://}) }.reject { |url| url.match(/^#{response_url}(?:#.*)?$/) }
32
+ @results ||=
33
+ UrlExtractor.extract(*url_attributes)
34
+ .map { |url| response_uri.join(url).to_s }
35
+ .grep(Parser::URI_REGEXP)
24
36
  end
25
37
 
26
38
  private
27
39
 
40
+ # @return [Nokogiri::HTML5::Document]
28
41
  def doc
29
- @doc ||= Nokogiri::HTML(response_body)
30
- end
31
-
32
- def resolved_urls
33
- UrlAttributesParser.parse(*url_attributes).map { |url| Absolutely.to_abs(base: response_url, relative: url) }
42
+ Nokogiri.HTML5(response_body)
34
43
  end
35
44
 
45
+ # @return [Nokogiri::XML::Element]
36
46
  def root_node
37
- doc.at_css('.h-entry .e-content', '.h-entry') || doc.css('body')
47
+ doc.at_css(*ROOT_NODE_SELECTORS_ARRAY)
38
48
  end
39
49
 
50
+ # @return [Array<Nokogiri::XML::Attr>]
40
51
  def url_attributes
41
- url_nodes.flat_map(&:attribute_nodes).select { |attribute| HTML_ATTRIBUTES_MAP.key?(attribute.name) }
52
+ url_nodes.flat_map(&:attribute_nodes).find_all { |attribute| HTML_ATTRIBUTES_MAP.key?(attribute.name) }
42
53
  end
43
54
 
55
+ # @return [Nokogiri::XML::NodeSet]
44
56
  def url_nodes
45
57
  root_node.css(*CSS_SELECTORS_ARRAY)
46
58
  end
47
59
 
48
- module UrlAttributesParser
49
- # @param attributes [Array<Nokogiri::XML::Attr>]
60
+ module UrlExtractor
61
+ # @param *attributes [Array<Nokogiri::XML::Attr>]
62
+ #
50
63
  # @return [Array<String>]
51
- def self.parse(*attributes)
52
- attributes.flat_map { |attribute| value_from(attribute) }
64
+ def self.extract(*attributes)
65
+ attributes.flat_map { |attribute| values_from(attribute) }
53
66
  end
54
67
 
55
68
  # @param attribute [Nokogiri::XML::Attr]
69
+ #
56
70
  # @return [String, Array<String>]
57
- def self.value_from(attribute)
71
+ def self.values_from(attribute)
58
72
  return attribute.value unless attribute.name == 'srcset'
59
73
 
60
74
  attribute.value.split(',').map { |value| value.strip.match(/^\S+/).to_s }
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmention
4
+ module Parsers
5
+ # @api private
6
+ class JsonParser < Parser
7
+ @mime_types = ['application/json']
8
+
9
+ Client.register_parser(self)
10
+
11
+ # @return [Array<String>] An array of absolute URLs.
12
+ def results
13
+ @results ||= UrlExtractor.extract(doc)
14
+ end
15
+
16
+ private
17
+
18
+ # @return [Array, Hash]
19
+ def doc
20
+ @doc ||= JSON.parse(response_body)
21
+ end
22
+
23
+ module UrlExtractor
24
+ # @param *objs [Array<Hash, Array, String, Integer, Boolean, nil>]
25
+ #
26
+ # @return [Array<String>]
27
+ def self.extract(*objs)
28
+ objs.flat_map { |obj| values_from(obj) }
29
+ end
30
+
31
+ # @param obj [Hash, Array, String, Integer, Boolean, nil]
32
+ #
33
+ # @return [Array<String>, String, nil]
34
+ def self.values_from(obj)
35
+ return obj.flat_map { |value| extract(value) }.compact if obj.is_a?(Array)
36
+ return extract(obj.values) if obj.is_a?(Hash)
37
+
38
+ obj if obj.is_a?(String) && obj.match?(Parser::URI_REGEXP)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmention
4
+ module Parsers
5
+ # @api private
6
+ class PlaintextParser < Parser
7
+ @mime_types = ['text/plain']
8
+
9
+ Client.register_parser(self)
10
+
11
+ # @return [Array<String>] An array of absolute URLs.
12
+ def results
13
+ @results ||= URI.extract(response_body, %w[http https])
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmention
4
+ class Request
5
+ # Defaults derived from Webmention specification examples.
6
+ # @see https://www.w3.org/TR/webmention/#limits-on-get-requests
7
+ HTTP_CLIENT_OPTS = {
8
+ follow: {
9
+ max_hops: 20
10
+ },
11
+ headers: {
12
+ accept: '*/*',
13
+ user_agent: 'Webmention Client (https://rubygems.org/gems/webmention)'
14
+ },
15
+ timeout_options: {
16
+ connect_timeout: 5,
17
+ read_timeout: 5
18
+ }
19
+ }.freeze
20
+
21
+ private_constant :HTTP_CLIENT_OPTS
22
+
23
+ # @return [Symbol]
24
+ attr_reader :method
25
+
26
+ # @return [HTTP::URI]
27
+ attr_reader :uri
28
+
29
+ # @return [Hash]
30
+ attr_reader :options
31
+
32
+ # Send an HTTP GET request to the supplied URL.
33
+ #
34
+ # @example
35
+ # Request.get('https://jgarber.example/posts/100')
36
+ #
37
+ # @param url [String]
38
+ #
39
+ # @return [Webmention::Response, Webmention::ErrorResponse]
40
+ def self.get(url)
41
+ new(:get, url).perform
42
+ end
43
+
44
+ # Send an HTTP POST request with form-encoded data to the supplied URL.
45
+ #
46
+ # @example
47
+ # Request.post(
48
+ # 'https://aaronpk.example/webmention',
49
+ # source: 'https://jgarber.examples/posts/100',
50
+ # target: 'https://aaronpk.example/notes/1',
51
+ # vouch: 'https://tantek.example/notes/1'
52
+ # )
53
+ #
54
+ # @param url [String]
55
+ # @param options [Hash{Symbol => String}]
56
+ # @option options [String] :source
57
+ # An absolute URL representing a source document.
58
+ # @option options [String] :target
59
+ # An absolute URL representing a target document.
60
+ # @param vouch [String]
61
+ # An absolute URL representing a document vouching for the source document.
62
+ # See https://indieweb.org/Vouch for additional details.
63
+ #
64
+ # @return [Webmention::Response, Webmention::ErrorResponse]
65
+ def self.post(url, **options)
66
+ new(:post, url, form: options.slice(:source, :target, :vouch)).perform
67
+ end
68
+
69
+ # Create a new Webmention::Request.
70
+ #
71
+ # @param method [Symbol]
72
+ # @param url [String]
73
+ # @param options [Hash{Symbol => String}]
74
+ #
75
+ # @return [Webmention::Request]
76
+ def initialize(method, url, **options)
77
+ @method = method.to_sym
78
+ @uri = HTTP::URI.parse(url.to_s)
79
+ @options = options
80
+ end
81
+
82
+ # :nocov:
83
+ # @return [String]
84
+ def inspect
85
+ "#<#{self.class}:#{format('%#0x', object_id)} " \
86
+ "method: #{method.upcase}, " \
87
+ "url: #{uri}>"
88
+ end
89
+ # :nocov:
90
+
91
+ # Submit the Webmention::Request.
92
+ #
93
+ # @return [Webmention::Response, Webmention::ErrorResponse]
94
+ def perform
95
+ Response.new(client.request(method, uri, options), self)
96
+ rescue HTTP::Error,
97
+ OpenSSL::SSL::SSLError => e
98
+ ErrorResponse.new(e.message, self)
99
+ end
100
+
101
+ private
102
+
103
+ def client
104
+ @client ||= HTTP::Client.new(HTTP_CLIENT_OPTS)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmention
4
+ class Response
5
+ extend Forwardable
6
+
7
+ # @return [Webmention::Request]
8
+ attr_reader :request
9
+
10
+ # @!method
11
+ # @return [HTTP::Headers]
12
+ def_delegator :@response, :headers
13
+
14
+ # @!method
15
+ # @return [HTTP::Response::Body]
16
+ def_delegator :@response, :body
17
+
18
+ # @!method
19
+ # @return [Integer]
20
+ def_delegator :@response, :code
21
+
22
+ # @!method
23
+ # @return [String]
24
+ def_delegator :@response, :reason
25
+
26
+ # !@method
27
+ # @return [String]
28
+ def_delegator :@response, :mime_type
29
+
30
+ # !@method
31
+ # @return [HTTP::URI]
32
+ def_delegator :@response, :uri
33
+
34
+ # Create a new Webmention::Response.
35
+ #
36
+ # Instances of this class represent completed HTTP requests, the details
37
+ # of which may be accessed using the delegated <code>#code</code> and
38
+ # <code>#reason</code>) instance methods.
39
+ #
40
+ # @param response [HTTP::Response]
41
+ # @param request [Webmention::Request]
42
+ #
43
+ # @return [Webmention::Response]
44
+ def initialize(response, request)
45
+ @response = response
46
+ @request = request
47
+ end
48
+
49
+ # :nocov:
50
+ # @return [String]
51
+ def inspect
52
+ "#<#{self.class}:#{format('%#0x', object_id)} " \
53
+ "code: #{code.inspect}, " \
54
+ "reason: #{reason}, " \
55
+ "url: #{request.uri}>"
56
+ end
57
+ # :nocov:
58
+
59
+ # @return [Boolean]
60
+ def ok?
61
+ true
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmention
4
+ class Url
5
+ extend Forwardable
6
+
7
+ # @return [HTTP::URI]
8
+ attr_reader :uri
9
+
10
+ # @!method
11
+ # @return [String]
12
+ def_delegator :uri, :to_s
13
+
14
+ # Create a new Webmention::Url.
15
+ #
16
+ # @param url [String, HTTP::URI, #to_s] An absolute URL.
17
+ #
18
+ # @return [Webmention::Url]
19
+ def initialize(url)
20
+ @uri = HTTP::URI.parse(url.to_s)
21
+ end
22
+
23
+ # :nocov:
24
+ # @return [String]
25
+ def inspect
26
+ "#<#{self.class}:#{format('%#0x', object_id)} " \
27
+ "uri: #{uri}>"
28
+ end
29
+ # :nocov:
30
+
31
+ # @return [Webmention::Response, Webmention::ErrorResponse]
32
+ def response
33
+ @response ||= Request.get(uri)
34
+ end
35
+
36
+ # @return [String, nil]
37
+ def webmention_endpoint
38
+ @webmention_endpoint ||= IndieWeb::Endpoints::Parser.new(response).results[:webmention] if response.ok?
39
+ end
40
+
41
+ # @return [Boolean]
42
+ def webmention_endpoint?
43
+ !webmention_endpoint.nil?
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmention
4
+ class Verification
5
+ # @param source_url [Webmention::Url]
6
+ # @param target_url [Webmention::Url]
7
+ # @param vouch_url [Webmention::Url]
8
+ def initialize(source_url, target_url, vouch_url: nil)
9
+ @source_url = source_url
10
+ @target_url = target_url
11
+ @vouch_url = vouch_url
12
+ end
13
+
14
+ # :nocov:
15
+ # @return [String]
16
+ def inspect
17
+ "#<#{self.class}:#{format('%#0x', object_id)} " \
18
+ "source_url: #{source_url} " \
19
+ "target_url: #{target_url} " \
20
+ "vouch_url: #{vouch_url}>"
21
+ end
22
+ # :nocov:
23
+
24
+ # @return [Boolean]
25
+ def source_mentions_target?
26
+ @source_mentions_target ||= mentioned_urls(source_url.response).any?(target_url.to_s)
27
+ end
28
+
29
+ # @return [Boolean]
30
+ def verified?
31
+ return source_mentions_target? unless verify_vouch?
32
+
33
+ source_mentions_target? && vouch_mentions_source?
34
+ end
35
+
36
+ # @return [Boolean]
37
+ def verify_vouch?
38
+ !vouch_url.nil? && !vouch_url.to_s.strip.empty?
39
+ end
40
+
41
+ # @return [Boolean]
42
+ def vouch_mentions_source?
43
+ @vouch_mentions_source ||=
44
+ verify_vouch? && mentioned_domains(vouch_url.response).any?(source_url.uri.host)
45
+ end
46
+
47
+ private
48
+
49
+ # @return [Webmention::Url]
50
+ attr_reader :source_url
51
+
52
+ # @return [Webmention::Url]
53
+ attr_reader :target_url
54
+
55
+ # @return [Webmention::Url]
56
+ attr_reader :vouch_url
57
+
58
+ # @param response [Webmention::Response]
59
+ #
60
+ # @raise (see Webmention::Client#mentioned_urls)
61
+ #
62
+ # @return [Array<String>]
63
+ def mentioned_domains(response)
64
+ mentioned_urls(response).map { |url| HTTP::URI.parse(url).host }.uniq
65
+ end
66
+
67
+ # @param response [Webmention::Response]
68
+ #
69
+ # @raise (see Webmention::Client#mentioned_urls)
70
+ #
71
+ # @return [Array<String>]
72
+ def mentioned_urls(response)
73
+ Client.registered_parsers[response.mime_type]
74
+ .new(response.body, response.uri)
75
+ .results
76
+ .uniq
77
+ end
78
+ end
79
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Webmention
2
- VERSION = '5.0.0'.freeze
4
+ VERSION = '6.0.0'
3
5
  end
data/lib/webmention.rb CHANGED
@@ -1,38 +1,100 @@
1
- require 'absolutely'
2
- require 'addressable/uri'
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
3
5
  require 'http'
4
6
  require 'indieweb/endpoints'
5
7
  require 'nokogiri'
6
8
 
7
- require 'webmention/version'
8
- require 'webmention/exceptions'
9
-
10
- require 'webmention/client'
9
+ require_relative 'webmention/version'
11
10
 
12
- require 'webmention/parsers'
13
- require 'webmention/parsers/base_parser'
14
- require 'webmention/parsers/html_parser'
11
+ require_relative 'webmention/client'
12
+ require_relative 'webmention/url'
13
+ require_relative 'webmention/request'
14
+ require_relative 'webmention/response'
15
+ require_relative 'webmention/error_response'
16
+ require_relative 'webmention/verification'
15
17
 
16
- require 'webmention/services/http_request_service'
18
+ require_relative 'webmention/parser'
19
+ require_relative 'webmention/parsers/html_parser'
20
+ require_relative 'webmention/parsers/json_parser'
21
+ require_relative 'webmention/parsers/plaintext_parser'
17
22
 
18
23
  module Webmention
19
- # Create a new Webmention::Client
20
- # Convenience method for Webmention::Client.new
24
+ # Retrieve unique URLs mentioned by the provided URL.
25
+ #
26
+ # @example
27
+ # Webmention.mentioned_urls('https://jgarber.example/posts/100')
28
+ #
29
+ # @param url [String, HTTP::URI, #to_s] An absolute URL.
21
30
  #
22
- # client = Webmention.client('https://source.example.com/post/100')
31
+ # @raise [NoMethodError]
32
+ # Raised when response is a Webmention::ErrorResponse or response is of an
33
+ # unsupported MIME type.
23
34
  #
24
- # @param source [String] An absolute URL representing the source document
25
- # @return [Webmention::Client]
26
- def self.client(source)
27
- Client.new(source)
35
+ # @return [Array<String>]
36
+ def self.mentioned_urls(url)
37
+ Client.new(url).mentioned_urls
28
38
  end
29
39
 
30
- # Send a webmention from the source URL to the target URL
40
+ # Send a webmention from a source URL to a target URL.
41
+ #
42
+ # @example Send a webmention
43
+ # source = 'https://jgarber.example/posts/100'
44
+ # target = 'https://aaronpk.example/notes/1'
45
+ # Webmention.send_webmention(source, target)
46
+ #
47
+ # @example Send a webmention with a vouch URL
48
+ # source = 'https://jgarber.example/posts/100'
49
+ # target = 'https://aaronpk.example/notes/1'
50
+ # Webmention.send_webmention(source, target, vouch: 'https://tantek.example/notes/1')
51
+ #
52
+ # @param source [String, HTTP::URI, #to_s]
53
+ # An absolute URL representing a source document.
54
+ # @param target [String, HTTP::URI, #to_s]
55
+ # An absolute URL representing a target document.
56
+ # @param vouch [String, HTTP::URI, #to_s]
57
+ # An absolute URL representing a document vouching for the source document.
58
+ # See https://indieweb.org/Vouch for additional details.
59
+ #
60
+ # @return [Webmention::Response, Webmention::ErrorResponse]
61
+ def self.send_webmention(source, target, vouch: nil)
62
+ Client.new(source, vouch: vouch).send_webmention(target)
63
+ end
64
+
65
+ # Send webmentions from a source URL to multiple target URLs.
66
+ #
67
+ # @example Send multiple webmentions
68
+ # source = 'https://jgarber.example/posts/100'
69
+ # targets = ['https://aaronpk.example/notes/1', 'https://adactio.example/notes/1']
70
+ # Webmention.send_webmentions(source, targets)
71
+ #
72
+ # @example Send multiple webmentions with a vouch URL
73
+ # source = 'https://jgarber.example/posts/100'
74
+ # targets = ['https://aaronpk.example/notes/1', 'https://adactio.example/notes/1']
75
+ # Webmention.send_webmentions(source, targets, vouch: 'https://tantek.example/notes/1')
76
+ #
77
+ # @param source [String, HTTP::URI, #to_s]
78
+ # An absolute URL representing a source document.
79
+ # @param *targets [Array<String, HTTP::URI, #to_s>]
80
+ # An array of absolute URLs representing multiple target documents.
81
+ # @param vouch [String, HTTP::URI, #to_s]
82
+ # An absolute URL representing a document vouching for the source document.
83
+ # See https://indieweb.org/Vouch for additional details.
84
+ #
85
+ # @return [Array<Webmention::Response, Webmention::ErrorResponse>]
86
+ def self.send_webmentions(source, *targets, vouch: nil)
87
+ Client.new(source, vouch: vouch).send_webmentions(*targets)
88
+ end
89
+
90
+ # Verify that a source URL links to a target URL.
91
+ #
92
+ # @param (see Webmention.send_webmention)
93
+ #
94
+ # @raise (see Webmention::Client#mentioned_urls)
31
95
  #
32
- # @param source [String] An absolute URL representing the source document
33
- # @param target [String] An absolute URL representing the target document
34
- # @return [HTTP::Response, nil]
35
- def self.send_mention(source, target)
36
- client(source).send_mention(target)
96
+ # @return [Boolean]
97
+ def self.verify_webmention(source, target, vouch: nil)
98
+ Client.new(source, vouch: vouch).verify_webmention(target)
37
99
  end
38
100
  end
data/webmention.gemspec CHANGED
@@ -1,30 +1,33 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'lib/webmention/version'
2
4
 
3
5
  Gem::Specification.new do |spec|
4
- spec.required_ruby_version = Gem::Requirement.new('>= 2.5', '< 2.8')
6
+ spec.required_ruby_version = '>= 2.6', '< 4'
5
7
 
6
8
  spec.name = 'webmention'
7
9
  spec.version = Webmention::VERSION
8
- spec.authors = ['Aaron Parecki', 'Nat Welch']
9
- spec.email = ['aaron@parecki.com']
10
+ spec.authors = ['Jason Garber']
11
+ spec.email = ['jason@sixtwothree.org']
10
12
 
11
13
  spec.summary = 'Webmention notification client'
12
14
  spec.description = 'A Ruby gem for sending Webmention notifications.'
13
15
  spec.homepage = 'https://github.com/indieweb/webmention-client-ruby'
14
16
  spec.license = 'Apache-2.0'
15
17
 
16
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
17
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(bin|test)/}) }
18
- end
18
+ spec.files = Dir['lib/**/*'].reject { |f| File.directory?(f) }
19
+ spec.files += %w[LICENSE CHANGELOG.md CONTRIBUTING.md README.md USAGE.md]
20
+ spec.files += %w[webmention.gemspec]
19
21
 
20
22
  spec.require_paths = ['lib']
21
23
 
22
- spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues"
23
- spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/v#{spec.version}/CHANGELOG.md"
24
+ spec.metadata = {
25
+ 'bug_tracker_uri' => "#{spec.homepage}/issues",
26
+ 'changelog_uri' => "#{spec.homepage}/blob/v#{spec.version}/CHANGELOG.md",
27
+ 'rubygems_mfa_required' => 'true'
28
+ }
24
29
 
25
- spec.add_runtime_dependency 'absolutely', '~> 5.0'
26
- spec.add_runtime_dependency 'addressable', '~> 2.7'
27
- spec.add_runtime_dependency 'http', '~> 4.4'
28
- spec.add_runtime_dependency 'indieweb-endpoints', '~> 5.0'
29
- spec.add_runtime_dependency 'nokogiri', '~> 1.10'
30
+ spec.add_runtime_dependency 'http', '~> 5.0'
31
+ spec.add_runtime_dependency 'indieweb-endpoints', '~> 7.1'
32
+ spec.add_runtime_dependency 'nokogiri', '~> 1.13'
30
33
  end