lib/diaspora_federation/discovery/h_card.rb
# frozen_string_literal: true

module DiasporaFederation
  module Discovery
    # This class provides the means of generating and 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 a person hash
    #   hc = HCard.new(
    #     guid:             "0123456789abcdef",
    #     nickname:         "user",
    #     full_name:        "User Name",
    #     seed_url:         "https://server.example/",
    #     photo_large_url:  "https://server.example/uploads/l.jpg",
    #     photo_medium_url: "https://server.example/uploads/m.jpg",
    #     photo_small_url:  "https://server.example/uploads/s.jpg",
    #     public_key:       "-----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 < Entity
      # @!attribute [r] guid
      #   @see Entities::Person#guid
      #   @return [String] guid
      property :guid, :string

      # @!attribute [r] nickname
      #   The first part of the diaspora* ID
      #   @return [String] nickname
      property :nickname, :string, optional: true

      # @!attribute [r] full_name
      #   @return [String] display name of the user
      property :full_name, :string

      # @!attribute [r] public_key
      #   When a user is created on the pod, the pod MUST generate a pgp keypair
      #   for them. This key is used for signing messages. The format is a
      #   DER-encoded PKCS#1 key beginning with the text
      #   "-----BEGIN PUBLIC KEY-----" and ending with "-----END PUBLIC KEY-----".
      #
      #   @return [String] public key
      property :public_key, :string

      # @!attribute [r] photo_large_url
      #   @return [String] url to the big avatar (300x300)
      property :photo_large_url, :string
      # @!attribute [r] photo_medium_url
      #   @return [String] url to the medium avatar (100x100)
      property :photo_medium_url, :string
      # @!attribute [r] photo_small_url
      #   @return [String] url to the small avatar (50x50)
      property :photo_small_url, :string

      # @!attribute [r] first_name
      #   @deprecated We decided to only use one name field, these should be removed
      #     in later iterations (will affect older diaspora* installations).
      #
      #   @see #full_name
      #   @return [String] first name
      property :first_name, :string

      # @!attribute [r] last_name
      #   @deprecated We decided to only use one name field, these should be removed
      #     in later iterations (will affect older diaspora* installations).
      #
      #   @see #full_name
      #   @return [String] last name
      property :last_name, :string

      # @!attribute [r] searchable
      #   @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).
      #
      #   flag if a user is searchable by name
      #   @return [Boolean] searchable flag
      property :searchable, :boolean

      # 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"
      }.freeze

      # 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_property(content, :key) do |html|
          html.pre(@public_key.to_s, class: "key")
        end

        # TODO: remove me!  ###################
        add_simple_property(content, :first_name, "given_name", @first_name)
        add_simple_property(content, :family_name, "family_name", @last_name)
        #######################################

        add_photos(content)

        builder.doc.to_xhtml(indent: 2, indent_text: " ")
      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)

        new(
          guid:             content_from_doc(doc, :uid),
          nickname:         content_from_doc(doc, :nickname),
          full_name:        content_from_doc(doc, :fn),
          photo_large_url:  photo_from_doc(doc, :photo),
          photo_medium_url: photo_from_doc(doc, :photo_medium),
          photo_small_url:  photo_from_doc(doc, :photo_small),
          searchable:       (content_from_doc(doc, :searchable) == "true"),
          public_key:       content_from_doc(doc, :key),

          # TODO: remove first_name and last_name!
          first_name:       content_from_doc(doc, :given_name),
          last_name:        content_from_doc(doc, :family_name)
        )
      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
      # @yield [Nokogiri::HTML::Builder] html builder
      def add_property(container, name)
        Nokogiri::HTML::Builder.with(container) do |html|
          html.dl(class: "entity_#{name}") {
            html.dt(name.to_s.capitalize)
            html.dd {
              yield 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_large_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

      # 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
      private_class_method def self.html_document_complete?(doc)
        !(doc.at_css(SELECTORS[:fn]).nil? || doc.at_css(SELECTORS[:photo]).nil?)
      end

      private_class_method 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 def self.element_from_doc(doc, selector)
        doc.at_css(SELECTORS[selector])
      end

      private_class_method def self.content_from_doc(doc, content_selector)
        element = element_from_doc(doc, content_selector)
        element.content if element
      end

      private_class_method def self.photo_from_doc(doc, photo_selector)
        element_from_doc(doc, photo_selector)["src"]
      end
    end
  end
end
