From 0fe6c4dc272af05bbcc83717bb7140857fa9989c Mon Sep 17 00:00:00 2001 From: Benjamin Neff Date: Wed, 17 Jun 2015 00:12:03 +0200 Subject: [PATCH] use host-meta xml generator from raven24's gem --- Gemfile.lock | 1 + LICENSE | 3 + .../webfinger_controller.rb | 3 +- .../webfinger/host_meta.erb | 10 -- diaspora_federation.gemspec | 1 + lib/diaspora_federation.rb | 3 +- lib/diaspora_federation/webfinger.rb | 11 ++ .../webfinger/host_meta.rb | 104 ++++++++++++ .../webfinger/xrd_document.rb | 151 ++++++++++++++++++ .../webfinger_controller_spec.rb | 13 +- spec/lib/webfinger/host_meta_spec.rb | 78 +++++++++ spec/lib/webfinger/xrd_document_spec.rb | 71 ++++++++ 12 files changed, 434 insertions(+), 15 deletions(-) delete mode 100644 app/views/diaspora_federation/webfinger/host_meta.erb create mode 100644 lib/diaspora_federation/webfinger.rb create mode 100644 lib/diaspora_federation/webfinger/host_meta.rb create mode 100644 lib/diaspora_federation/webfinger/xrd_document.rb create mode 100644 spec/lib/webfinger/host_meta_spec.rb create mode 100644 spec/lib/webfinger/xrd_document_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 271df32..d81f370 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: diaspora_federation (0.0.1) + nokogiri (~> 1.6.6.2) rails (~> 4.2.2) GEM diff --git a/LICENSE b/LICENSE index f097ca6..1d72708 100644 --- a/LICENSE +++ b/LICENSE @@ -13,3 +13,6 @@ GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Some parts are based on an older federation gem from Florian Staudacher: +https://github.com/Raven24/diaspora-federation diff --git a/app/controllers/diaspora_federation/webfinger_controller.rb b/app/controllers/diaspora_federation/webfinger_controller.rb index 56ac430..0201d6f 100644 --- a/app/controllers/diaspora_federation/webfinger_controller.rb +++ b/app/controllers/diaspora_federation/webfinger_controller.rb @@ -3,7 +3,8 @@ require_dependency "diaspora_federation/application_controller" module DiasporaFederation class WebfingerController < ApplicationController def host_meta - render "host_meta", content_type: "application/xrd+xml" + doc = WebFinger::HostMeta.from_base_url(DiasporaFederation.server_uri.to_s) + render body: doc.to_xml, content_type: "application/xrd+xml" end ## diff --git a/app/views/diaspora_federation/webfinger/host_meta.erb b/app/views/diaspora_federation/webfinger/host_meta.erb deleted file mode 100644 index dd796f3..0000000 --- a/app/views/diaspora_federation/webfinger/host_meta.erb +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - diff --git a/diaspora_federation.gemspec b/diaspora_federation.gemspec index 3304b6b..66d2e6e 100644 --- a/diaspora_federation.gemspec +++ b/diaspora_federation.gemspec @@ -18,4 +18,5 @@ Gem::Specification.new do |s| s.test_files = Dir["test/**/*"] s.add_dependency "rails", "~> 4.2.2" + s.add_dependency "nokogiri", "~> 1.6.6.2" end diff --git a/lib/diaspora_federation.rb b/lib/diaspora_federation.rb index e480172..b0a819a 100644 --- a/lib/diaspora_federation.rb +++ b/lib/diaspora_federation.rb @@ -1,6 +1,8 @@ require "diaspora_federation/engine" require "diaspora_federation/logging" +require "diaspora_federation/webfinger" + ## # diaspora* federation rails engine module DiasporaFederation @@ -73,7 +75,6 @@ module DiasporaFederation end end - ## # raised, if the engine is not configured correctly class ConfigurationError < RuntimeError end diff --git a/lib/diaspora_federation/webfinger.rb b/lib/diaspora_federation/webfinger.rb new file mode 100644 index 0000000..1012dae --- /dev/null +++ b/lib/diaspora_federation/webfinger.rb @@ -0,0 +1,11 @@ +module DiasporaFederation + ## + # This module provides the namespace for the various classes implementing + # WebFinger and other protocols used for metadata discovery on remote servers + # in the Diaspora* network. + module WebFinger + end +end + +require "diaspora_federation/webfinger/xrd_document" +require "diaspora_federation/webfinger/host_meta" diff --git a/lib/diaspora_federation/webfinger/host_meta.rb b/lib/diaspora_federation/webfinger/host_meta.rb new file mode 100644 index 0000000..1c55f14 --- /dev/null +++ b/lib/diaspora_federation/webfinger/host_meta.rb @@ -0,0 +1,104 @@ + +module DiasporaFederation + module WebFinger + ## + # Generates and parses Host Meta documents. + # + # This is a minimal implementation of the standard, only to the degree of what + # is used for the purposes of the Diaspora* protocol. (e.g. WebFinger) + # + # @example Creating a Host Meta document + # doc = HostMeta.from_base_url("https://pod.example.tld/") + # doc.to_xml + # + # @example Parsing a Host Meta document + # doc = HostMeta.from_xml(xml_string) + # webfinger_tpl = doc.webfinger_template_url + # + # @see http://tools.ietf.org/html/rfc6415 RFC 6415: "Web Host Metadata" + # @see XrdDocument + class HostMeta + private_class_method :new + + # URL fragment to append to the base URL + WEBFINGER_SUFFIX = "webfinger?q={uri}" + + ## + # Returns the WebFinger URL that was used to build this instance (either from + # xml or by giving a base URL). + # @return [String] WebFinger template URL + def webfinger_template_url + @webfinger_url + end + + ## + # Produces the XML string for the Host Meta instance with a +Link+ element + # containing the +webfinger_url+. + # @return [String] XML string + def to_xml + doc = XrdDocument.new + doc.links << {rel: "lrdd", + type: "application/xrd+xml", + template: @webfinger_url} + doc.to_xml + end + + ## + # Builds a new HostMeta instance and constructs the WebFinger URL from the + # given base URL by appending HostMeta::WEBFINGER_SUFFIX. + # @return [HostMeta] + # @raise [InvalidData] if the webfinger url is malformed + def self.from_base_url(base_url) + raise ArgumentError, "base_url is not a String" unless base_url.instance_of?(String) + + base_url += "/" unless base_url.end_with?("/") + webfinger_url = base_url + WEBFINGER_SUFFIX + raise InvalidData, "invalid webfinger url: #{webfinger_url}" unless webfinger_url_valid?(webfinger_url) + + hm = allocate + hm.instance_variable_set(:@webfinger_url, webfinger_url) + hm + end + + ## + # Reads the given Host Meta XML document string and populates the + # +webfinger_url+. + # @param [String] hostmeta_xml Host Meta XML string + # @raise [InvalidData] if the xml or the webfinger url is malformed + def self.from_xml(hostmeta_xml) + data = XrdDocument.xml_data(hostmeta_xml) + raise InvalidData, "received an invalid xml" unless data.key?(:links) + + webfinger_url = webfinger_url_from_xrd(data) + raise InvalidData, "invalid webfinger url: #{webfinger_url}" unless webfinger_url_valid?(webfinger_url) + + hm = allocate + hm.instance_variable_set(:@webfinger_url, webfinger_url) + hm + end + + ## + # Applies some basic sanity-checking to the given URL + # @param [String] url validation subject + # @return [Boolean] validation result + def self.webfinger_url_valid?(url) + !url.nil? && url.instance_of?(String) && url =~ %r{^https?:\/\/.*\{uri\}}i + end + private_class_method :webfinger_url_valid? + + ## + # Gets the webfinger url from an XRD data structure + # @param [Hash] extracted data + # @return [String] webfinger url + def self.webfinger_url_from_xrd(data) + link = data[:links].find {|l| (l[:rel] == "lrdd" && l[:type] == "application/xrd+xml") } + return link[:template] unless link.nil? + end + private_class_method :webfinger_url_from_xrd + + # Raised, if the +webfinger_url+ is missing or malformed + class InvalidData < RuntimeError + end + end + end +end diff --git a/lib/diaspora_federation/webfinger/xrd_document.rb b/lib/diaspora_federation/webfinger/xrd_document.rb new file mode 100644 index 0000000..108c2c4 --- /dev/null +++ b/lib/diaspora_federation/webfinger/xrd_document.rb @@ -0,0 +1,151 @@ +module DiasporaFederation + module WebFinger + ## + # This class implements basic handling of XRD documents as far as it is + # necessary in the context of the protocols used with Diaspora* federation. + # + # @note {http://tools.ietf.org/html/rfc6415 RFC 6415} recommends that servers + # should also offer the JRD format in addition to the XRD representation. + # Implementing +XrdDocument#to_json+ and +XrdDocument.json_data+ should + # be almost trivial due to the simplicity of the format and the way the data + # is stored internally already. See + # {http://tools.ietf.org/html/rfc6415#appendix-A RFC 6415, Appendix A} + # for a description of the JSON format. + # + # @example Creating a XrdDocument + # doc = XrdDocument.new + # doc.expires = DateTime.new(2020, 1, 15, 0, 0, 1) + # doc.subject = "http://example.tld/articles/11"" + # doc.aliases << "http://example.tld/cool_article" + # doc.aliases << "http://example.tld/authors/2/articles/3" + # doc.properties["http://x.example.tld/ns/version"] = "1.3" + # doc.links << { rel: "author", type: "text/html", href: "http://example.tld/authors/2" } + # doc.links << { rel: "copyright", template: "http://example.tld/copyright?id={uri}" } + # + # doc.to_xml + # + # @example Parsing a XrdDocument + # data = XrdDocument.xml_data(xml_string) + # + # @see http://docs.oasis-open.org/xri/xrd/v1.0/xrd-1.0.html Extensible Resource Descriptor (XRD) Version 1.0 + class XrdDocument + # xml namespace url + XMLNS = "http://docs.oasis-open.org/ns/xri/xrd-1.0" + + # +Link+ element attributes + LINK_ATTRS = %i(rel type href template) + + # format string for datetime (+Expires+ element) + DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + attr_writer :expires, :subject + + # @return [Array] list of alias URIs + attr_reader :aliases + + # @return [Hash mixed>] list of properties. Hash key represents the + # +type+ attribute, and the value is the element content + attr_reader :properties + + # @return [Array val>>] list of +Link+ element hashes. Each + # hash contains the attributesa and their associated values for the +Link+ + # element. + attr_reader :links + + def initialize + @aliases = [] + @links = [] + @properties = {} + end + + ## + # Generates an XML document from the current instance and returns it as string + # @return [String] XML document + def to_xml + builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| + xml.XRD("xmlns" => XMLNS) { + if !@expires.nil? && @expires.instance_of?(DateTime) + xml.Expires(@expires.strftime(DATETIME_FORMAT)) + end + + xml.Subject(@subject) if !@subject.nil? && !@subject.empty? + + @aliases.each do |a| + next if !a.instance_of?(String) || a.empty? + xml.Alias(a.to_s) + end + + @properties.each do |type, val| + xml.Property(val.to_s, type: type) + end + + @links.each do |l| + attrs = {} + LINK_ATTRS.each do |attr| + attrs[attr.to_s] = l[attr] if l.key?(attr) + end + xml.Link(attrs) + end + } + end + builder.to_xml + end + + ## + # Parse the XRD document from the given string and create a hash containing + # the extracted data. + # + # Small bonus: the hash structure that comes out of this method is the same + # as the one used to produce a JRD (JSON Resource Descriptor) or parsing it. + # + # @param [String] xrd_doc XML string + # @return [Hash] extracted data + # @raise [InvalidDocument] if the XRD is malformed + def self.xml_data(xrd_doc) + raise ArgumentError unless xrd_doc.instance_of?(String) + + doc = Nokogiri::XML::Document.parse(xrd_doc) + raise InvalidDocument, "Not an XRD document" if !doc.root || doc.root.name != "XRD" + + data = {} + ns = {xrd: XMLNS} + + exp_elem = doc.at_xpath("xrd:XRD/xrd:Expires", ns) + unless exp_elem.nil? + data[:expires] = DateTime.strptime(exp_elem.content, DATETIME_FORMAT) + end + + subj_elem = doc.at_xpath("xrd:XRD/xrd:Subject", ns) + data[:subject] = subj_elem.content unless subj_elem.nil? + + aliases = [] + doc.xpath("xrd:XRD/xrd:Alias", ns).each do |node| + aliases << node.content + end + data[:aliases] = aliases unless aliases.empty? + + properties = {} + doc.xpath("xrd:XRD/xrd:Property", ns).each do |node| + properties[node[:type]] = node.children.empty? ? nil : node.content + end + data[:properties] = properties unless properties.empty? + + links = [] + doc.xpath("xrd:XRD/xrd:Link", ns).each do |node| + link = {} + LINK_ATTRS.each do |attr| + link[attr] = node[attr.to_s] if node.key?(attr.to_s) + end + links << link + end + data[:links] = links unless links.empty? + + data + end + + # Raised, if the XML structure is invalid + class InvalidDocument < RuntimeError + end + end + end +end diff --git a/spec/controllers/diaspora_federation/webfinger_controller_spec.rb b/spec/controllers/diaspora_federation/webfinger_controller_spec.rb index 3410850..5a565e4 100644 --- a/spec/controllers/diaspora_federation/webfinger_controller_spec.rb +++ b/spec/controllers/diaspora_federation/webfinger_controller_spec.rb @@ -3,22 +3,29 @@ module DiasporaFederation routes { DiasporaFederation::Engine.routes } describe "#host_meta" do + before do + DiasporaFederation.server_uri = URI("http://localhost:3000/") + end + it "succeeds" do get :host_meta expect(response).to be_success end it "contains the webfinger-template" do - DiasporaFederation.server_uri = "http://localhost:3000/" get :host_meta expect(response.body).to include "template=\"http://localhost:3000/webfinger?q={uri}\"" end - it "renders the host_meta template" do + it "returns a application/xrd+xml" do get :host_meta - expect(response).to render_template("host_meta") expect(response.header["Content-Type"]).to include "application/xrd+xml" end + + it "calls WebFinger::HostMeta.from_base_url with the base url" do + expect(WebFinger::HostMeta).to receive(:from_base_url).with("http://localhost:3000/").and_call_original + get :host_meta + end end describe "#legacy_webfinger" do diff --git a/spec/lib/webfinger/host_meta_spec.rb b/spec/lib/webfinger/host_meta_spec.rb new file mode 100644 index 0000000..7fc74ca --- /dev/null +++ b/spec/lib/webfinger/host_meta_spec.rb @@ -0,0 +1,78 @@ +module DiasporaFederation + describe WebFinger::HostMeta do + base_url = "https://pod.example.tld/" + xml = <<-XML + + + + +XML + + it "must not create blank instances" do + expect { WebFinger::HostMeta.new }.to raise_error(NoMethodError) + end + + context "#to_xml" do + it "creates a nice XML document" do + hm = WebFinger::HostMeta.from_base_url(base_url) + expect(hm.to_xml).to eq(xml) + end + + it "appends a '/' if necessary" do + hm = WebFinger::HostMeta.from_base_url("https://pod.example.tld") + expect(hm.to_xml).to eq(xml) + end + + it "fails if the base_url was omitted" do + expect { WebFinger::HostMeta.from_base_url("") }.to raise_error(WebFinger::HostMeta::InvalidData) + end + end + + context "#webfinger_template_url" do + it "parses its own output" do + hm = WebFinger::HostMeta.from_xml(xml) + expect(hm.webfinger_template_url).to eq("#{base_url}webfinger?q={uri}") + end + + it "also reads old-style XML" do + historic_xml = <<-XML + + + + + + + + +XML + hm = WebFinger::HostMeta.from_xml(historic_xml) + expect(hm.webfinger_template_url).to eq("#{base_url}webfinger?q={uri}") + end + + it "fails if the document does not contain a webfinger url" do + invalid_xml = < + + +XML + expect { WebFinger::HostMeta.from_xml(invalid_xml) }.to raise_error(WebFinger::HostMeta::InvalidData) + end + + it "fails if the document contains a malformed webfinger url" do + invalid_xml = < + + + +XML + expect { WebFinger::HostMeta.from_xml(invalid_xml) }.to raise_error(WebFinger::HostMeta::InvalidData) + end + + it "fails if the document is invalid" do + expect { WebFinger::HostMeta.from_xml("") }.to raise_error(WebFinger::XrdDocument::InvalidDocument) + end + end + end +end diff --git a/spec/lib/webfinger/xrd_document_spec.rb b/spec/lib/webfinger/xrd_document_spec.rb new file mode 100644 index 0000000..3185b06 --- /dev/null +++ b/spec/lib/webfinger/xrd_document_spec.rb @@ -0,0 +1,71 @@ +module DiasporaFederation + describe WebFinger::XrdDocument do + xml = < + + 2010-01-30T09:30:00Z + http://blog.example.com/article/id/314 + http://blog.example.com/cool_new_thing + http://blog.example.com/steve/article/7 + 1.3 + + + + + +XML + + data = { + subject: "http://blog.example.com/article/id/314", + expires: DateTime.parse("2010-01-30T09:30:00Z"), + aliases: %w( + http://blog.example.com/cool_new_thing + http://blog.example.com/steve/article/7 + ), + properties: { + "http://blgx.example.net/ns/version" => "1.3", + "http://blgx.example.net/ns/ext" => nil + }, + links: [ + { + rel: "author", + type: "text/html", + href: "http://blog.example.com/author/steve" + }, + { + rel: "author", + href: "http://example.com/author/john" + }, + { + rel: "copyright", + template: "http://example.com/copyright?id={uri}" + } + ] + } + + it "creates the xml document" do + doc = WebFinger::XrdDocument.new + doc.expires = data[:expires] + doc.subject = data[:subject] + + data[:aliases].each do |a| + doc.aliases << a + end + + data[:properties].each do |t, v| + doc.properties[t] = v + end + + data[:links].each do |h| + doc.links << h + end + + expect(doc.to_xml).to eq(xml) + end + + it "reads the xml document" do + doc = WebFinger::XrdDocument.xml_data(xml) + expect(doc).to eq(data) + end + end +end