webmention 3.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 = '3.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.4', '< 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', '~> 4.0'
26
- spec.add_runtime_dependency 'addressable', '~> 2.7'
27
- spec.add_runtime_dependency 'http', '~> 4.4'
28
- spec.add_runtime_dependency 'indieweb-endpoints', '~> 3.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