diff --git a/lib/diaspora_federation/web_finger.rb b/lib/diaspora_federation/web_finger.rb index 5c52b98..2df92fd 100644 --- a/lib/diaspora_federation/web_finger.rb +++ b/lib/diaspora_federation/web_finger.rb @@ -11,3 +11,4 @@ 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" +require "diaspora_federation/web_finger/h_card" diff --git a/lib/diaspora_federation/web_finger/exceptions.rb b/lib/diaspora_federation/web_finger/exceptions.rb index 1b34db8..f61f5c0 100644 --- a/lib/diaspora_federation/web_finger/exceptions.rb +++ b/lib/diaspora_federation/web_finger/exceptions.rb @@ -11,6 +11,8 @@ module DiasporaFederation # * if the +webfinger_url+ is missing or malformed in {HostMeta.from_base_url} or {HostMeta.from_xml} # * if the +data+ given to {WebFinger.from_person} is an invalid type or doesn't contain all required entries # * if the parsed XML from {WebFinger.from_xml} is incomplete + # * if the params passed to {HCard.from_account} or {HCard.from_html} + # are in some way malformed, invalid or incomplete. class InvalidData < RuntimeError end end diff --git a/lib/diaspora_federation/web_finger/h_card.rb b/lib/diaspora_federation/web_finger/h_card.rb new file mode 100644 index 0000000..2b566a4 --- /dev/null +++ b/lib/diaspora_federation/web_finger/h_card.rb @@ -0,0 +1,272 @@ +module DiasporaFederation + module WebFinger + ## + # This class provides the means of generating an parsing account data to and + # from the hCard format. + # hCard is based on +RFC 2426+ (vCard) which got superseded by +RFC 6350+. + # There is a draft for a new h-card format specification, that makes use of + # the new vCard standard. + # + # @note The current implementation contains a huge amount of legacy elements + # and classes, that should be removed and cleaned up in later iterations. + # + # @todo This needs some radical restructuring. The generated HTML is not + # correctly nested according to the hCard standard and class names are + # partially wrong. Also, apart from that, it's just ugly. + # + # @example Creating a hCard document from account data + # hc = HCard.from_profile({ + # guid: "0123456789abcdef", + # diaspora_handle: "user@server.example", + # full_name: "User Name", + # url: "https://server.example/", + # photo_full_url: "https://server.example/uploads/f.jpg", + # photo_medium_url: "https://server.example/uploads/m.jpg", + # photo_small_url: "https://server.example/uploads/s.jpg", + # pubkey: "-----BEGIN PUBLIC KEY-----\nABCDEF==\n-----END PUBLIC KEY-----", + # searchable: true, + # first_name: "User", + # last_name: "Name" + # }) + # html_string = hc.to_html + # + # @example Create a HCard instance from an hCard document + # hc = HCard.from_html(html_string) + # ... + # full_name = hc.full_name + # ... + # + # @see http://microformats.org/wiki/hCard "hCard 1.0" + # @see http://microformats.org/wiki/h-card "h-card" (draft) + # @see http://www.ietf.org/rfc/rfc2426.txt "vCard MIME Directory Profile" (obsolete) + # @see http://www.ietf.org/rfc/rfc6350.txt "vCard Format Specification" + class HCard + private_class_method :new + + attr_reader :guid, :nickname, :full_name, :url, :pubkey, + :photo_full_url, :photo_medium_url, :photo_small_url + + # @deprecated We decided to only use one name field, these should be removed + # in later iterations (will affect older Diaspora* installations). + attr_reader :first_name, :last_name + + # @deprecated As this is a simple property, consider move to WebFinger instead + # of HCard. vCard has no comparable field for this information, but + # Webfinger may declare arbitrary properties (will affect older Diaspora* + # installations). + attr_reader :searchable + + # CSS selectors for finding all the hCard fields + SELECTORS = { + uid: ".uid", + nickname: ".nickname", + fn: ".fn", + given_name: ".given_name", + family_name: ".family_name", + url: "#pod_location[href]", + photo: ".entity_photo .photo[src]", + photo_medium: ".entity_photo_medium .photo[src]", + photo_small: ".entity_photo_small .photo[src]", + key: ".key", + searchable: ".searchable" + } + + # Create the HTML string from the current HCard instance + # @return [String] HTML string + def to_html + builder = create_builder + + content = builder.doc.at_css("#content_inner") + + add_simple_property(content, :uid, "uid", @guid) + add_simple_property(content, :nickname, "nickname", @nickname) + add_simple_property(content, :full_name, "fn", @full_name) + add_simple_property(content, :searchable, "searchable", @searchable) + add_simple_property(content, :key, "key", @pubkey) + + # TODO: change me! ################### + add_simple_property(content, :first_name, "given_name", @first_name) + add_simple_property(content, :family_name, "family_name", @last_name) + ####################################### + + add_property(content, :url) do |html| + html.a(@url.to_s, id: "pod_location", class: "url", rel: "me", href: @url.to_s) + end + + add_photos(content) + + builder.doc.to_xhtml(indent: 2, indent_text: " ") + end + + # Creates a new HCard instance from the given Hash containing profile data + # @param [Hash] data account data + # @return [HCard] HCard instance + # @raise [InvalidData] if the account data Hash is invalid or incomplete + def self.from_profile(data) + raise InvalidData unless account_data_complete?(data) + + hc = allocate + hc.instance_eval { + @guid = data[:guid] + @nickname = data[:diaspora_handle].split("@").first + @full_name = data[:full_name] + @url = data[:url] + @photo_full_url = data[:photo_full_url] + @photo_medium_url = data[:photo_medium_url] + @photo_small_url = data[:photo_small_url] + @pubkey = data[:pubkey] + @searchable = data[:searchable] + + # TODO: change me! ################### + @first_name = data[:first_name] + @last_name = data[:last_name] + ####################################### + } + hc + end + + # Creates a new HCard instance from the given HTML string. + # @param html_string [String] HTML string + # @return [HCard] HCard instance + # @raise [InvalidData] if the HTML string is invalid or incomplete + def self.from_html(html_string) + doc = parse_html_and_validate(html_string) + + hc = allocate + hc.instance_eval { + @guid = content_from_doc(doc, :uid) + @nickname = content_from_doc(doc, :nickname) + @full_name = content_from_doc(doc, :fn) + @url = element_from_doc(doc, :url)["href"] + @photo_full_url = photo_from_doc(doc, :photo) + @photo_medium_url = photo_from_doc(doc, :photo_medium) + @photo_small_url = photo_from_doc(doc, :photo_small) + @pubkey = content_from_doc(doc, :key) unless element_from_doc(doc, :key).nil? + @searchable = content_from_doc(doc, :searchable) + + # TODO: change me! ################### + @first_name = content_from_doc(doc, :given_name) + @last_name = content_from_doc(doc, :family_name) + ####################################### + } + hc + end + + private + + # Creates the base HCard html structure + # @return [Nokogiri::HTML::Builder] HTML Builder instance + def create_builder + Nokogiri::HTML::Builder.new do |html| + html.html { + html.head { + html.meta(charset: "UTF-8") + html.title(@full_name) + } + + html.body { + html.div(id: "content") { + html.h1(@full_name) + html.div(id: "content_inner", class: "entity_profile vcard author") { + html.h2("User profile") + } + } + } + } + end + end + + # Add a property to the hCard document. The element will be added to the given + # container element and a "definition list" structure will be created around + # it. A Nokogiri::HTML::Builder instance will be passed to the given block, + # which should be used to add the element(s) containing the property data. + # + # @param container [Nokogiri::XML::Element] parent element for added property HTML + # @param name [Symbol] property name + # @param block [Proc] block returning an element + def add_property(container, name, &block) + Nokogiri::HTML::Builder.with(container) do |html| + html.dl(class: "entity_#{name}") { + html.dt(name.to_s.capitalize) + html.dd { + block.call(html) + } + } + end + end + + # Calls {HCard#add_property} for a simple text property. + # @param container [Nokogiri::XML::Element] parent element + # @param name [Symbol] property name + # @param class_name [String] HTML class name + # @param value [#to_s] property value + # @see HCard#add_property + def add_simple_property(container, name, class_name, value) + add_property(container, name) do |html| + html.span(value.to_s, class: class_name) + end + end + + # Calls {HCard#add_property} to add the photos + # @param container [Nokogiri::XML::Element] parent element + # @see HCard#add_property + def add_photos(container) + add_property(container, :photo) do |html| + html.img(class: "photo avatar", width: "300", height: "300", src: @photo_full_url.to_s) + end + + add_property(container, :photo_medium) do |html| + html.img(class: "photo avatar", width: "100", height: "100", src: @photo_medium_url.to_s) + end + + add_property(container, :photo_small) do |html| + html.img(class: "photo avatar", width: "50", height: "50", src: @photo_small_url.to_s) + end + end + + # 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.instance_of?(Hash) && data.key?(:guid) && + data.key?(:diaspora_handle) && data.key?(:full_name) && + data.key?(:url) && data.key?(:photo_full_url) && + data.key?(:photo_medium_url) && data.key?(:photo_small_url) && + data.key?(:pubkey) && data.key?(:searchable) && + data.key?(:first_name) && data.key?(:last_name) + end + private_class_method :account_data_complete? + + # Make sure some of the most important elements are present in the parsed + # HTML document. + # @param [LibXML::XML::Document] doc HTML document + # @return [Boolean] validation result + def self.html_document_complete?(doc) + !(doc.at_css(SELECTORS[:fn]).nil? || doc.at_css(SELECTORS[:nickname]).nil? || + doc.at_css(SELECTORS[:url]).nil? || doc.at_css(SELECTORS[:photo]).nil?) + end + private_class_method :html_document_complete? + + def self.parse_html_and_validate(html_string) + raise ArgumentError, "hcard html is not a string" unless html_string.instance_of?(String) + + doc = Nokogiri::HTML::Document.parse(html_string) + raise InvalidData, "hcard html incomplete" unless html_document_complete?(doc) + doc + end + private_class_method :parse_html_and_validate + + def element_from_doc(doc, selector) + doc.at_css(SELECTORS[selector]) + end + + def content_from_doc(doc, content_selector) + element_from_doc(doc, content_selector).content + end + + def photo_from_doc(doc, photo_selector) + element_from_doc(doc, photo_selector)["src"] + end + end + end +end diff --git a/lib/diaspora_federation/web_finger/web_finger.rb b/lib/diaspora_federation/web_finger/web_finger.rb index 278b363..8b4427c 100644 --- a/lib/diaspora_federation/web_finger/web_finger.rb +++ b/lib/diaspora_federation/web_finger/web_finger.rb @@ -123,7 +123,7 @@ module DiasporaFederation doc.to_xml end - # Create a WebFinger instance from the given account data Hash. + # Create a WebFinger instance from the given person data Hash. # @param [Hash] data account data # @return [WebFinger] WebFinger instance # @raise [InvalidData] if the given data Hash is invalid or incomplete diff --git a/spec/lib/web_finger/h_card_spec.rb b/spec/lib/web_finger/h_card_spec.rb new file mode 100644 index 0000000..81f3beb --- /dev/null +++ b/spec/lib/web_finger/h_card_spec.rb @@ -0,0 +1,250 @@ +module DiasporaFederation + describe WebFinger::HCard do + let(:guid) { "abcdef0123456789" } + let(:handle) { "user@pod.example.tld" } + let(:first_name) { "Test" } + let(:last_name) { "Testington" } + let(:name) { "#{first_name} #{last_name}" } + let(:url) { "https://pod.example.tld/users/me" } + let(:photo_url) { "https://pod.example.tld/uploads/f.jpg" } + let(:photo_url_m) { "https://pod.example.tld/uploads/m.jpg" } + let(:photo_url_s) { "https://pod.example.tld/uploads/s.jpg" } + let(:key) { "-----BEGIN PUBLIC KEY-----\nABCDEF==\n-----END PUBLIC KEY-----" } + let(:searchable) { true } + + let(:html) { + <<-HTML + + + + + + #{name} + + +
+

