├── .ruby-version ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── ci.yml │ └── publish.yml ├── spec ├── support │ ├── fixtures │ │ ├── sample_post_no_links.txt │ │ ├── sample_post_no_links.json │ │ ├── sample_post.txt │ │ ├── sample_post_no_links.html │ │ ├── sample_post.json │ │ ├── sample_post_anchors_only.html │ │ └── sample_post.html │ └── fixtures_helper.rb ├── lib │ ├── webmention │ │ ├── error_response_spec.rb │ │ ├── response_spec.rb │ │ ├── parsers │ │ │ ├── plaintext_parser_results_spec.rb │ │ │ ├── json_parser_results_spec.rb │ │ │ └── html_parser_results_spec.rb │ │ └── request_perform_spec.rb │ ├── webmention_send_webmentions_spec.rb │ ├── webmention_send_webmention_spec.rb │ ├── webmention_mentioned_urls_spec.rb │ └── webmention_verify_webmention_spec.rb └── spec_helper.rb ├── .devcontainer ├── setup.sh └── devcontainer.json ├── Rakefile ├── .irbrc ├── .editorconfig ├── .rubocop.yml ├── bin ├── rspec ├── rubocop └── console ├── CODE_OF_CONDUCT.md ├── .simplecov ├── Gemfile ├── lib ├── webmention │ ├── parsers │ │ ├── plaintext_parser.rb │ │ ├── json_parser.rb │ │ └── html_parser.rb │ ├── parser.rb │ ├── error_response.rb │ ├── url.rb │ ├── response.rb │ ├── verification.rb │ ├── request.rb │ └── client.rb └── webmention.rb ├── LICENSE ├── .git-blame-ignore-revs ├── .gitignore ├── webmention.gemspec ├── CONTRIBUTING.md ├── README.md ├── CHANGELOG.md └── USAGE.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.6 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * jgarber623 2 | -------------------------------------------------------------------------------- /spec/support/fixtures/sample_post_no_links.txt: -------------------------------------------------------------------------------- 1 | Hello, world! 2 | -------------------------------------------------------------------------------- /spec/support/fixtures/sample_post_no_links.json: -------------------------------------------------------------------------------- 1 | ["Hello, world!"] 2 | -------------------------------------------------------------------------------- /spec/support/fixtures/sample_post.txt: -------------------------------------------------------------------------------- 1 | Hello, world! 2 | 3 | https://aaronpk.example/post/100 4 | -------------------------------------------------------------------------------- /.devcontainer/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | set -vx 6 | 7 | bundle install 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | 5 | require "rspec/core/rake_task" 6 | 7 | RSpec::Core::RakeTask.new 8 | 9 | task default: :spec 10 | -------------------------------------------------------------------------------- /.irbrc: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "irb/completion" 4 | 5 | IRB.conf[:HISTORY_FILE] = ".irb_history" 6 | IRB.conf[:USE_AUTOCOMPLETE] = false 7 | IRB.conf[:USE_PAGER] = false 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_size = 2 8 | indent_style = space 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - https://rubocop.jgarber.cc/rubocop.yml 3 | - https://rubocop.jgarber.cc/rubocop-rspec.yml 4 | 5 | plugins: rubocop-packaging 6 | 7 | AllCops: 8 | TargetRubyVersion: "2.7" 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | assignees: 9 | - "jgarber623" 10 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "rubygems" 5 | require "bundler/setup" 6 | 7 | ARGV.unshift("--require", "spec_helper") 8 | 9 | load Gem.bin_path("rspec-core", "rspec") 10 | -------------------------------------------------------------------------------- /spec/support/fixtures/sample_post_no_links.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Sample Post 4 | 5 | 6 |
7 |

Hello, world!

