diff --git a/.rubocop.yml b/.rubocop.yml index 3ecc6cb..db03767 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,13 +11,16 @@ Metrics/LineLength: # Too short methods lead to extraction of single-use methods, which can make # the code easier to read (by naming things), but can also clutter the class -Metrics/MethodLength: +Metrics/MethodLength: Max: 20 # The guiding principle of classes is SRP, SRP can't be accurately measured by LoC Metrics/ClassLength: Max: 1500 +Metrics/ModuleLength: + Max: 1500 + # No space makes the method definition shorter and differentiates # from a regular assignment. Style/SpaceAroundEqualsInParameterDefault: @@ -66,7 +69,7 @@ Lint/AssignmentInCondition: AllowSafeAssignment: false # A specialized exception class will take one or more arguments and construct the message from it. -# So both variants make sense. +# So both variants make sense. Style/RaiseArgs: Enabled: false @@ -124,11 +127,11 @@ Lint/ShadowingOuterLocalVariable: # Check with yard instead. Style/Documentation: - Enabled: false + Enabled: false # This is just silly. Calling the argument `other` in all cases makes no sense. Style/OpMethod: - Enabled: false + Enabled: false # There are valid cases, for example debugging Cucumber steps, # also they'll fail CI anyway diff --git a/lib/diaspora_federation/web_finger.rb b/lib/diaspora_federation/web_finger.rb index 71326f0..5c52b98 100644 --- a/lib/diaspora_federation/web_finger.rb +++ b/lib/diaspora_federation/web_finger.rb @@ -10,3 +10,4 @@ end require "diaspora_federation/web_finger/exceptions" require "diaspora_federation/web_finger/xrd_document" require "diaspora_federation/web_finger/host_meta" +require "diaspora_federation/web_finger/web_finger" diff --git a/lib/diaspora_federation/web_finger/web_finger.rb b/lib/diaspora_federation/web_finger/web_finger.rb new file mode 100644 index 0000000..85fd2f7 --- /dev/null +++ b/lib/diaspora_federation/web_finger/web_finger.rb @@ -0,0 +1,203 @@ +module DiasporaFederation + module WebFinger + ## + # The WebFinger document used for Diaspora* user discovery is based on an older + # draft of the specification you can find in the wiki of the "webfinger" project + # on {http://code.google.com/p/webfinger/wiki/WebFingerProtocol Google Code} + # (from around 2010). + # + # In the meantime an actual RFC draft has been in development, which should + # serve as a base for all future changes of this implementation. + # + # @example Creating a WebFinger document from account data + # wf = WebFinger.from_account({ + # acct_uri: "acct:user@server.example", + # alias_url: "https://server.example/people/0123456789abcdef", + # hcard_url: "https://server.example/hcard/users/user", + # seed_url: "https://server.example/", + # profile_url: "https://server.example/u/user", + # updates_url: "https://server.example/public/user.atom", + # guid: "0123456789abcdef", + # pubkey: "ABCDEF==" + # }) + # xml_string = wf.to_xml + # + # @example Creating a WebFinger instance from an xml document + # wf = WebFinger.from_xml(xml_string) + # ... + # hcard_url = wf.hcard_url + # ... + # + # @see http://tools.ietf.org/html/draft-jones-appsawg-webfinger "WebFinger" - + # current draft + # @see http://code.google.com/p/webfinger/wiki/CommonLinkRelations + # @see http://www.iana.org/assignments/link-relations/link-relations.xhtml + # official list of IANA link relations + class WebFinger + private_class_method :new + + attr_reader :acct_uri, :alias_url, :hcard_url, :seed_url, :profile_url, :updates_url + + # @deprecated Either convert these to +Property+ elements or move to the + # +hCard+, which actually has fields for an +UID+ and +KEY+ defined in + # the +vCard+ specification (will affect older Diaspora* installations). + attr_reader :guid, :pubkey + + # +hcard+ link relation + REL_HCARD = "http://microformats.org/profile/hcard" + + # +seed_location+ link relation + REL_SEED = "http://joindiaspora.com/seed_location" + + # @deprecated This should be a +Property+ or moved to the +hCard+, but +Link+ + # is inappropriate according to the specification (will affect older + # Diaspora* installations). + # +guid+ link relation + REL_GUID = "http://joindiaspora.com/guid" + + # +profile-page+ link relation. + # @note This might just as well be an +Alias+ instead of a +Link+. + REL_PROFILE = "http://webfinger.net/rel/profile-page" + + # Atom feed link relation + REL_UPDATES = "http://schemas.google.com/g/2010#updates-from" + + # @deprecated This should be a +Property+ or moved to the +hcard+, but +Link+ + # is inappropriate according to the specification (will affect older + # Diaspora* installations). + # +diaspora-public-key+ link relation + REL_PUBKEY = "diaspora-public-key" + + # Create the XML string from the current WebFinger instance + # @return [String] XML string + def to_xml + doc = XrdDocument.new + doc.subject = @acct_uri + doc.aliases << @alias_url + + add_links_to(doc) + + doc.to_xml + end + + # Create a WebFinger instance from the given account data Hash. + # @param [Hash] data account data + # @return [WebFinger] WebFinger instance + # @raise [InvalidData] if the given data Hash is invalid or incomplete + def self.from_account(data) + raise InvalidData unless account_data_complete?(data) + + wf = allocate + wf.instance_eval { + @acct_uri = data[:acct_uri] + @alias_url = data[:alias_url] + @hcard_url = data[:hcard_url] + @seed_url = data[:seed_url] + @profile_url = data[:profile_url] + @updates_url = data[:updates_url] + + # TODO: change me! ######### + @guid = data[:guid] + @pubkey = data[:pubkey] + ############################# + } + wf + end + + # Create a WebFinger instance from the given XML string. + # @param [String] webfinger_xml WebFinger XML string + # @return [WebFinger] WebFinger instance + def self.from_xml(webfinger_xml) + data = XrdDocument.xml_data(webfinger_xml) + raise InvalidData unless xml_data_valid?(data) + + hcard, seed, guid, profile, updates, pubkey = parse_links(data) + + wf = allocate + wf.instance_eval { + @acct_uri = data[:subject] + @alias_url = data[:aliases].first + @hcard_url = hcard[:href] + @seed_url = seed[:href] + @profile_url = profile[:href] + @updates_url = updates[:href] + + # TODO: change me! ########## + @guid = guid[:href] + @pubkey = pubkey[:href] + ############################## + } + wf + end + + private + + # Checks the given account data Hash for correct type and completeness. + # @param [Hash] data account data + # @return [Boolean] validation result + def self.account_data_complete?(data) + !data.nil? && data.instance_of?(Hash) && + data.key?(:acct_uri) && data.key?(:alias_url) && + data.key?(:hcard_url) && data.key?(:seed_url) && + data.key?(:guid) && data.key?(:profile_url) && + data.key?(:updates_url) && data.key?(:pubkey) + end + private_class_method :account_data_complete? + + # Does some rudimentary checking on the data Hash produced from parsing the + # XML string + # @param [Hash] data XML data + # @return [Boolean] validation result + def self.xml_data_valid?(data) + data.key?(:subject) && data.key?(:aliases) && data.key?(:links) + end + private_class_method :xml_data_valid? + + def add_links_to(doc) + doc.links << {rel: REL_HCARD, + type: "text/html", + href: @hcard_url} + doc.links << {rel: REL_SEED, + type: "text/html", + href: @seed_url} + + # TODO: change me! ############## + doc.links << {rel: REL_GUID, + type: "text/html", + href: @guid} + ################################## + + doc.links << {rel: REL_PROFILE, + type: "text/html", + href: @profile_url} + doc.links << {rel: REL_UPDATES, + type: "application/atom+xml", + href: @updates_url} + + # TODO: change me! ############## + doc.links << {rel: REL_PUBKEY, + type: "RSA", + href: @pubkey} + ################################## + end + + def self.parse_links(data) + links = data[:links] + hcard = parse_link(links, REL_HCARD) + seed = parse_link(links, REL_SEED) + guid = parse_link(links, REL_GUID) + profile = parse_link(links, REL_PROFILE) + updates = parse_link(links, REL_UPDATES) + pubkey = parse_link(links, REL_PUBKEY) + raise InvalidData unless [hcard, seed, guid, profile, updates, pubkey].all? + [hcard, seed, guid, profile, updates, pubkey] + end + private_class_method :parse_links + + def self.parse_link(links, rel) + links.find {|l| l[:rel] == rel } + end + private_class_method :parse_link + end + end +end diff --git a/spec/lib/web_finger/web_finger_spec.rb b/spec/lib/web_finger/web_finger_spec.rb new file mode 100644 index 0000000..ba3d23a --- /dev/null +++ b/spec/lib/web_finger/web_finger_spec.rb @@ -0,0 +1,117 @@ +module DiasporaFederation + describe WebFinger::WebFinger do + acct = "acct:user@pod.example.tld" + alias_url = "http://pod.example.tld/" + hcard_url = "https://pod.example.tld/hcard/users/abcdef0123456789" + seed_url = "https://pod.geraspora.de/" + guid = "abcdef0123456789" + profile_url = "https://pod.example.tld/u/user" + updates_url = "https://pod.example.tld/public/user.atom" + pubkey = "AAAAAA==" + + xml = <<-XML + + + #{acct} + #{alias_url} + + + + + + + +XML + + it "must not create blank instances" do + expect { WebFinger::WebFinger.new }.to raise_error + end + + context "generation" do + it "creates a nice XML document" do + wf = WebFinger::WebFinger.from_account( + acct_uri: acct, + alias_url: alias_url, + hcard_url: hcard_url, + seed_url: seed_url, + profile_url: profile_url, + updates_url: updates_url, + guid: guid, + pubkey: pubkey + ) + expect(wf.to_xml).to eq(xml) + end + + it "fails if some params are missing" do + expect { + WebFinger::WebFinger.from_account( + acct_uri: acct, + alias_url: alias_url, + hcard_url: hcard_url + ) + }.to raise_error(WebFinger::InvalidData) + end + + it "fails if nothing was given" do + expect { WebFinger::WebFinger.from_account({}) }.to raise_error(WebFinger::InvalidData) + end + end + + context "parsing" do + it "reads its own output" do + wf = WebFinger::WebFinger.from_xml(xml) + expect(wf.acct_uri).to eq(acct) + expect(wf.alias_url).to eq(alias_url) + expect(wf.hcard_url).to eq(hcard_url) + expect(wf.seed_url).to eq(seed_url) + expect(wf.profile_url).to eq(profile_url) + expect(wf.updates_url).to eq(updates_url) + + expect(wf.guid).to eq(guid) + expect(wf.pubkey).to eq(pubkey) + end + + it "reads old-style XML" do + historic_xml = <<-XML + + + #{acct} + #{alias_url} + + + + + + + + + +XML + + wf = WebFinger::WebFinger.from_xml(historic_xml) + expect(wf.acct_uri).to eq(acct) + expect(wf.alias_url).to eq(alias_url) + expect(wf.hcard_url).to eq(hcard_url) + expect(wf.seed_url).to eq(seed_url) + expect(wf.profile_url).to eq(profile_url) + expect(wf.updates_url).to eq(updates_url) + + expect(wf.guid).to eq(guid) + expect(wf.pubkey).to eq(pubkey) + end + + it "fails if the document is empty" do + invalid_xml = < + + +XML + expect { WebFinger::WebFinger.from_xml(invalid_xml) }.to raise_error(WebFinger::InvalidData) + end + + it "fails if the document is not XML" do + expect { WebFinger::WebFinger.from_xml("") }.to raise_error(WebFinger::InvalidDocument) + end + end + end +end