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