8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | By contributing to and participating in the development of indieweb-endpoints-ruby, you acknowledge that you have read and agree to the [IndieWeb Code of Conduct](https://indieweb.org/code-of-conduct). 4 | -------------------------------------------------------------------------------- /spec/support/fixtures_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FixturesHelper 4 | def load_fixture(file_name, file_type = "html") 5 | File.read(File.join(Dir.pwd, "spec/support/fixtures/#{file_name}.#{file_type}")) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "rubygems" 5 | require "bundler/setup" 6 | 7 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 8 | 9 | load Gem.bin_path("rubocop", "rubocop") 10 | -------------------------------------------------------------------------------- /spec/lib/webmention/error_response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Webmention::ErrorResponse do 4 | subject(:error_response) { described_class.new("foo", request) } 5 | 6 | let(:request) { instance_double(Webmention::Request) } 7 | 8 | it { is_expected.not_to be_ok } 9 | it { is_expected.to have_attributes(message: "foo", request: request) } 10 | end 11 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | formatters = SimpleCov::Formatter.from_env(ENV) 4 | 5 | if RSpec.configuration.files_to_run.length > 1 6 | require "simplecov-console" 7 | 8 | formatters << SimpleCov::Formatter::Console 9 | end 10 | 11 | SimpleCov.start do 12 | enable_coverage :branch 13 | 14 | formatter SimpleCov::Formatter::MultiFormatter.new(formatters) 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify gem's dependencies in webmention.gemspec 6 | gemspec 7 | 8 | gem "irb" 9 | gem "rake" 10 | gem "rspec" 11 | gem "rubocop" 12 | gem "rubocop-packaging" 13 | gem "rubocop-performance" 14 | gem "rubocop-rake" 15 | gem "rubocop-rspec" 16 | gem "simplecov" 17 | gem "simplecov-console" 18 | gem "webmock" 19 | -------------------------------------------------------------------------------- /spec/support/fixtures/sample_post.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | { 4 | "rels": { 5 | "target": "https://target.example/post/100" 6 | } 7 | }, 8 | { 9 | "rels": { 10 | "target": "https://target.example/post/200" 11 | } 12 | } 13 | ], 14 | { 15 | "rels": { 16 | "url": "https://target.example/post/100" 17 | } 18 | }, 19 | ["Hello", "world!"] 20 | ] 21 | -------------------------------------------------------------------------------- /spec/lib/webmention/response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Webmention::Response do 4 | subject(:response) { described_class.new(http_response, request) } 5 | 6 | let(:http_response) { instance_double(HTTP::Response) } 7 | let(:request) { instance_double(Webmention::Request) } 8 | 9 | it { is_expected.to be_ok } 10 | it { is_expected.to have_attributes(request: request) } 11 | end 12 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "webmention" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | ENV["IRBRC"] = ".irbrc" 15 | 16 | require "irb" 17 | IRB.start(__FILE__) 18 | -------------------------------------------------------------------------------- /lib/webmention/parsers/plaintext_parser.rb: -------------------------------------------------------------------------------- 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] An array of absolute URLs. 12 | def results 13 | @results ||= URI::DEFAULT_PARSER.extract(response_body, ["http", "https"]) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2013 by Aaron Parecki 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /spec/lib/webmention_send_webmentions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Webmention, ".send_webmentions" do 4 | subject(:response) { described_class.send_webmentions(*target_urls) } 5 | 6 | let(:source_url) { "https://jgarber.example/foo" } 7 | 8 | let(:target_urls) do 9 | [ 10 | "https://aaronpk.example/bar", 11 | "https://adactio.example/biz", 12 | "https://tantek.example/baz", 13 | ] 14 | end 15 | 16 | before do 17 | target_urls.each { |target_url| stub_request(:get, target_url) } 18 | end 19 | 20 | it { is_expected.to be_a(Array) } 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/fixtures/sample_post_anchors_only.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Sample Post 4 | 5 | 6 |
7 | Hello, world! 8 |
9 |
10 |

Link to Target 1

11 |

Link to Target 2

12 |

Relative Link to Source 1

13 |

Relative Link to Self

14 |

Invalid Link

15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /spec/lib/webmention/parsers/plaintext_parser_results_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Webmention::Parsers::PlaintextParser, "#results" do 4 | subject(:results) { described_class.new(response_body, "https://jgarber.example/foo").results } 5 | 6 | context "when response body contains no URLs" do 7 | let(:response_body) { load_fixture(:sample_post_no_links, "txt") } 8 | 9 | it { is_expected.to eq([]) } 10 | end 11 | 12 | context "when response body contains URLs" do 13 | let(:response_body) { load_fixture(:sample_post, "txt") } 14 | 15 | it { is_expected.to eq(["https://aaronpk.example/post/100"]) } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Since git version 2.23, git-blame has a feature to ignore certain commits. 2 | # 3 | # This file contains a list of commits that are not likely what you are looking 4 | # for in `git blame`. You can set this file as a default ignore file for blame 5 | # by running the following command: 6 | # 7 | # $ git config blame.ignoreRevsFile .git-blame-ignore-revs 8 | 9 | c7f71425322c9f2e1b279ac5fa3a075fbd8ddc22 10 | 1cae17eba50450ded5189de3fb5a59eed225b5a8 11 | 604b1d7de4709812f393980acd773318265d2c06 12 | ae980701869802ee34dc4134d7d74246f7788db8 13 | b31ac9550ad91e1dab2d282c75df7fa979defb56 14 | be81aa06d5fb8dc39d92c9642aaa81d1951167de 15 | 56a2cd08586e8bbe243bf27441c7b6ef5afb4670 16 | a4c09cf5915cb15b6a7de17022b81a34fb64fdb0 17 | -------------------------------------------------------------------------------- /spec/lib/webmention/request_perform_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Webmention::Request, "#perform" do 4 | subject(:response) { request.perform } 5 | 6 | let(:url) { "https://jgarber.example" } 7 | let(:request) { described_class.new(:get, url) } 8 | 9 | context "when an exception is raised" do 10 | before do 11 | stub_request(:get, url).to_raise(HTTP::ConnectionError.new("foo")) 12 | end 13 | 14 | it { is_expected.to have_attributes(message: "foo", ok?: false, request: request) } 15 | end 16 | 17 | context "when no exception is raised" do 18 | before { stub_request(:get, url) } 19 | 20 | it { is_expected.to have_attributes(code: 200, ok?: true, request: request) } 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/webmention/parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Webmention 4 | # @api private 5 | class Parser 6 | URI_REGEXP = URI::DEFAULT_PARSER.make_regexp(["http", "https"]).freeze 7 | 8 | public_constant :URI_REGEXP 9 | 10 | class << self 11 | # @return [Array] 12 | attr_reader :mime_types 13 | end 14 | 15 | # @param response_body [HTTP::Response::Body, String, #to_s] 16 | # @param response_uri [String, HTTP::URI, #to_s] 17 | def initialize(response_body, response_uri) 18 | @response_body = response_body.to_s 19 | @response_uri = HTTP::URI.parse(response_uri.to_s) 20 | end 21 | 22 | private 23 | 24 | # @return [String] 25 | attr_reader :response_body 26 | 27 | # @return [HTTP::URI] 28 | attr_reader :response_uri 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/fixtures/sample_post.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Sample Post 4 | 5 | 6 |
7 | Hello, world! 8 |
9 |
10 |

Link to Target 1

11 |

Link to Target 2

12 |

Duplicate Link to Target 2

13 |

Sample Image

14 |

Absolute Link to Self

15 |

Invalid Link

16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /spec/lib/webmention/parsers/json_parser_results_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Webmention::Parsers::JsonParser, "#results" do 4 | subject(:results) { described_class.new(response_body, "https://jgarber.example/foo").results } 5 | 6 | context "when response body contains no URLs" do 7 | let(:response_body) { load_fixture(:sample_post_no_links, "json") } 8 | 9 | it { is_expected.to eq([]) } 10 | end 11 | 12 | context "when response body contains URLs" do 13 | let(:response_body) { load_fixture(:sample_post, "json") } 14 | 15 | let(:extracted_urls) do 16 | [ 17 | "https://target.example/post/100", 18 | "https://target.example/post/200", 19 | "https://target.example/post/100", 20 | ] 21 | end 22 | 23 | it { is_expected.to eq(extracted_urls) } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | workflow_call: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lint: 12 | name: Lint 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | - uses: ruby/setup-ruby@v1 17 | with: 18 | bundler-cache: true 19 | - run: bin/rubocop --color 20 | test: 21 | name: Test (Ruby ${{ matrix.ruby }}) 22 | runs-on: ubuntu-latest 23 | needs: lint 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | ruby: ["2.7", "3.0", "3.1", "3.2", "3.3"] 28 | steps: 29 | - uses: actions/checkout@v6 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | bundler-cache: true 33 | ruby-version: ${{ matrix.ruby }} 34 | - run: bin/rspec --force-color --format documentation 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /.ruby-lsp 5 | /coverage/ 6 | /InstalledFiles 7 | /pkg/ 8 | /spec/reports/ 9 | /spec/examples.txt 10 | /test/tmp/ 11 | /test/version_tmp/ 12 | /tmp/ 13 | 14 | # Used by dotenv library to load environment variables. 15 | # .env 16 | 17 | # Ignore history files. 18 | .irb_history 19 | .rdbg_history 20 | 21 | # Documentation cache and generated files: 22 | /.yardoc/ 23 | /_yardoc/ 24 | /doc/ 25 | /rdoc/ 26 | 27 | # Environment normalization: 28 | /.bundle/ 29 | /vendor/bundle 30 | /lib/bundler/man/ 31 | 32 | # for a library or gem, you might want to ignore these files since the code is 33 | # intended to run in multiple environments; otherwise, check them in: 34 | Gemfile.lock 35 | # .ruby-version 36 | # .ruby-gemset 37 | 38 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 39 | .rvmrc 40 | 41 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 42 | .rubocop-https?--* 43 | -------------------------------------------------------------------------------- /lib/webmention/parsers/json_parser.rb: -------------------------------------------------------------------------------- 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] An array of absolute URLs. 12 | def results 13 | @results ||= extract_urls_from(JSON.parse(response_body)) 14 | end 15 | 16 | private 17 | 18 | # @param objs [Array] 19 | # 20 | # @return [Array] 21 | def extract_urls_from(*objs) 22 | objs.flat_map do |obj| 23 | return obj.flat_map { |value| extract_urls_from(value) }.compact if obj.is_a?(Array) 24 | return extract_urls_from(obj.values) if obj.is_a?(Hash) 25 | 26 | obj if obj.is_a?(String) && obj.match?(Parser::URI_REGEXP) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/webmention/error_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Webmention 4 | class ErrorResponse 5 | # @return [String] 6 | attr_reader :message 7 | 8 | # @return [Request] 9 | attr_reader :request 10 | 11 | # Create a new {ErrorResponse}. 12 | # 13 | # Instances of this class represent HTTP requests that generated errors 14 | # (e.g. connection error, SSL error) or that could not locate a Webmention 15 | # endpoint. The nature of the error is captured in the {#message} instance 16 | # method. 17 | # 18 | # @param message [String] 19 | # @param request [Request] 20 | def initialize(message, request) 21 | @message = message 22 | @request = request 23 | end 24 | 25 | # :nocov: 26 | # @return [String] 27 | def inspect 28 | "#<#{self.class}:#{format("%#0x", object_id)} " \ 29 | "message: #{message}>" 30 | end 31 | # :nocov: 32 | 33 | # @return [Boolean] 34 | def ok? 35 | false 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/lib/webmention/parsers/html_parser_results_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Webmention::Parsers::HtmlParser, "#results" do 4 | subject(:results) { described_class.new(response_body, "https://jgarber.example/foo").results } 5 | 6 | context "when response body contains no URLs" do 7 | let(:response_body) { load_fixture(:sample_post_no_links) } 8 | 9 | it { is_expected.to eq([]) } 10 | end 11 | 12 | context "when response body contains URLs within an h-entry" do 13 | let(:response_body) { load_fixture(:sample_post) } 14 | 15 | let(:extracted_urls) do 16 | [ 17 | "https://aaronpk.example/post/1", 18 | "https://aaronpk.example/post/2", 19 | "https://aaronpk.example/post/2", 20 | "https://jgarber.example", 21 | "https://aaronpk.example/image.jpg", 22 | "https://aaronpk.example/image-1x.jpg", 23 | "https://aaronpk.example/image-2x.jpg", 24 | ] 25 | end 26 | 27 | it { is_expected.to eq(extracted_urls) } 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/webmention/url.rb: -------------------------------------------------------------------------------- 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 {Url}. 15 | # 16 | # @param url [String, HTTP::URI, #to_s] An absolute URL. 17 | def initialize(url) 18 | @uri = HTTP::URI.parse(url.to_s) 19 | end 20 | 21 | # :nocov: 22 | # @return [String] 23 | def inspect 24 | "#<#{self.class}:#{format("%#0x", object_id)} " \ 25 | "uri: #{uri}>" 26 | end 27 | # :nocov: 28 | 29 | # @return [Response, ErrorResponse] 30 | def response 31 | @response ||= Request.get(uri) 32 | end 33 | 34 | # @return [String, nil] 35 | def webmention_endpoint 36 | @webmention_endpoint ||= IndieWeb::Endpoints::Parser.new(response).to_h[:webmention] if response.ok? 37 | end 38 | 39 | # @return [Boolean] 40 | def webmention_endpoint? 41 | !webmention_endpoint.nil? 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /webmention.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.required_ruby_version = ">= 2.7" 5 | 6 | spec.name = "webmention" 7 | spec.version = "9.0.0" 8 | spec.authors = ["Jason Garber"] 9 | spec.email = ["jason@sixtwothree.org"] 10 | 11 | spec.summary = "A Ruby gem for sending and verifying Webmention notifications." 12 | spec.description = spec.summary 13 | spec.homepage = "https://github.com/indieweb/webmention-client-ruby" 14 | spec.license = "Apache-2.0" 15 | 16 | spec.files = Dir["lib/**/*"].reject { |f| File.directory?(f) } 17 | spec.files += ["LICENSE", "CHANGELOG.md", "CONTRIBUTING.md", "README.md", "USAGE.md"] 18 | spec.files += ["webmention.gemspec"] 19 | 20 | spec.require_paths = ["lib"] 21 | 22 | spec.metadata = { 23 | "bug_tracker_uri" => "#{spec.homepage}/issues", 24 | "changelog_uri" => "#{spec.homepage}/releases/tag/v#{spec.version}", 25 | "documentation_uri" => "https://rubydoc.info/gems/#{spec.name}/#{spec.version}", 26 | "homepage_uri" => spec.homepage, 27 | "rubygems_mfa_required" => "true", 28 | "source_code_uri" => "#{spec.homepage}/tree/v#{spec.version}", 29 | } 30 | 31 | spec.add_dependency "http", "~> 5.3" 32 | spec.add_dependency "indieweb-endpoints", "~> 10.0" 33 | spec.add_dependency "nokogiri", ">= 1.14" 34 | end 35 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/ruby 3 | { 4 | "name": "webmention-client-ruby", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/base:1-bookworm", 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | "features": { 10 | "ghcr.io/rails/devcontainer/features/ruby:2": { 11 | "version": "3.4.6" 12 | } 13 | }, 14 | 15 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 16 | // "forwardPorts": [], 17 | 18 | // An array of Docker CLI arguments that should be used when running the container. 19 | "runArgs": ["--name", "webmention-client-ruby"], 20 | 21 | // Use 'postCreateCommand' to run commands after the container is created. 22 | "postCreateCommand": ".devcontainer/setup.sh", 23 | 24 | // Configure tool-specific properties. 25 | "customizations": { 26 | "vscode": { 27 | "extensions": [ 28 | "EditorConfig.EditorConfig" 29 | ] 30 | } 31 | } 32 | 33 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 34 | // "remoteUser": "root" 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | ci: 9 | name: CI 10 | uses: ./.github/workflows/ci.yml 11 | publish-to-rubygems: 12 | name: Publish to RubyGems 13 | permissions: 14 | contents: write 15 | id-token: write 16 | needs: ci 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v6 20 | - uses: ruby/setup-ruby@v1 21 | with: 22 | bundler-cache: true 23 | - uses: rubygems/release-gem@v1 24 | with: 25 | await-release: false 26 | publish-to-github-packages: 27 | name: Publish to GitHub Packages 28 | permissions: 29 | contents: read 30 | packages: write 31 | needs: ci 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v6 35 | - uses: ruby/setup-ruby@v1 36 | with: 37 | bundler-cache: true 38 | - run: | 39 | mkdir -p $HOME/.gem 40 | touch $HOME/.gem/credentials 41 | chmod 0600 $HOME/.gem/credentials 42 | printf -- "---\n:github: Bearer ${{ secrets.GITHUB_TOKEN }}\n" > $HOME/.gem/credentials 43 | - run: bundle exec rake release 44 | env: 45 | BUNDLE_GEM__PUSH_KEY: github 46 | RUBYGEMS_HOST: "https://rubygems.pkg.github.com/${{ github.repository_owner }}" 47 | -------------------------------------------------------------------------------- /lib/webmention/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Webmention 4 | class Response 5 | extend Forwardable 6 | 7 | # @return [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 {Response}. 35 | # 36 | # Instances of this class represent completed HTTP requests, the details 37 | # of which may be accessed using the delegated {#code} and {#reason} 38 | # instance methods. 39 | # 40 | # @param response [HTTP::Response] 41 | # @param request [Request] 42 | def initialize(response, request) 43 | @response = response 44 | @request = request 45 | end 46 | 47 | # :nocov: 48 | # @return [String] 49 | def inspect 50 | "#<#{self.class}:#{format("%#0x", object_id)} " \ 51 | "code: #{code.inspect}, " \ 52 | "reason: #{reason}, " \ 53 | "url: #{request.uri}>" 54 | end 55 | # :nocov: 56 | 57 | # @return [Boolean] 58 | def ok? 59 | true 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/lib/webmention_send_webmention_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Webmention, ".send_webmention" do 4 | subject(:response) { described_class.send_webmention(source_url, target_url) } 5 | 6 | let(:source_url) { "https://jgarber.example/foo" } 7 | let(:target_url) { "https://aaronpk.example/bar" } 8 | 9 | context "when target URL is not an absolute URL" do 10 | let(:target_url) { "/foo" } 11 | let(:message) { "unknown scheme: " } 12 | 13 | it { is_expected.to be_a(Webmention::ErrorResponse) } 14 | it { is_expected.to have_attributes(message: message, ok?: false) } 15 | end 16 | 17 | context "when target URL unreachable" do 18 | let(:message) { "Exception from WebMock" } 19 | 20 | before do 21 | stub_request(:get, target_url).to_raise(OpenSSL::SSL::SSLError) 22 | end 23 | 24 | it { is_expected.to be_a(Webmention::ErrorResponse) } 25 | it { is_expected.to have_attributes(message: message, ok?: false) } 26 | end 27 | 28 | context "when target URL does not advertise a Webmention endpoint" do 29 | let(:message) { "No webmention endpoint found for target URL #{target_url}" } 30 | 31 | before do 32 | stub_request(:get, target_url).to_return(body: load_fixture(:sample_post)) 33 | end 34 | 35 | it { is_expected.to be_a(Webmention::ErrorResponse) } 36 | it { is_expected.to have_attributes(message: message, ok?: false) } 37 | end 38 | 39 | context "when target URL advertises a Webmention endpoint" do 40 | let(:webmention_endpoint) { "#{target_url}/webmention" } 41 | 42 | before do 43 | stub_request(:get, target_url).to_return( 44 | headers: { 45 | Link: %(<#{webmention_endpoint}>; rel="webmention"), 46 | } 47 | ) 48 | 49 | stub_request(:post, webmention_endpoint).to_return(status: 200) 50 | end 51 | 52 | it { is_expected.to be_a(Webmention::Response) } 53 | it { is_expected.to be_ok } 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/webmention/parsers/html_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Webmention 4 | module Parsers 5 | # @api private 6 | class HtmlParser < Parser 7 | @mime_types = ["text/html"] 8 | 9 | Client.register_parser(self) 10 | 11 | HTML_ATTRIBUTES_MAP = { 12 | "cite" => ["blockquote", "del", "ins", "q"], 13 | "data" => ["object"], 14 | "href" => ["a", "area"], 15 | "poster" => ["video"], 16 | "src" => ["audio", "embed", "img", "source", "track", "video"], 17 | "srcset" => ["img", "source"], 18 | }.freeze 19 | 20 | CSS_SELECTORS_ARRAY = HTML_ATTRIBUTES_MAP 21 | .flat_map { |attribute, names| names.map { |name| "#{name}[#{attribute}]" } }.freeze 22 | 23 | ROOT_NODE_SELECTORS_ARRAY = [".h-entry .e-content", ".h-entry", "body"].freeze 24 | 25 | private_constant :HTML_ATTRIBUTES_MAP 26 | private_constant :CSS_SELECTORS_ARRAY 27 | private_constant :ROOT_NODE_SELECTORS_ARRAY 28 | 29 | # @return [Array] An array of absolute URLs. 30 | def results 31 | @results ||= 32 | extract_urls_from(*url_attributes) 33 | .map { |url| response_uri.join(url).to_s } 34 | .grep(Parser::URI_REGEXP) 35 | end 36 | 37 | private 38 | 39 | # @return [Nokogiri::HTML5::Document] 40 | def doc 41 | Nokogiri.HTML5(response_body) 42 | end 43 | 44 | # @param attributes [Array] 45 | # 46 | # @return [Array] 47 | def extract_urls_from(*attributes) 48 | attributes.flat_map do |attribute| 49 | if attribute.name == "srcset" 50 | attribute.value.split(",").map { |value| value.strip.match(/^\S+/).to_s } 51 | else 52 | attribute.value 53 | end 54 | end 55 | end 56 | 57 | # @return [Nokogiri::XML::Element] 58 | def root_node 59 | doc.at_css(*ROOT_NODE_SELECTORS_ARRAY) 60 | end 61 | 62 | # @return [Array] 63 | def url_attributes 64 | url_nodes.flat_map(&:attribute_nodes).select { |attribute| HTML_ATTRIBUTES_MAP.key?(attribute.name) } 65 | end 66 | 67 | # @return [Nokogiri::XML::NodeSet] 68 | def url_nodes 69 | root_node.css(*CSS_SELECTORS_ARRAY) 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/lib/webmention_mentioned_urls_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Webmention, ".mentioned_urls" do 4 | subject(:mentioned_urls) { described_class.mentioned_urls(source_url) } 5 | 6 | let(:source_url) { "https://jgarber.example/foo" } 7 | 8 | context "when response is an ErrorResponse" do 9 | before do 10 | stub_request(:get, source_url).to_raise(OpenSSL::SSL::SSLError) 11 | end 12 | 13 | it "raises a NoMethodError" do 14 | expect { mentioned_urls }.to raise_error(NoMethodError) 15 | end 16 | end 17 | 18 | context "when response is of unsupported MIME type" do 19 | before do 20 | stub_request(:get, source_url).to_return( 21 | headers: { 22 | "Content-Type": "foo/bar", 23 | } 24 | ) 25 | end 26 | 27 | it "raises a NoMethodError" do 28 | expect { mentioned_urls }.to raise_error(NoMethodError) 29 | end 30 | end 31 | 32 | context "when no mentioned URLs found" do 33 | before do 34 | stub_request(:get, source_url).to_return( 35 | body: load_fixture(:sample_post_no_links), 36 | headers: { 37 | "Content-Type": "text/html", 38 | } 39 | ) 40 | end 41 | 42 | it { is_expected.to eq([]) } 43 | end 44 | 45 | context "when mentioned URLs found" do 46 | let(:extracted_urls) do 47 | [ 48 | "https://aaronpk.example/image-1x.jpg", 49 | "https://aaronpk.example/image-2x.jpg", 50 | "https://aaronpk.example/image.jpg", 51 | "https://aaronpk.example/post/1", 52 | "https://aaronpk.example/post/2", 53 | "https://jgarber.example", 54 | ] 55 | end 56 | 57 | before do 58 | stub_request(:get, source_url).to_return( 59 | body: load_fixture(:sample_post), 60 | headers: { 61 | "Content-Type": "text/html", 62 | } 63 | ) 64 | end 65 | 66 | it { is_expected.to eq(extracted_urls) } 67 | end 68 | 69 | context "when mentioned URLs include links to source URL" do 70 | let(:extracted_urls) do 71 | [ 72 | "https://aaronpk.example/post/1", 73 | "https://aaronpk.example/post/2", 74 | "https://jgarber.example/post/1", 75 | ] 76 | end 77 | 78 | before do 79 | stub_request(:get, source_url).to_return( 80 | body: load_fixture(:sample_post_anchors_only), 81 | headers: { 82 | "Content-Type": "text/html", 83 | } 84 | ) 85 | end 86 | 87 | it { is_expected.to eq(extracted_urls) } 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/webmention/verification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Webmention 4 | class Verification 5 | # Create a new {Verification}. 6 | # 7 | # @param source_url [Url] 8 | # An {URL} representing a source document. 9 | # @param target_url [Url] 10 | # An {URL} representing a target document. 11 | # @param vouch_url [Url] 12 | # An {URL} representing a document vouching for the source document. 13 | # See https://indieweb.org/Vouch for additional details. 14 | def initialize(source_url, target_url, vouch_url: nil) 15 | @source_url = source_url 16 | @target_url = target_url 17 | @vouch_url = vouch_url 18 | end 19 | 20 | # :nocov: 21 | # @return [String] 22 | def inspect 23 | "#<#{self.class}:#{format("%#0x", object_id)} " \ 24 | "source_url: #{source_url} " \ 25 | "target_url: #{target_url} " \ 26 | "vouch_url: #{vouch_url}>" 27 | end 28 | # :nocov: 29 | 30 | # @return [Boolean] 31 | def source_mentions_target? 32 | @source_mentions_target ||= mentioned_urls(source_url.response).any?(target_url.to_s) 33 | end 34 | 35 | # @return [Boolean] 36 | def verified? 37 | return source_mentions_target? unless verify_vouch? 38 | 39 | source_mentions_target? && vouch_mentions_source? 40 | end 41 | 42 | # @return [Boolean] 43 | def verify_vouch? 44 | !vouch_url.nil? && !vouch_url.to_s.strip.empty? 45 | end 46 | 47 | # @return [Boolean] 48 | def vouch_mentions_source? 49 | @vouch_mentions_source ||= 50 | verify_vouch? && mentioned_domains(vouch_url.response).any?(source_url.uri.host) 51 | end 52 | 53 | private 54 | 55 | # @return [Url] 56 | attr_reader :source_url 57 | 58 | # @return [Url] 59 | attr_reader :target_url 60 | 61 | # @return [Url] 62 | attr_reader :vouch_url 63 | 64 | # @param response [Response] 65 | # 66 | # @raise (see Client#mentioned_urls) 67 | # 68 | # @return [Array] 69 | def mentioned_domains(response) 70 | Set.new(mentioned_urls(response).map { |url| HTTP::URI.parse(url).host }).to_a 71 | end 72 | 73 | # @param response [Response] 74 | # 75 | # @raise (see Client#mentioned_urls) 76 | # 77 | # @return [Array] 78 | def mentioned_urls(response) 79 | Set.new( 80 | Client.registered_parsers[response.mime_type] 81 | .new(response.body, response.uri) 82 | .results 83 | ).to_a 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to webmention-client-ruby 2 | 3 | There are a couple ways you can help improve webmention-client-ruby: 4 | 5 | 1. Fix an existing [Issue][issues] and submit a [Pull Request][pulls]. 6 | 1. Review open [Pull Requests][pulls]. 7 | 1. Report a new [Issue][issues]. _Only do this after you've made sure the behavior or problem you're observing isn't already documented in an open issue._ 8 | 9 | ## Getting Started 10 | 11 | webmention-client-ruby is developed using Ruby 3.4 and is tested against additional Ruby versions using [GitHub Actions](https://github.com/indieweb/webmention-client-ruby/actions). 12 | 13 | > [!TIP] 14 | > This project is configured with a [Dev Container](https://containers.dev) which includes everything you'd need to contribute to webmention-client-ruby. If you use a supported code editor or IDE, you're encouraged to use the existing Dev Container setup. 15 | 16 | Before making changes to webmention-client-ruby, you'll want to install Ruby 3.4. Using a Ruby version managment tool like [rbenv](https://github.com/rbenv/rbenv), [chruby](https://github.com/postmodern/chruby), or [rvm](https://github.com/rvm/rvm) is recommended. Once you've installed Ruby 3.4 using your method of choice, install the project's gems by running: 17 | 18 | ```sh 19 | bundle install 20 | ``` 21 | 22 | ## Making Changes 23 | 24 | 1. Fork and clone the project's repo. 25 | 2. Install development dependencies as outlined above. 26 | 3. Create a feature branch for the code changes you're looking to make: `git checkout -b my-new-feature`. 27 | 4. _Write some code!_ 28 | 5. If your changes would benefit from testing, add the necessary tests and verify everything passes by running `bin/rspec`. 29 | 6. Commit your changes: `git commit -am 'Add some new feature or fix some issue'`. _(See [this excellent article](https://cbea.ms/git-commit/) for tips on writing useful Git commit messages.)_ 30 | 7. Push the branch to your fork: `git push -u origin my-new-feature`. 31 | 8. Create a new [Pull Request][pulls] and we'll review your changes. 32 | 33 | ## Code Style 34 | 35 | Code formatting conventions are defined in the `.editorconfig` file which uses the [EditorConfig](https://editorconfig.org) syntax. There are [plugins for a variety of editors](https://editorconfig.org/#download) that utilize the settings in the `.editorconfig` file. We recommended installing the EditorConfig plugin for your editor of choice. 36 | 37 | Your bug fix or feature addition won't be rejected if it runs afoul of any (or all) of these guidelines, but following the guidelines will definitely make everyone's lives a little easier. 38 | 39 | [issues]: https://github.com/indieweb/webmention-client-ruby/issues 40 | [pulls]: https://github.com/indieweb/webmention-client-ruby/pulls 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webmention-client-ruby 2 | 3 | **A Ruby gem for sending and verifying [Webmention](https://indieweb.org/Webmention) notifications.** 4 | 5 | [![Gem](https://img.shields.io/gem/v/webmention.svg?logo=rubygems&style=for-the-badge)](https://rubygems.org/gems/webmention) 6 | [![Downloads](https://img.shields.io/gem/dt/webmention.svg?logo=rubygems&style=for-the-badge)](https://rubygems.org/gems/webmention) 7 | [![Build](https://img.shields.io/github/actions/workflow/status/indieweb/webmention-client-ruby/ci.yml?branch=main&logo=github&style=for-the-badge)](https://github.com/indieweb/webmention-client-ruby/actions/workflows/ci.yml) 8 | 9 | ## Key Features 10 | 11 | - Crawl a URL for mentioned URLs. 12 | - Perform [endpoint discovery](https://www.w3.org/TR/webmention/#sender-discovers-receiver-webmention-endpoint) on mentioned URLs. 13 | - Send webmentions to one or more mentioned URLs (and optionally include a [vouch](https://indieweb.org/Vouch) URL). 14 | - Verify that a received webmention's source URL links to a target URL (and optionally verify that a vouch URL mentions the source URL's domain). 15 | - Supports Ruby 2.7 and newer. 16 | 17 | ## Getting Started 18 | 19 | Before installing and using webmention-client-ruby, you'll want to have [Ruby](https://www.ruby-lang.org) 2.7 (or newer) installed. Using a Ruby version managment tool like [rbenv](https://github.com/rbenv/rbenv), [chruby](https://github.com/postmodern/chruby), or [rvm](https://github.com/rvm/rvm) is recommended. 20 | 21 | webmention-client-ruby is developed using Ruby 3.4 and is tested against additional Ruby versions using [GitHub Actions](https://github.com/indieweb/webmention-client-ruby/actions). 22 | 23 | ## Installation 24 | 25 | Add webmention-client-ruby to your project's `Gemfile` and run `bundle install`: 26 | 27 | ```ruby 28 | source "https://rubygems.org" 29 | 30 | gem "webmention" 31 | ``` 32 | 33 | ## Usage 34 | 35 | See [USAGE.md](USAGE.md) for documentation of webmention-client-ruby's features. 36 | 37 | ## Contributing 38 | 39 | See [CONTRIBUTING.md](https://github.com/indieweb/webmention-client-ruby/blob/main/CONTRIBUTING.md) for more on how to contribute to webmention-client-ruby. Your help is greatly appreciated! 40 | 41 | By contributing to and participating in the development of webmention-client-ruby, you acknowledge that you have read and agree to the [IndieWeb Code of Conduct](https://indieweb.org/code-of-conduct). 42 | 43 | ## Acknowledgments 44 | 45 | webmention-client-ruby is written and maintained by [Jason Garber](https://sixtwothree.org) ([@jgarber623](https://github.com/jgarber623)) with help from [these additional contributors](https://github.com/indieweb/webmention-client-ruby/graphs/contributors). Prior to 2018, webmention-client-ruby was written and maintained by [Aaron Parecki](https://aaronparecki.com) ([@aaronpk](https://github.com/aaronpk)) and [Nat Welch](https://natwelch.com) ([@icco](https://github.com/icco)). 46 | 47 | To learn more about Webmention, see [indieweb.org/Webmention](https://indieweb.org/Webmention) and [webmention.net](https://webmention.net). 48 | 49 | ## License 50 | 51 | webmention-client-ruby is freely available under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html). See [LICENSE](https://github.com/indieweb/webmention-client-ruby/blob/main/LICENSE) for more details. 52 | -------------------------------------------------------------------------------- /lib/webmention/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Webmention 4 | class Request 5 | # Set defaults derived from Webmention specification examples. 6 | # 7 | # @see https://www.w3.org/TR/webmention/#limits-on-get-requests 8 | # W3C Webmention Recommendation § 4.2 Limits on GET requests 9 | HTTP_CLIENT_OPTS = { 10 | follow: { 11 | max_hops: 20, 12 | }, 13 | headers: { 14 | accept: "*/*", 15 | user_agent: "Webmention Client (https://rubygems.org/gems/webmention)", 16 | }, 17 | timeout_options: { 18 | connect_timeout: 5, 19 | read_timeout: 5, 20 | }, 21 | }.freeze 22 | 23 | private_constant :HTTP_CLIENT_OPTS 24 | 25 | # @return [Symbol] 26 | attr_reader :method 27 | 28 | # @return [HTTP::URI] 29 | attr_reader :uri 30 | 31 | # @return [Hash] 32 | attr_reader :options 33 | 34 | # Send an HTTP GET request to the supplied URL. 35 | # 36 | # @example 37 | # Webmention::Request.get("https://jgarber.example/posts/100") 38 | # 39 | # @param url [String] 40 | # 41 | # @return [Response, ErrorResponse] 42 | def self.get(url) 43 | new(:get, url).perform 44 | end 45 | 46 | # Send an HTTP POST request with form-encoded data to the supplied URL. 47 | # 48 | # @example 49 | # Webmention::Request.post( 50 | # "https://aaronpk.example/webmention", 51 | # source: "https://jgarber.examples/posts/100", 52 | # target: "https://aaronpk.example/notes/1", 53 | # vouch: "https://tantek.example/notes/1" 54 | # ) 55 | # 56 | # @param url [String] 57 | # @param options [Hash{Symbol => String}] 58 | # @option options [String] :source 59 | # An absolute URL representing a source document. 60 | # @option options [String] :target 61 | # An absolute URL representing a target document. 62 | # @option options [String] :vouch 63 | # An absolute URL representing a document vouching for the source document. 64 | # See https://indieweb.org/Vouch for additional details. 65 | # 66 | # @return [Response, ErrorResponse] 67 | def self.post(url, **options) 68 | new(:post, url, form: options.slice(:source, :target, :vouch)).perform 69 | end 70 | 71 | # Create a new {Request}. 72 | # 73 | # @param method [Symbol] 74 | # @param url [String] 75 | # @param options [Hash{Symbol => String}] 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 {Request}. 92 | # 93 | # @return [Response, ErrorResponse] 94 | def perform 95 | Response.new(client.request(method, uri, options), self) 96 | rescue HTTP::Error, OpenSSL::SSL::SSLError => e 97 | ErrorResponse.new(e.message, self) 98 | end 99 | 100 | private 101 | 102 | # @return [HTTP::Client] 103 | def client 104 | @client ||= HTTP::Client.new(HTTP_CLIENT_OPTS) 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/lib/webmention_verify_webmention_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Webmention, ".verify_webmention" do 4 | subject(:verification) { described_class.verify_webmention(source_url, target_url) } 5 | 6 | let(:source_url) { "https://jgarber.example/post/1" } 7 | let(:target_url) { "https://aaronpk.example/post/2" } 8 | let(:vouch_url) { "https://tantek.example/post/1" } 9 | 10 | context "when source URL does not link to target URL" do 11 | let(:verification_attributes) do 12 | { 13 | source_mentions_target?: false, 14 | verified?: false, 15 | verify_vouch?: false, 16 | } 17 | end 18 | 19 | before do 20 | stub_request(:get, source_url).to_return( 21 | body: load_fixture(:sample_post_no_links), 22 | headers: { 23 | "Content-Type": "text/html", 24 | } 25 | ) 26 | end 27 | 28 | it { is_expected.to be_a(Webmention::Verification) } 29 | it { is_expected.to have_attributes(verification_attributes) } 30 | end 31 | 32 | context "when source URL links to target URL" do 33 | let(:verification_attributes) do 34 | { 35 | source_mentions_target?: true, 36 | verified?: true, 37 | verify_vouch?: false, 38 | } 39 | end 40 | 41 | before do 42 | stub_request(:get, source_url).to_return( 43 | body: %({"url":"#{target_url}"}), 44 | headers: { 45 | "Content-Type": "application/json", 46 | } 47 | ) 48 | end 49 | 50 | it { is_expected.to be_a(Webmention::Verification) } 51 | it { is_expected.to have_attributes(verification_attributes) } 52 | end 53 | 54 | context "when vouch URL does not link to source URL domain" do 55 | subject(:verification) { described_class.verify_webmention(source_url, target_url, vouch: vouch_url) } 56 | 57 | let(:verification_attributes) do 58 | { 59 | source_mentions_target?: true, 60 | verified?: false, 61 | verify_vouch?: true, 62 | vouch_mentions_source?: false, 63 | } 64 | end 65 | 66 | before do 67 | stub_request(:get, source_url).to_return( 68 | body: load_fixture(:sample_post_anchors_only), 69 | headers: { 70 | "Content-Type": "text/html", 71 | } 72 | ) 73 | 74 | stub_request(:get, vouch_url).to_return( 75 | body: load_fixture(:sample_post_no_links), 76 | headers: { 77 | "Content-Type": "text/html", 78 | } 79 | ) 80 | end 81 | 82 | it { is_expected.to be_a(Webmention::Verification) } 83 | it { is_expected.to have_attributes(verification_attributes) } 84 | end 85 | 86 | context "when vouch URL links to source URL domain" do 87 | subject(:verification) { described_class.verify_webmention(source_url, target_url, vouch: vouch_url) } 88 | 89 | let(:verification_attributes) do 90 | { 91 | source_mentions_target?: true, 92 | verified?: true, 93 | verify_vouch?: true, 94 | vouch_mentions_source?: true, 95 | } 96 | end 97 | 98 | before do 99 | stub_request(:get, source_url).to_return( 100 | body: load_fixture(:sample_post_anchors_only), 101 | headers: { 102 | "Content-Type": "text/html", 103 | } 104 | ) 105 | 106 | stub_request(:get, vouch_url).to_return( 107 | body: "I vouch for:\n\nhttps://jgarber.example\nhttps://adactio.example", 108 | headers: { 109 | "Content-Type": "text/plain", 110 | } 111 | ) 112 | end 113 | 114 | it { is_expected.to be_a(Webmention::Verification) } 115 | it { is_expected.to have_attributes(verification_attributes) } 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/webmention.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | require "http" 6 | require "indieweb/endpoints" 7 | require "nokogiri" 8 | 9 | require_relative "webmention/client" 10 | require_relative "webmention/url" 11 | require_relative "webmention/request" 12 | require_relative "webmention/response" 13 | require_relative "webmention/error_response" 14 | require_relative "webmention/verification" 15 | 16 | require_relative "webmention/parser" 17 | require_relative "webmention/parsers/html_parser" 18 | require_relative "webmention/parsers/json_parser" 19 | require_relative "webmention/parsers/plaintext_parser" 20 | 21 | module Webmention 22 | # Retrieve unique URLs mentioned by the provided URL. 23 | # 24 | # @example 25 | # Webmention.mentioned_urls("https://jgarber.example/posts/100") 26 | # 27 | # @param url [String, HTTP::URI, #to_s] An absolute URL. 28 | # 29 | # @raise [NoMethodError] 30 | # Raised when response is an {ErrorResponse} or response is of an 31 | # unsupported MIME type. 32 | # 33 | # @return [Array] 34 | def self.mentioned_urls(url) 35 | Client.new(url).mentioned_urls 36 | end 37 | 38 | # Send a webmention from a source URL to a target URL. 39 | # 40 | # @example Send a webmention 41 | # source = "https://jgarber.example/posts/100" 42 | # target = "https://aaronpk.example/notes/1" 43 | # Webmention.send_webmention(source, target) 44 | # 45 | # @example Send a webmention with a vouch URL 46 | # source = "https://jgarber.example/posts/100" 47 | # target = "https://aaronpk.example/notes/1" 48 | # Webmention.send_webmention(source, target, vouch: "https://tantek.example/notes/1") 49 | # 50 | # @param source [String, HTTP::URI, #to_s] 51 | # An absolute URL representing a source document. 52 | # @param target [String, HTTP::URI, #to_s] 53 | # An absolute URL representing a target document. 54 | # @param vouch [String, HTTP::URI, #to_s] 55 | # An absolute URL representing a document vouching for the source document. 56 | # See https://indieweb.org/Vouch for additional details. 57 | # 58 | # @return [Response, ErrorResponse] 59 | def self.send_webmention(source, target, vouch: nil) 60 | Client.new(source, vouch: vouch).send_webmention(target) 61 | end 62 | 63 | # Send webmentions from a source URL to multiple target URLs. 64 | # 65 | # @example Send multiple webmentions 66 | # source = "https://jgarber.example/posts/100" 67 | # targets = ["https://aaronpk.example/notes/1", "https://adactio.example/notes/1"] 68 | # Webmention.send_webmentions(source, targets) 69 | # 70 | # @example Send multiple webmentions with a vouch URL 71 | # source = "https://jgarber.example/posts/100" 72 | # targets = ["https://aaronpk.example/notes/1", "https://adactio.example/notes/1"] 73 | # Webmention.send_webmentions(source, targets, vouch: "https://tantek.example/notes/1") 74 | # 75 | # @param source [String, HTTP::URI, #to_s] 76 | # An absolute URL representing a source document. 77 | # @param targets [Array] 78 | # An array of absolute URLs representing multiple target documents. 79 | # @param vouch [String, HTTP::URI, #to_s] 80 | # An absolute URL representing a document vouching for the source document. 81 | # See https://indieweb.org/Vouch for additional details. 82 | # 83 | # @return [Array] 84 | def self.send_webmentions(source, *targets, vouch: nil) 85 | Client.new(source, vouch: vouch).send_webmentions(*targets) 86 | end 87 | 88 | # Verify that a source URL links to a target URL. 89 | # 90 | # @param (see Webmention.send_webmention) 91 | # 92 | # @raise (see Client#mentioned_urls) 93 | # 94 | # @return [Boolean] 95 | def self.verify_webmention(source, target, vouch: nil) 96 | Client.new(source, vouch: vouch).verify_webmention(target) 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | > [!NOTE] 4 | > From v8.0.0, changes are documented using [GitHub Releases](https://github.com/indieweb/webmention-client-ruby/releases). For a given release, metadata on RubyGems.org will link to that version's Release page. 5 | 6 | ## 7.0.0 / 2022-11-09 7 | 8 | - Refactor HTML and JSON parser classes (ec58206 and 6818c05) 9 | - Update indieweb-endpoints dependency constraint (4cd742f) 10 | - Relax nokogiri dependency constraint (e86f3bc) 11 | - **Breaking change:** Update development Ruby to 2.7.6 and minimum Ruby to 2.7 (5bee7dd) 12 | 13 | ## 6.0.0 / 2022-05-13 14 | 15 | ### New Features 16 | 17 | - Top-level module methods: 18 | - `Webmention.send_webmention(source, target)` 19 | - `Webmention.send_webmentions(source, *targets)` 20 | - `Webmention.mentioned_urls(url)` 21 | - `Webmention.verify_webmention(source, target)` 22 | - New JSON and plaintext parsers 23 | - [Vouch](https://indieweb.org/Vouch) URL support (9829269) 24 | - Webmention verification support (5fe5f58 and 100644) 25 | - Fewer exceptions! HTTP response handling updated to return similar objects (`Webmention::Response` and `Webmention::ErrorResponse`). 26 | - Fewer runtime dependencies! 27 | 28 | ### Breaking Changes 29 | 30 | - `Webmention.send_mention` renamed to `Webmention.send_webmention` 31 | - `Webmention.client` method removed 32 | - `Webmention::Client#send_all_mentions` removed in favor of `Webmention.mentioned_urls` and `Webmention.send_webmentions` 33 | - Response objects from `Webmention.send_webmention` and `Webmention.send_webmentions` have changed from instances of `HTTP::Response` to instances of `Webmention::Response` or `Webmention::ErrorResponse` 34 | - Remove Absolutely and Addressable dependencies 35 | - Add support for Ruby 3 (a31aae6) 36 | - Update minimum supported Ruby version to 2.6 (e4fed8e) 37 | 38 | ### Development Changes 39 | 40 | - Remove Reek development dependency (806bbc7) 41 | - Update development Ruby version to 2.6.10 (7e52ec9) 42 | - Migrate test suite to RSpec (79ac684) 43 | - Migrate to GitHub Actions (f5a3d7a) 44 | 45 | ## 5.0.0 / 2020-12-13 46 | 47 | - Update absolutely and indieweb-endpoints gems to v5.0 (89f4ea8) 48 | 49 | ## 4.0.0 / 2020-08-23 50 | 51 | - **Breaking change:** Update minimum supported Ruby version to 2.5 (b2bc62f) 52 | - Update indieweb-endpoints to 4.0 (e61588f) 53 | - Update project Ruby version to 2.5.8 (2a626a6) 54 | 55 | ## 3.0.0 / 2020-05-19 56 | 57 | - Reject "internal" URLs when sending webmentions (#24) (ccc82c8) 58 | - Select only HTTP/HTTPS URLs when sending webmentions (#22) (39e5852) 59 | 60 | ## 2.2.0 / 2020-05-18 61 | 62 | - Update absolutely and indieweb-endpoints gems (350d2ed) 63 | - Add pry-byebug and `bin/console` script (d2c5e03) 64 | - Move development dependencies to Gemfile per current Bundler conventions (3a2fc21) 65 | - Update development Ruby version to 2.4.10 (4c7d1f7) 66 | 67 | ## 2.1.0 / 2020-04-06 68 | 69 | - Refactor `BaseParser` class and remove `Registerable` module (b706229) 70 | - Refactor `HttpRequest` and `NodeParser` classes into Service Objects (f29c073 and 7456bf1) 71 | 72 | ## 2.0.0 / 2020-01-25 73 | 74 | - Add Ruby 2.7 to list of supported Ruby versions (c67ed14) 75 | - Update absolutely, addressable, http, and indieweb-endpoints version constaints (986d326 and 6ba054f) 76 | - Update development dependencies (74ac982) 77 | - Update project Ruby version to 2.4.9 and update documentation (fd61ddf) 78 | 79 | ## 1.0.2 / 2019-08-31 80 | 81 | - Update Addressable and WebMock gems (0b98981) 82 | - Update project development Ruby to 2.4.7 and update documentation (882d4d3) 83 | 84 | ## 1.0.1 / 2019-07-17 85 | 86 | - Update indieweb-endpoints (cfe6287) and rubocop (c2b7047) 87 | 88 | ## 1.0.0 / 2019-07-03 89 | 90 | ### Breaking Changes 91 | 92 | For an instance of the `Webmention::Client` class: 93 | 94 | - The `send_mention` no longer accepts the `full_response` argument. When a Webmention endpoint is found, the method returns an `HTTP::Response` object. Otherwise, the method returns `nil`. 95 | - The `send_mentions` method is renamed to `send_all_mentions` and now returns a Hash whose keys are URLs and values are `HTTP::Response` objects (or `nil` when no Webmention endpoint is found at the given URL). 96 | - The `mentioned_url` method returns an Array of URLs mentioned within given URL's first `.h-entry` (if one exists). Otherwise, it returns a list of all URLs within the given URL's ``. 97 | 98 | ### Development Changes 99 | 100 | - Removes [Bundler](https://bundler.io) as a dependency (5e1662d) 101 | - Updates project Ruby to 2.4.6 (the latest 2.4.x release at this time) (b53a400) 102 | - Add the [Reek](https://github.com/troessner/reek) code smell detector (eb314dc) 103 | - Adds binstubs for more easily running common development tools (8899a22) 104 | -------------------------------------------------------------------------------- /lib/webmention/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Webmention 4 | class Client 5 | @registered_parsers = {} 6 | 7 | class << self 8 | # @api private 9 | attr_reader :registered_parsers 10 | end 11 | 12 | # @return [Url] 13 | attr_reader :source_url 14 | 15 | # @return [Url] 16 | attr_reader :vouch_url 17 | 18 | # @api private 19 | def self.register_parser(klass) 20 | klass.mime_types.each { |mime_type| @registered_parsers[mime_type] = klass } 21 | end 22 | 23 | # Create a new {Client}. 24 | # 25 | # @example 26 | # Webmention::Client.new("https://jgarber.example/posts/100") 27 | # 28 | # @example 29 | # Webmention::Client.new("https://jgarber.example/posts/100", vouch: "https://tantek.example/notes/1") 30 | # 31 | # @param source [String, HTTP::URI, #to_s] 32 | # An absolute URL representing a source document. 33 | # @param vouch [String, HTTP::URI, #to_s] 34 | # An absolute URL representing a document vouching for the source document. 35 | # See https://indieweb.org/Vouch for additional details. 36 | def initialize(source, vouch: nil) 37 | @source_url = Url.new(source) 38 | @vouch_url = Url.new(vouch) 39 | end 40 | 41 | # :nocov: 42 | # @return [String] 43 | def inspect 44 | "#<#{self.class}:#{format("%#0x", object_id)} " \ 45 | "source_url: #{source_url} " \ 46 | "vouch_url: #{vouch_url}>" 47 | end 48 | # :nocov: 49 | 50 | # Retrieve unique URLs mentioned by this client's source URL. 51 | # 52 | # @example 53 | # client = Webmention::Client.new("https://jgarber.example/posts/100") 54 | # client.mentioned_urls 55 | # 56 | # @raise [NoMethodError] 57 | # Raised when response is an {ErrorResponse} or response is of an 58 | # unsupported MIME type. 59 | # 60 | # @return [Array] 61 | def mentioned_urls 62 | response = source_url.response 63 | 64 | urls = Set.new( 65 | self.class 66 | .registered_parsers[response.mime_type] 67 | .new(response.body, response.uri) 68 | .results 69 | ) 70 | 71 | urls.reject! { |url| url.match(/^#{response.uri}(?:#.*)?$/) } 72 | 73 | urls.to_a.sort 74 | end 75 | 76 | # Send a webmention from this client's source URL to a target URL. 77 | # 78 | # @example 79 | # client = Webmention::Client.new("https://jgarber.example/posts/100") 80 | # client.send_webmention("https://aaronpk.example/notes/1") 81 | # 82 | # @param target [String, HTTP::URI, #to_s] 83 | # An absolute URL representing a target document. 84 | # 85 | # @return [Response, ErrorResponse] 86 | def send_webmention(target) 87 | target_url = Url.new(target) 88 | 89 | # A Webmention endpoint exists. Send the request and return the response. 90 | if target_url.webmention_endpoint? 91 | return Request.post(target_url.webmention_endpoint, **request_options_for(target)) 92 | end 93 | 94 | # An error was encountered fetching the target URL. Return the response. 95 | return target_url.response unless target_url.response.ok? 96 | 97 | # No Webmention endpoint exists. Return a new ErrorResponse. 98 | ErrorResponse.new("No webmention endpoint found for target URL #{target}", target_url.response.request) 99 | end 100 | 101 | # Send webmentions from this client's source URL to multiple target URLs. 102 | # 103 | # @example 104 | # client = Webmention::Client.new("https://jgarber.example/posts/100") 105 | # targets = ["https://aaronpk.example/notes/1", "https://adactio.example/notes/1"] 106 | # client.send_webmentions(targets) 107 | # 108 | # @param targets [Array] 109 | # An array of absolute URLs representing multiple target documents. 110 | # 111 | # @return [Array] 112 | def send_webmentions(*targets) 113 | targets.map { |target| send_webmention(target) } 114 | end 115 | 116 | # Verify that this client's source URL links to a target URL. 117 | # 118 | # @param target [String, HTTP::URI, #to_s] 119 | # An absolute URL representing a target document. 120 | # 121 | # @raise (see Client#mentioned_urls) 122 | # 123 | # @return [Verification] 124 | def verify_webmention(target) 125 | Verification.new(source_url, Url.new(target), vouch_url: vouch_url) 126 | end 127 | 128 | private 129 | 130 | # @param target [String, HTTP::URI, #to_s] 131 | # 132 | # @return [Hash{Symbol => String}] 133 | def request_options_for(target) 134 | opts = { 135 | source: source_url, 136 | target: target, 137 | vouch: vouch_url, 138 | } 139 | 140 | opts.transform_values! { |value| value.to_s.strip } 141 | opts.reject! { |_, value| value.empty? } 142 | 143 | opts 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by the `rspec --init` command. Conventionally, all 4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 5 | # The generated `.rspec` file contains `--require spec_helper` which will cause 6 | # this file to always be loaded, without a need to explicitly require it in any 7 | # files. 8 | # 9 | # Given that it is always loaded, you are encouraged to keep this file as 10 | # light-weight as possible. Requiring heavyweight dependencies from this file 11 | # will add to the boot time of your test suite on EVERY test run, even for an 12 | # individual file that may not need all of that loaded. Instead, consider making 13 | # a separate helper file that requires the additional dependencies and performs 14 | # the additional setup, and require it from the spec files that actually need 15 | # it. 16 | # 17 | # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 18 | 19 | require "simplecov" 20 | require "webmock/rspec" 21 | 22 | require "webmention" 23 | 24 | Dir[File.join(__dir__, "support/**/*.rb")].sort.each { |f| require_relative f } 25 | 26 | RSpec.configure do |config| 27 | config.include FixturesHelper 28 | 29 | # rspec-expectations config goes here. You can use an alternate 30 | # assertion/expectation library such as wrong or the stdlib/minitest 31 | # assertions if you prefer. 32 | config.expect_with :rspec do |expectations| 33 | # This option will default to `true` in RSpec 4. It makes the `description` 34 | # and `failure_message` of custom matchers include text for helper methods 35 | # defined using `chain`, e.g.: 36 | # be_bigger_than(2).and_smaller_than(4).description 37 | # # => "be bigger than 2 and smaller than 4" 38 | # ...rather than: 39 | # # => "be bigger than 2" 40 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 41 | end 42 | 43 | # rspec-mocks config goes here. You can use an alternate test double 44 | # library (such as bogus or mocha) by changing the `mock_with` option here. 45 | config.mock_with :rspec do |mocks| 46 | # Prevents you from mocking or stubbing a method that does not exist on 47 | # a real object. This is generally recommended, and will default to 48 | # `true` in RSpec 4. 49 | mocks.verify_partial_doubles = true 50 | end 51 | 52 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 53 | # have no way to turn it off -- the option exists only for backwards 54 | # compatibility in RSpec 3). It causes shared context metadata to be 55 | # inherited by the metadata hash of host groups and examples, rather than 56 | # triggering implicit auto-inclusion in groups with matching metadata. 57 | config.shared_context_metadata_behavior = :apply_to_host_groups 58 | 59 | # The settings below are suggested to provide a good initial experience 60 | # with RSpec, but feel free to customize to your heart's content. 61 | 62 | # This allows you to limit a spec run to individual examples or groups 63 | # you care about by tagging them with `:focus` metadata. When nothing 64 | # is tagged with `:focus`, all examples get run. RSpec also provides 65 | # aliases for `it`, `describe`, and `context` that include `:focus` 66 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 67 | config.filter_run_when_matching :focus 68 | 69 | # Allows RSpec to persist some state between runs in order to support 70 | # the `--only-failures` and `--next-failure` CLI options. We recommend 71 | # you configure your source control system to ignore this file. 72 | # config.example_status_persistence_file_path = 'spec/examples.txt' 73 | 74 | # Limits the available syntax to the non-monkey patched syntax that is 75 | # recommended. For more details, see: 76 | # https://relishapp.com/rspec/rspec-core/docs/configuration/zero-monkey-patching-mode 77 | config.disable_monkey_patching! 78 | 79 | # This setting enables warnings. It's recommended, but in some cases may 80 | # be too noisy due to issues in dependencies. 81 | config.warnings = true 82 | 83 | # Many RSpec users commonly either run the entire suite or an individual 84 | # file, and it's useful to allow more verbose output when running an 85 | # individual spec file. 86 | if config.files_to_run.one? 87 | # Use the documentation formatter for detailed output, 88 | # unless a formatter has already been configured 89 | # (e.g. via a command-line flag). 90 | config.default_formatter = "doc" 91 | end 92 | 93 | # Print the 10 slowest examples and example groups at the 94 | # end of the spec run, to help surface which specs are running 95 | # particularly slow. 96 | # config.profile_examples = 10 97 | 98 | # Run specs in random order to surface order dependencies. If you find an 99 | # order dependency and want to debug it, you can fix the order by providing 100 | # the seed, which is printed after each run. 101 | # --seed 1234 102 | config.order = :random 103 | 104 | # Seed global randomization in this process using the `--seed` CLI option. 105 | # Setting this allows you to use `--seed` to deterministically reproduce 106 | # test failures related to randomization by passing the same `--seed` value 107 | # as the one that triggered the failure. 108 | Kernel.srand config.seed 109 | end 110 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | # Using webmention-client-ruby 2 | 3 | > [!NOTE] 4 | > Before using webmention-client-ruby, please read the [Getting Started](README.md#getting-started) and [Installation](README.md#installation) sections of the project's [README.md](README.md). 5 | 6 | ## Sending a webmention 7 | 8 | With webmention-client-ruby installed, you may send a webmention from a source URL to a target URL: 9 | 10 | ```ruby 11 | require "webmention" 12 | 13 | source = "https://jgarber.example/post/100" # A post on your website 14 | target = "https://aaronpk.example/post/100" # A post on someone else's website 15 | 16 | response = Webmention.send_webmention(source, target) 17 | ``` 18 | 19 | `Webmention.send_webmention` will return either a `Webmention::Response` or a `Webmention::ErrorResponse`. Instances of both classes respond to `ok?`. Building on the examples above, a `Webmention::ErrorResponse` may be returned when: 20 | 21 | 1. The target URL does not advertise a Webmention endpoint. 22 | 2. The request to the target URL raises an `HTTP::Error` or an `OpenSSL::SSL::SSLError`. 23 | 24 | ```ruby 25 | response.ok? 26 | #=> false 27 | 28 | response.class 29 | #=> Webmention::ErrorResponse 30 | 31 | response.message 32 | #=> "No webmention endpoint found for target URL https://aaronpk.example/post/100" 33 | ``` 34 | 35 | A `Webmention::Response` will be returned in all other cases. 36 | 37 | ```ruby 38 | response.ok? 39 | #=> true 40 | 41 | response.class 42 | #=> Webmention::Response 43 | ``` 44 | 45 | Instances of `Webmention::Response` include useful methods delegated to the underlying `HTTP::Response` object: 46 | 47 | ```ruby 48 | response.headers #=> HTTP::Headers 49 | response.body #=> HTTP::Response::Body 50 | response.code #=> Integer 51 | response.reason #=> String 52 | response.mime_type #=> String 53 | response.uri #=> HTTP::URI 54 | ``` 55 | 56 | > [!NOTE] 57 | > `Webmention::Response` objects may return a variety of status codes that will vary depending on the endpoint's capabilities and the success or failure of the request. See [the Webmention spec](https://www.w3.org/TR/webmention/) for more on status codes on their implications. A `Webmention::Response` responding affirmatively to `ok?` _may_ also have a non-successful HTTP status code (e.g. `404 Not Found`). 58 | 59 | ## Sending multiple webmentions 60 | 61 | To send webmentions to multiple target URLs mentioned by a source URL: 62 | 63 | ```ruby 64 | source = "https://jgarber.example/post/100" 65 | targets = ["https://aaronpk.example/notes/1", "https://adactio.example/notes/1"] 66 | 67 | responses = Webmention.send_webmentions(source, targets) 68 | ``` 69 | 70 | `Webmention.send_webmentions` will return an array of `Webmention::Response` and `Webmention::ErrorResponse` objects. 71 | 72 | ## Including a vouch URL 73 | 74 | webmention-client-ruby supports submitting a [vouch](https://indieweb.org/Vouch) URL when sending webmentions: 75 | 76 | ```ruby 77 | # Send a webmention with a vouch URL to a target URL 78 | Webmention.send_webmention(source, target, vouch: "https://tantek.example/notes/1") 79 | 80 | # Send webmentions with a vouch URL to multiple target URLs 81 | Webmention.send_webmentions(source, targets, vouch: "https://tantek.example/notes/1") 82 | ``` 83 | 84 | ## Discovering mentioned URLs 85 | 86 | To retrieve unique URLs mentioned by a URL: 87 | 88 | ```ruby 89 | urls = Webmention.mentioned_urls("https://jgarber.example/post/100") 90 | ``` 91 | 92 | `Webmention.mentioned_urls` will crawl the provided URL, parse the response body, and return a sorted list of unique URLs. Response bodies are parsed using MIME type-specific rules as noted in the [Verifying a webmention](#verifying-a-webmention) section below. 93 | 94 | When parsing HTML documents, webmention-client-ruby will find the first [h-entry](https://microformats.org/wiki/h-entry) and search its markup for URLs. If no h-entry is found, the parser will search the document's ``. 95 | 96 | > [!NOTE] 97 | > Links pointing to the supplied URL (or those with internal fragment identifiers) will be rejected. You may wish to additionally filter the results returned by `Webmention.mentioned_urls` before sending webmentions. 98 | 99 | ## Verifying a webmention 100 | 101 | webmention-client-ruby verifies [HTML](https://www.w3.org/TR/html/), [JSON](https://json.org), and plaintext files in accordance with [Section 3.2.2](https://www.w3.org/TR/webmention/#webmention-verification) of [the W3C's Webmention Recommendation](https://www.w3.org/TR/webmention/): 102 | 103 | > The receiver **should** use per-media-type rules to determine whether the source document mentions the target URL. 104 | 105 | In plaintext documents, webmention-client-ruby will search the source URL for exact matches of the target URL. If the source URL is a JSON document, key/value pairs whose value equals the target URL are matched. 106 | 107 | HTML documents are searched for a variety of elements and attributes whose values may be (or include) URLs: 108 | 109 | | Element | Attributes | 110 | |:-------------|:----------------| 111 | | `a` | `href` | 112 | | `area` | `href` | 113 | | `audio` | `src` | 114 | | `blockquote` | `cite` | 115 | | `del` | `cite` | 116 | | `embed` | `src` | 117 | | `img` | `src`, `srcset` | 118 | | `ins` | `cite` | 119 | | `object` | `data` | 120 | | `q` | `cite` | 121 | | `source` | `src`, `srcset` | 122 | | `track` | `src` | 123 | | `video` | `src` | 124 | 125 | To verify a received webmention: 126 | 127 | ```ruby 128 | # Verify that a source URL links to a target URL 129 | verification = Webmention.verify_webmention(source, target) 130 | 131 | # Verify that a source URL links to a target URL and that the vouch URL mentions 132 | # the source URL's domain 133 | verification = Webmention.verify_webmention(source, target, vouch: "https://tantek.example/notes/1") 134 | ``` 135 | 136 | `Webmention.verify_webmention` returns an instance of `Webmention::Verification` which includes the following methods (each returns either `true` or `false`): 137 | 138 | ```ruby 139 | verification.source_mentions_target? 140 | verification.verified? 141 | verification.verify_vouch? 142 | verification.vouch_mentions_source? 143 | ``` 144 | 145 | > [!NOTE] 146 | > `Webmention.verify_webmention` parses HTML documents using the same rules outlined in [Discovering mentioned URLs](#discovering-mentioned-urls). 147 | 148 | ## Exception Handling 149 | 150 | webmention-client-ruby avoids raising exceptions when making HTTP requests. As noted above, a `Webmention::ErrorResponse` should be returned in cases where an HTTP request triggers an exception. 151 | 152 | When crawling the supplied URL, `Webmention.mentioned_urls` _may_ raise a `NoMethodError` if a `Webmention::ErrorResponse` is returned, or the response is of an unsupported MIME type. 153 | --------------------------------------------------------------------------------