#{name}

+
+

User profile

+
+
Uid
+
+ #{guid} +
+
+
+
Nickname
+
+ #{handle.split('@').first} +
+
+
+
Full_name
+
+ #{name} +
+
+
+
Searchable
+
+ #{searchable} +
+
+
+
Key
+
+ #{key} +
+
+
+
First_name
+
+ #{first_name} +
+
+
+
Family_name
+
+ #{last_name} +
+
+
+
Url
+
+ #{url} +
+
+
+
Photo
+
+ +
+
+
+
Photo_medium
+
+ +
+
+
+
Photo_small
+
+ +
+
+
+
+ + +HTML + } + + it "must not create blank instances" do + expect { WebFinger::HCard.new }.to raise_error(NameError) + end + + context "generation" do + it "creates an instance from a data hash" do + hc = WebFinger::HCard.from_profile( + guid: guid, + diaspora_handle: handle, + full_name: name, + url: url, + photo_full_url: photo_url, + photo_medium_url: photo_url_m, + photo_small_url: photo_url_s, + pubkey: key, + searchable: searchable, + first_name: first_name, + last_name: last_name + ) + expect(hc.to_html).to eq(html) + end + + it "fails if some params are missing" do + expect { + WebFinger::HCard.from_profile( + guid: guid, + diaspora_handle: handle + ) + }.to raise_error(WebFinger::InvalidData) + end + + it "fails if nothing was given" do + expect { WebFinger::HCard.from_profile({}) }.to raise_error(WebFinger::InvalidData) + end + + it "fails if nil was given" do + expect { WebFinger::HCard.from_profile(nil) }.to raise_error(WebFinger::InvalidData) + end + end + + context "parsing" do + it "reads its own output" do + hc = WebFinger::HCard.from_html(html) + expect(hc.guid).to eq(guid) + expect(hc.nickname).to eq(handle.split("@").first) + expect(hc.full_name).to eq(name) + expect(hc.url).to eq(url) + expect(hc.photo_full_url).to eq(photo_url) + expect(hc.photo_medium_url).to eq(photo_url_m) + expect(hc.photo_small_url).to eq(photo_url_s) + expect(hc.pubkey).to eq(key) + expect(hc.searchable).to eq(searchable.to_s) + + expect(hc.first_name).to eq(first_name) + expect(hc.last_name).to eq(last_name) + end + + it "reads old-style HTML" do + historic_html = <<-HTML +
+

#{name}

+
+
+

User profile

+
+
Nickname
+
+#{name} +
+
+
+
First name
+
+#{first_name} +
+
+
+
Family name
+
+#{last_name} +
+
+
+
Full name
+
+#{name} +
+
+
+
URL
+
+#{url} +
+
+
+
Photo
+
+ +
+
+
+
Photo
+
+ +
+
+
+
Photo
+
+ +
+
+
+
Searchable
+
+#{searchable} +
+
+
+
+
+HTML + + hc = WebFinger::HCard.from_html(historic_html) + expect(hc.url).to eq(url) + expect(hc.photo_full_url).to eq(photo_url) + expect(hc.photo_medium_url).to eq(photo_url_m) + expect(hc.photo_small_url).to eq(photo_url_s) + expect(hc.searchable).to eq(searchable.to_s) + + expect(hc.first_name).to eq(first_name) + expect(hc.last_name).to eq(last_name) + end + + it "fails if the document is incomplete" do + invalid_html = <<-HTML +
+ #{name} +
+HTML + expect { WebFinger::HCard.from_html(invalid_html) }.to raise_error(WebFinger::InvalidData) + end + + it "fails if the document is not HTML" do + expect { WebFinger::HCard.from_html("") }.to raise_error(WebFinger::InvalidData) + end + end + end +end diff --git a/spec/lib/web_finger/web_finger_spec.rb b/spec/lib/web_finger/web_finger_spec.rb index c5e5971..dc4d349 100644 --- a/spec/lib/web_finger/web_finger_spec.rb +++ b/spec/lib/web_finger/web_finger_spec.rb @@ -27,7 +27,7 @@ module DiasporaFederation XML it "must not create blank instances" do - expect { WebFinger::WebFinger.new }.to raise_error + expect { WebFinger::WebFinger.new }.to raise_error(NameError) end context "generation" do